From d5dfcc733cc5253a09563da7c4c91d76f7fa297a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Nikola=20Buci=C4=87?=
Date: Mon, 13 Apr 2026 16:36:40 +0200
Subject: [PATCH 01/10] ci: add PR test workflow
Runs the Pest suite on every pull request (opened, synchronize, reopened)
using PHP 8.5, SQLite in-memory, and a Composer cache for fast reruns.
Co-Authored-By: Claude Sonnet 4.6
---
.github/workflows/run-tests.yml | 39 +++++++++++++++++++++++++++++++++
1 file changed, 39 insertions(+)
create mode 100644 .github/workflows/run-tests.yml
diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml
new file mode 100644
index 0000000..a7b832a
--- /dev/null
+++ b/.github/workflows/run-tests.yml
@@ -0,0 +1,39 @@
+name: Run Tests
+
+on:
+ pull_request:
+ types: [opened, synchronize, reopened]
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: '8.5'
+ extensions: zip, sqlite3, pdo_sqlite
+ coverage: none
+
+ - name: Cache Composer dependencies
+ uses: actions/cache@v4
+ with:
+ path: vendor
+ key: ${{ runner.os }}-composer-${{ hashFiles('composer.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-composer-
+
+ - name: Install dependencies
+ run: composer install --no-interaction --prefer-dist --optimize-autoloader
+
+ - name: Copy environment file
+ run: cp .env.example .env
+
+ - name: Generate application key
+ run: php artisan key:generate
+
+ - name: Run tests
+ run: composer test
\ No newline at end of file
From d0e4a54c4c0b0a3f12e3c3c2270bad9b11f5f2ee Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Nikola=20Buci=C4=87?=
Date: Mon, 13 Apr 2026 16:37:02 +0200
Subject: [PATCH 02/10] ci: fix bump-version trigger branch and add
workflow_dispatch
Co-Authored-By: Claude Sonnet 4.6
---
.github/workflows/bump-version.yml | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/.github/workflows/bump-version.yml b/.github/workflows/bump-version.yml
index 6ccda15..eb666a5 100644
--- a/.github/workflows/bump-version.yml
+++ b/.github/workflows/bump-version.yml
@@ -2,7 +2,10 @@ name: Bump Version
on:
push:
- branches: [main]
+ branches:
+ - master
+ workflow_dispatch:
+
jobs:
bump:
From 7e4b996d1cd0d6995bb7692e26a304012e5055f0 Mon Sep 17 00:00:00 2001
From: Nikola Bucic
Date: Tue, 7 Apr 2026 00:32:54 +0200
Subject: [PATCH 03/10] Trying to run the program locally
---
app/Livewire/Library.php | 38 +-
app/Livewire/ProgramEditor.php | 217 +++++-------
app/Livewire/Settings.php | 38 +-
app/Livewire/TimerScreen.php | 303 +++++++---------
app/Timer/AppSettings.php | 74 ++++
app/Timer/TimerCursor.php | 144 ++++----
app/Timer/TimerProgram.php | 208 +++++++++++
app/Timer/TimerRunner.php | 330 ++++++++----------
config/app.php | 13 -
config/queue.php | 54 ---
...026_04_09_122314_create_sessions_table.php | 31 --
resources/js/app.js | 97 +++--
.../views/livewire/program-editor.blade.php | 21 +-
.../views/livewire/timer-screen.blade.php | 276 ++++-----------
tests/Unit/Timer/BeepLogicTest.php | 140 ++------
15 files changed, 930 insertions(+), 1054 deletions(-)
create mode 100644 app/Timer/AppSettings.php
create mode 100644 app/Timer/TimerProgram.php
delete mode 100644 config/queue.php
delete mode 100644 database/migrations/2026_04_09_122314_create_sessions_table.php
diff --git a/app/Livewire/Library.php b/app/Livewire/Library.php
index 620f5f2..c945711 100644
--- a/app/Livewire/Library.php
+++ b/app/Livewire/Library.php
@@ -4,12 +4,15 @@
namespace App\Livewire;
-use App\Models\Program;
-use App\Models\Setting;
+use App\Timer\AppSettings;
+use App\Timer\TimerProgram;
+use Illuminate\Support\Facades\Log;
use Illuminate\View\View;
+use JsonException;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
+use RuntimeException;
#[Layout('layouts.app')]
#[Title('Library — Interval Timer')]
@@ -18,6 +21,7 @@ class Library extends Component
public string $newName = '';
public bool $showCreate = false;
+ /** @var TimerProgram[] */
public array $programs = [];
public function mount(): void
@@ -27,15 +31,15 @@ public function mount(): void
public function loadPrograms(): void
{
- $this->programs = Program::with('phases')
- ->orderByRaw('COALESCE(last_used_at, created_at) DESC')
- ->get()
- ->toArray();
+ $this->programs = array_map(
+ static fn(TimerProgram $p) => $p->toArray(),
+ TimerProgram::all()
+ );
}
public function openCreate(): void
{
- $this->newName = '';
+ $this->newName = '';
$this->showCreate = true;
}
@@ -49,13 +53,13 @@ public function createProgram(): void
{
$this->validate(['newName' => 'required|string|max:60']);
- $settings = Setting::current();
+ $settings = AppSettings::load();
+ $program = TimerProgram::create(trim($this->newName));
+ $program->beepLeadIn = $settings->defaultBeepLeadIn;
+ $program->endSound = $settings->defaultEndSound;
+ $program->save();
- $program = Program::create([
- 'name' => trim($this->newName),
- 'beep_lead_in' => $settings->default_beep_lead_in,
- 'end_sound' => $settings->default_end_sound,
- ]);
+ Log::info('Message', ['data' => $this]);
$this->showCreate = false;
$this->newName = '';
@@ -65,7 +69,13 @@ public function createProgram(): void
public function deleteProgram(string $id): void
{
- Program::find($id)?->delete();
+ try {
+ $program = TimerProgram::load($id);
+ $program->delete();
+ } catch (RuntimeException|JsonException) {
+ // Already gone — ignore.
+ }
+
$this->loadPrograms();
}
diff --git a/app/Livewire/ProgramEditor.php b/app/Livewire/ProgramEditor.php
index 76559d8..9c2af1e 100644
--- a/app/Livewire/ProgramEditor.php
+++ b/app/Livewire/ProgramEditor.php
@@ -5,10 +5,12 @@
namespace App\Livewire;
use App\Enum\BeepLeadIn;
-use App\Models\Program;
-use App\Models\Setting;
+use App\Timer\AppSettings;
+use App\Timer\Phase;
+use App\Timer\TimerProgram;
use Illuminate\Validation\Rules\Enum;
use Illuminate\View\View;
+use JsonException;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
@@ -34,7 +36,7 @@ class ProgramEditor extends Component
public bool $showPhaseForm = false;
- /** @var array[] Raw phase arrays for display, mutated in-place */
+ /** @var array[] Raw phase arrays (toArray) for display, mutated in-place */
public array $phases = [];
// Color palette for quick-pick
@@ -49,39 +51,14 @@ class ProgramEditor extends Component
'#06b6d4', // cyan
];
- public function mount(string $id): void
- {
- if ($id === 'create') {
- $settings = Setting::current();
- $this->beepLeadIn = $settings->default_beep_lead_in;
- $this->endSound = $settings->default_end_sound;
- } else {
- $program = Program::with('phases')->findOrFail($id);
- $this->programId = $program->id;
- $this->name = $program->name;
- $this->beepLeadIn = $program->beep_lead_in;
- $this->endSound = $program->end_sound;
- $this->phases = $program->phases
- ->map(fn($p) => [
- 'label' => $p->label,
- 'duration' => $p->duration,
- 'repetitions' => $p->repetitions,
- 'pause' => $p->pause,
- 'cooldown' => $p->cooldown,
- 'color' => $p->color,
- ])
- ->all();
- }
- }
-
- // ── Phase form ────────────────────────────────────────────────────────
-
public function cancelPhaseForm(): void
{
$this->showPhaseForm = false;
$this->resetPhaseForm();
}
+ // ── Phase form ────────────────────────────────────────────────────────
+
public function deletePhase(int $index): void
{
array_splice($this->phases, $index, 1);
@@ -90,16 +67,58 @@ public function deletePhase(int $index): void
public function editPhase(int $index): void
{
$p = $this->phases[$index];
- $this->phaseLabel = $p['label'];
+ $this->phaseLabel = $p['label'];
$this->phaseDuration = $p['duration'];
- $this->phaseReps = $p['repetitions'];
- $this->phasePause = $p['pause'];
+ $this->phaseReps = $p['repetitions'];
+ $this->phasePause = $p['pause'];
$this->phaseCooldown = $p['cooldown'];
- $this->phaseColor = $p['color'];
+ $this->phaseColor = $p['color'];
$this->editingPhaseIndex = $index;
$this->showPhaseForm = true;
}
+ public function formattedDuration(): string
+ {
+ $total = $this->totalDuration();
+ return sprintf('%d:%02d', intdiv($total, 60), $total % 60);
+ }
+
+ public function totalDuration(): int
+ {
+ return array_reduce(
+ $this->phases,
+ static function (int $carry, array $p): int {
+ $repTime = $p['duration'] * $p['repetitions'];
+ $pauses = $p['pause'] * max(0, $p['repetitions'] - 1);
+ return $carry + $repTime + $pauses + $p['cooldown'];
+ },
+ 0,
+ );
+ }
+
+ /**
+ * @throws JsonException
+ */
+ public function mount(string $id): void
+ {
+ if ($id === 'create') {
+ // New program with defaults from settings
+ $settings = AppSettings::load();
+ $this->beepLeadIn = $settings->defaultBeepLeadIn;
+ $this->endSound = $settings->defaultEndSound;
+ } else {
+ $program = TimerProgram::load($id);
+ $this->programId = $program->id;
+ $this->name = $program->name;
+ $this->beepLeadIn = $program->beepLeadIn;
+ $this->endSound = $program->endSound;
+ $this->phases = array_map(
+ static fn(Phase $p) => $p->toArray(),
+ $program->phases,
+ );
+ }
+ }
+
public function movePhaseDown(int $index): void
{
if ($index >= count($this->phases) - 1) return;
@@ -114,6 +133,8 @@ public function movePhaseUp(int $index): void
[$this->phases[$index], $this->phases[$index - 1]];
}
+ // ── Program save/delete ───────────────────────────────────────────────
+
public function openAddPhase(): void
{
if (count($this->phases) >= 10) {
@@ -124,13 +145,31 @@ public function openAddPhase(): void
$this->showPhaseForm = true;
}
+ // ── Computed ─────────────────────────────────────────────────────────
+
+ private function resetPhaseForm(): void
+ {
+ $this->phaseLabel = '';
+ $this->phaseDuration = 30;
+ $this->phaseReps = 3;
+ $this->phasePause = 0;
+ $this->phaseCooldown = 0;
+ $this->phaseColor = '#3b82f6';
+ $this->editingPhaseIndex = null;
+ }
+
+ public function render(): View
+ {
+ return view('livewire.program-editor');
+ }
+
public function savePhase(): void
{
$this->validate([
- 'phaseLabel' => 'required|string|max:40',
+ 'phaseLabel' => 'required|string|max:40',
'phaseDuration' => 'required|integer|min:1|max:3600',
- 'phaseReps' => 'required|integer|min:1|max:50',
- 'phasePause' => 'required|integer|min:0|max:3600',
+ 'phaseReps' => 'required|integer|min:1|max:50',
+ 'phasePause' => 'required|integer|min:0|max:3600',
'phaseCooldown' => 'required|integer|min:0|max:3600',
]);
@@ -139,12 +178,12 @@ public function savePhase(): void
}
$phaseArray = [
- 'label' => trim($this->phaseLabel),
- 'duration' => $this->phaseDuration,
+ 'label' => trim($this->phaseLabel),
+ 'duration' => $this->phaseDuration,
'repetitions' => $this->phaseReps,
- 'pause' => $this->phasePause,
- 'cooldown' => $this->phaseCooldown,
- 'color' => $this->phaseColor,
+ 'pause' => $this->phasePause,
+ 'cooldown' => $this->phaseCooldown,
+ 'color' => $this->phaseColor,
];
if ($this->editingPhaseIndex !== null) {
@@ -164,102 +203,36 @@ public function savePhaseAndAddNew(): void
$this->showPhaseForm = true;
}
- // ── Program save ──────────────────────────────────────────────────────
+ // ── Internals ─────────────────────────────────────────────────────────
+ /**
+ * @throws JsonException
+ */
public function saveProgram(): void
{
$this->validate([
- 'name' => 'required|string|max:60',
+ 'name' => 'required|string|max:60',
'beepLeadIn' => ['required', new Enum(BeepLeadIn::class)],
- 'endSound' => 'required|in:triple,chime',
+ 'endSound' => 'required|in:triple,chime',
]);
if ($this->programId === '') {
- $settings = Setting::current();
- $program = Program::create([
- 'name' => $this->name,
- 'beep_lead_in' => $settings->default_beep_lead_in,
- 'end_sound' => $settings->default_end_sound,
- ]);
+ $program = TimerProgram::create($this->name);
$this->programId = $program->id;
} else {
- $program = Program::findOrFail($this->programId);
+ $program = TimerProgram::load($this->programId);
$program->name = $this->name;
}
- $program->beep_lead_in = $this->beepLeadIn;
- $program->end_sound = $this->endSound;
- $program->save();
-
- $program->phases()->delete();
- foreach ($this->phases as $index => $phaseData) {
- $program->phases()->create([
- 'sort_order' => $index,
- 'label' => $phaseData['label'],
- 'duration' => (int) $phaseData['duration'],
- 'repetitions' => (int) $phaseData['repetitions'],
- 'pause' => (int) $phaseData['pause'],
- 'cooldown' => (int) $phaseData['cooldown'],
- 'color' => $phaseData['color'],
- ]);
- }
-
- $this->redirect("/timer/$program->id");
- }
-
- // ── Computed ─────────────────────────────────────────────────────────
-
- public function formattedDuration(): string
- {
- $total = $this->totalDuration();
- return sprintf('%d:%02d', intdiv($total, 60), $total % 60);
- }
-
- public function totalDuration(): int
- {
- return array_reduce(
+ $program->beepLeadIn = $this->beepLeadIn;
+ $program->endSound = $this->endSound;
+ $program->phases = array_map(
+ static fn(array $p) => Phase::fromArray($p),
$this->phases,
- static function (int $carry, array $p): int {
- $repTime = $p['duration'] * $p['repetitions'];
- $pauses = $p['pause'] * max(0, $p['repetitions'] - 1);
- return $carry + $repTime + $pauses + $p['cooldown'];
- },
- 0,
);
- }
- /**
- * True when the phase form is open for the last phase in the list
- * (or for a new phase that will become the last).
- * Used in the view to grey out the cooldown field.
- */
- public function editingIsLastPhase(): bool
- {
- $count = count($this->phases);
- if ($count === 0) {
- return true;
- }
- if ($this->editingPhaseIndex === null) {
- return true;
- }
- return $this->editingPhaseIndex === $count - 1;
- }
-
- public function render(): View
- {
- return view('livewire.program-editor');
- }
-
- // ── Internals ─────────────────────────────────────────────────────────
+ $program->save();
- private function resetPhaseForm(): void
- {
- $this->phaseLabel = '';
- $this->phaseDuration = 30;
- $this->phaseReps = 3;
- $this->phasePause = 0;
- $this->phaseCooldown = 0;
- $this->phaseColor = '#3b82f6';
- $this->editingPhaseIndex = null;
+ $this->redirect("/timer/$program->id");
}
}
diff --git a/app/Livewire/Settings.php b/app/Livewire/Settings.php
index 305b63c..a8b3823 100644
--- a/app/Livewire/Settings.php
+++ b/app/Livewire/Settings.php
@@ -5,9 +5,10 @@
namespace App\Livewire;
use App\Enum\BeepLeadIn;
-use App\Models\Setting;
+use App\Timer\AppSettings;
use Illuminate\Validation\Rules\Enum;
use Illuminate\View\View;
+use JsonException;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
@@ -26,13 +27,13 @@ class Settings extends Component
public function mount(): void
{
- $settings = Setting::current();
+ $settings = AppSettings::load();
- $this->defaultBeepLeadIn = $settings->default_beep_lead_in;
- $this->defaultEndSound = $settings->default_end_sound;
- $this->soundMode = $settings->sound_mode;
- $this->volume = $settings->volume;
- $this->keepScreenOn = $settings->keep_screen_on;
+ $this->defaultBeepLeadIn = $settings->defaultBeepLeadIn;
+ $this->defaultEndSound = $settings->defaultEndSound;
+ $this->soundMode = $settings->soundMode;
+ $this->volume = $settings->volume;
+ $this->keepScreenOn = $settings->keepScreenOn;
}
public function render(): View
@@ -40,23 +41,26 @@ public function render(): View
return view('livewire.settings');
}
+ /**
+ * @throws JsonException
+ */
public function save(): void
{
$this->validate([
'defaultBeepLeadIn' => ['required', new Enum(BeepLeadIn::class)],
- 'defaultEndSound' => 'required|in:triple,chime',
- 'soundMode' => 'required|in:beep,voice',
- 'volume' => 'required|numeric|min:0|max:1',
- 'keepScreenOn' => 'boolean',
+ 'defaultEndSound' => 'required|in:triple,chime',
+ 'soundMode' => 'required|in:beep,voice',
+ 'volume' => 'required|numeric|min:0|max:1',
+ 'keepScreenOn' => 'boolean',
]);
- $settings = Setting::current();
+ $settings = AppSettings::load();
- $settings->default_beep_lead_in = $this->defaultBeepLeadIn;
- $settings->default_end_sound = $this->defaultEndSound;
- $settings->sound_mode = $this->soundMode;
- $settings->volume = round((float) $this->volume, 2);
- $settings->keep_screen_on = $this->keepScreenOn;
+ $settings->defaultBeepLeadIn = $this->defaultBeepLeadIn;
+ $settings->defaultEndSound = $this->defaultEndSound;
+ $settings->soundMode = $this->soundMode;
+ $settings->volume = round((float)$this->volume, 2);
+ $settings->keepScreenOn = $this->keepScreenOn;
$settings->save();
diff --git a/app/Livewire/TimerScreen.php b/app/Livewire/TimerScreen.php
index abb7958..c50f60d 100644
--- a/app/Livewire/TimerScreen.php
+++ b/app/Livewire/TimerScreen.php
@@ -5,14 +5,13 @@
namespace App\Livewire;
use App\Enum\StateMachine;
-use App\Models\HistoryEntry;
-use App\Models\Phase;
-use App\Models\Program;
-use App\Models\Setting;
+use App\Timer\AppSettings;
use App\Timer\TimerCursor;
+use App\Timer\TimerProgram;
use App\Timer\TimerRunner;
use Illuminate\Support\Facades\Log;
use Illuminate\View\View;
+use JsonException;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
@@ -26,7 +25,7 @@ class TimerScreen extends Component
public string $programName = '';
// ── Cursor snapshot (serializable scalars for Livewire) ──────────────
- public StateMachine $state = StateMachine::idle;
+ public StateMachine $state = StateMachine::idle; // mirrors TimerCursor->state
public int $remaining = 0;
public int $totalRemaining = 0;
public int $phaseIndex = 0;
@@ -36,255 +35,209 @@ class TimerScreen extends Component
public string $phaseLabel = '';
public string $phaseColor = '#3b82f6';
public int $phaseReps = 1;
- /** @var array[] Serialised Phase rows for the phase strip */
- public array $phases = [];
// ── Settings (for the JS audio layer) ────────────────────────────────
public string $soundMode = 'beep';
public float $volume = 0.8;
public string $endSound = 'triple';
- // ── Ring countdown ────────────────────────────────────────────────────
- public int $programTotalDuration = 0;
-
// ── Beep lead-in (so JS can display a countdown label) ───────────────
public string $countdownLabel = '';
- // ── History (shown on Timer tab when no program is loaded) ───────────
- /** @var array[] serialised HistoryEntry rows + 'program_exists' bool */
- public array $history = [];
-
- public function mount(?string $id = null): void
- {
- $settings = Setting::current();
- $this->soundMode = $settings->sound_mode;
- $this->volume = $settings->volume;
-
- if ($id) {
- $this->loadProgram($id);
- } else {
- $this->loadHistory();
- }
- }
-
- // ── Timer controls ────────────────────────────────────────────────────
-
- public function loadProgram(string $id): void
- {
- $runner = app(TimerRunner::class);
- $program = Program::with('phases')->findOrFail($id);
-
- $this->programId = $id;
- $this->programName = $program->name;
- $this->endSound = $program->end_sound;
- $this->programTotalDuration = $program->totalDuration();
- $this->rehydrateRunner($runner);
-
- $this->syncCursor($runner->cursor(), $program);
-
- $this->dispatch('topbar-title', title: $program->name);
- $this->dispatch('settingsLoaded', soundMode: $this->soundMode, volume: $this->volume, program: $program);
- }
-
public function discard(): void
{
app(TimerRunner::class)->discard();
- $this->state = StateMachine::idle;
- $this->remaining = 0;
+ $this->state = StateMachine::idle;
+ $this->remaining = 0;
$this->totalRemaining = 0;
$this->dispatch('topbar-title', title: config('app.name'));
}
- public function pause(): void
+ public function formattedRemaining(): string
{
- $runner = app(TimerRunner::class);
- $this->rehydrateRunner($runner);
- $runner->pause();
- $this->syncCursor($runner->cursor(), Program::with('phases')->findOrFail($this->programId));
+ $s = $this->remaining;
+ return sprintf('%d:%02d', intdiv($s, 60), $s % 60);
}
- public function resume(): void
- {
- $runner = app(TimerRunner::class);
- $this->rehydrateRunner($runner);
- $runner->resume();
- $this->syncCursor($runner->cursor(), Program::with('phases')->findOrFail($this->programId));
- }
+ // ── Timer controls ────────────────────────────────────────────────────
- public function restart(): void
+ public function formattedTotal(): string
{
- $runner = app(TimerRunner::class);
- $program = Program::with('phases')->findOrFail($this->programId);
- $this->programTotalDuration = $program->totalDuration();
- $runner->load($program);
- $this->syncCursor($runner->cursor(), $program);
- $this->dispatch('topbar-title', title: config('app.name'));
+ $s = $this->totalRemaining;
+ return sprintf('%d:%02d', intdiv($s, 60), $s % 60);
}
- public function start(): void
+ /**
+ * @throws JsonException
+ */
+ public function mount(?string $id = null): void
{
- $runner = app(TimerRunner::class);
- $program = Program::with('phases')->findOrFail($this->programId);
+ $settings = AppSettings::load();
+ $this->soundMode = $settings->soundMode;
+ $this->volume = $settings->volume;
- $this->programTotalDuration = $program->totalDuration();
- $runner->load($program);
- $runner->start();
- $this->syncCursor($runner->cursor(), $program);
-
- $this->dispatch('topbar-title', title: $this->programName);
+ if ($id) {
+ $this->loadProgram($id);
+ }
}
- /** Called every second from JS setInterval via wire:poll equivalent. */
- public function tick(): void
+ public function loadProgram(string $id): void
{
$runner = app(TimerRunner::class);
- $this->rehydrateRunner($runner);
+ $program = TimerProgram::load($id);
- if (!$runner->cursor()->isActive()) {
- return;
- }
+ $runner->load($program);
- $runner->tick();
- $cursor = $runner->cursor();
- $program = Program::with('phases')->findOrFail($this->programId);
+ Log::info("Runner info: {$runner->program()->name}.");
- $this->syncCursor($cursor, $program);
+ $this->programId = $id;
+ $this->programName = $program->name;
+ $this->endSound = $program->endSound;
- if ($cursor->isCompleted()) {
- Log::info('Completed!');
- $this->dispatch('playEndSound', sound: $this->endSound);
- $this->dispatch('topbar-title', title: config('app.name'));
- }
+ $this->syncCursor($runner->cursor(), $program);
+
+ Log::info('Dispatching settings loaded');
+ // Push settings to JS audio layer
+ $this->dispatch('settingsLoaded', soundMode: $this->soundMode, volume: $this->volume, program: $program);
}
public function requestSettings(): void
{
- $program = $this->programId ? Program::with('phases')->find($this->programId) : null;
+ $program = $this->programId ? TimerProgram::load($this->programId) : null;
$this->dispatch('settingsLoaded', soundMode: $this->soundMode, volume: $this->volume, program: $program);
}
- public function render(): View
+ private function syncCursor(TimerCursor $cursor, TimerProgram $program): void
{
- return view('livewire.timer-screen');
- }
+ $this->state = $cursor->state;
+ $this->remaining = $cursor->remaining;
+ $this->totalRemaining = $cursor->totalRemaining;
+ $this->phaseIndex = $cursor->phaseIndex;
+ $this->repIndex = $cursor->repIndex;
- // ── Display helpers ───────────────────────────────────────────────────
+ if (isset($program->phases[$cursor->phaseIndex])) {
+ $phase = $program->phases[$cursor->phaseIndex];
+ $this->phaseLabel = $phase->label;
+ $this->phaseColor = $phase->color;
+ $this->phaseReps = $phase->repetitions;
+ }
+ }
- public function formattedRemaining(): string
+ /**
+ * @throws JsonException
+ */
+ public function pause(): void
{
- $s = $this->remaining;
- return sprintf('%d:%02d', intdiv($s, 60), $s % 60);
+ $runner = app(TimerRunner::class);
+ $runner->pause();
+ $this->syncCursor($runner->cursor(), TimerProgram::load($this->programId));
}
- public function formattedTotal(): string
+ public function render(): View
{
- $s = $this->totalRemaining;
- return sprintf('%d:%02d', intdiv($s, 60), $s % 60);
+ return view('livewire.timer-screen');
}
+ // ── Computed display helpers ──────────────────────────────────────────
+
public function repLabel(): string
{
- if (in_array($this->state, [StateMachine::prepare, StateMachine::cooldown, StateMachine::completed], true)) {
+ if (in_array($this->state->value, ['pause', 'cooldown', 'paused', 'completed', 'idle'], true)) {
return '';
}
return sprintf('%d / %d', $this->repIndex + 1, $this->phaseReps);
}
+ /**
+ * @throws JsonException
+ */
+ public function restart(): void
+ {
+ $runner = app(TimerRunner::class);
+ $program = TimerProgram::load($this->programId);
+ $runner->load($program);
+ $this->syncCursor($runner->cursor(), $program);
+ $this->dispatch('topbar-title', title: config('app.name'));
+ }
+
+ /**
+ * @throws JsonException
+ */
+ public function resume(): void
+ {
+ $runner = app(TimerRunner::class);
+ $runner->resume();
+ $this->syncCursor($runner->cursor(), TimerProgram::load($this->programId));
+ }
+
public function segmentLabel(): string
{
return match ($this->state) {
- StateMachine::prepare => 'Get Ready',
- StateMachine::pause => 'Pause',
- StateMachine::cooldown => 'Cooldown',
- StateMachine::paused => 'Paused',
- StateMachine::completed => 'Complete!',
- default => $this->phaseLabel,
+ 'pause' => 'Pause',
+ 'cooldown' => 'Cooldown',
+ 'paused' => 'Paused',
+ 'completed' => 'Complete!',
+ default => $this->phaseLabel,
};
}
- // ── Internals ─────────────────────────────────────────────────────────
-
- private function rehydrateRunner(TimerRunner $runner): void
+ /**
+ * @throws JsonException
+ */
+ public function start(): void
{
- if (!$this->programId) {
- return;
- }
-
- $program = Program::with('phases')->findOrFail($this->programId);
- $runner->load($program);
+ $runner = app(TimerRunner::class);
+ $program = TimerProgram::load($this->programId);
- $cursor = new TimerCursor(
- phaseIndex: $this->phaseIndex,
- repIndex: $this->repIndex,
- state: $this->state,
- remaining: $this->remaining,
- totalRemaining: $this->totalRemaining,
- );
-
- $runner->cursor = $cursor;
- $runner->onBeep(function (string $reason): void {
- $this->handleBeep($reason);
+ $runner->onBeep(function (string $reason) use ($program): void {
+ $this->handleBeep($reason, $program);
});
$runner->onPauseBeep(function (): void {
$this->dispatch('playPauseBeep');
});
+
+// $runner->start();
+ $this->syncCursor($runner->cursor(), $program);
+
+ // EDGE top bar → program name
+ $this->dispatch('topbar-title', title: $this->programName);
}
- private function handleBeep(string $reason): void
+ // ── Internals ─────────────────────────────────────────────────────────
+
+ private function handleBeep(string $reason, TimerProgram $program): void
{
+ // Determine the countdown label for voice mode
$this->countdownLabel = match ($reason) {
- 'prepare', 'countdown' => (string) $this->remaining,
- 'rep_end' => 'Done',
- 'pause_end' => 'Go',
- 'cooldown_end' => 'Next',
- default => '',
+ 'countdown' => $this->remaining . ' seconds',
+ 'rep_end' => 'Done',
+ 'pause_end' => 'Go',
+ 'cooldown_end' => 'Next',
+ default => '',
};
$this->dispatch('playBeep', reason: $reason);
}
- private function syncCursor(TimerCursor $cursor, Program $program): void
+ /** Called every second from JS setInterval via wire:poll equivalent.
+ * @throws JsonException
+ */
+ public function tick(): void
{
- $this->state = $cursor->state;
- $this->remaining = $cursor->remaining;
- $this->totalRemaining = $cursor->totalRemaining;
- $this->phaseIndex = $cursor->phaseIndex;
- $this->repIndex = $cursor->repIndex;
-
- $this->phases = $program->phases
- ->map(fn(Phase $p) => [
- 'label' => $p->label,
- 'duration' => $p->duration,
- 'repetitions' => $p->repetitions,
- 'pause' => $p->pause,
- 'cooldown' => $p->cooldown,
- 'color' => $p->color,
- ])
- ->all();
+ $runner = app(TimerRunner::class);
- if (isset($program->phases[$cursor->phaseIndex])) {
- $phase = $program->phases[$cursor->phaseIndex];
- $this->phaseLabel = $phase->label;
- $this->phaseColor = $phase->color;
- $this->phaseReps = $phase->repetitions;
+ if (!$runner->cursor()->isActive()) {
+ return;
}
- }
- private function loadHistory(): void
- {
- $entries = HistoryEntry::latest('completed_at')->limit(20)->get();
-
- $existingIds = Program::whereIn('id', $entries->pluck('program_id')->filter())
- ->pluck('id')
- ->flip();
-
- $this->history = $entries
- ->map(fn(HistoryEntry $e) => [
- 'program_id' => $e->program_id,
- 'program_name' => $e->program_name,
- 'completed_at' => $e->completed_at->toISOString(),
- 'total_duration' => $e->total_duration,
- 'program_exists' => $e->program_id !== null && $existingIds->has($e->program_id),
- ])
- ->all();
+ $runner->tick();
+ $cursor = $runner->cursor();
+ $program = TimerProgram::load($this->programId);
+
+ $this->syncCursor($cursor, $program);
+
+ if ($cursor->isCompleted()) {
+ $this->dispatch('playEndSound', sound: $this->endSound);
+ $this->dispatch('topbar-title', title: config('app.name'));
+ }
}
}
diff --git a/app/Timer/AppSettings.php b/app/Timer/AppSettings.php
new file mode 100644
index 0000000..2a52be9
--- /dev/null
+++ b/app/Timer/AppSettings.php
@@ -0,0 +1,74 @@
+defaultBeepLeadIn = BeepLeadIn::from((int)($data['default_beep_lead_in'] ?? 3));
+ $settings->defaultEndSound = $data['default_end_sound'] ?? 'triple';
+ $settings->soundMode = $data['sound_mode'] ?? 'beep';
+ $settings->volume = (float)($data['volume'] ?? 0.8);
+ $settings->keepScreenOn = (bool)($data['keep_screen_on'] ?? true);
+ }
+
+ return $settings;
+ }
+
+ /**
+ * @throws JsonException
+ */
+ public function save(): void
+ {
+ Storage::put(
+ self::PATH,
+ json_encode($this->toArray(), JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR),
+ );
+ }
+
+ public function toArray(): array
+ {
+ return [
+ 'default_beep_lead_in' => $this->defaultBeepLeadIn,
+ 'default_end_sound' => $this->defaultEndSound,
+ 'sound_mode' => $this->soundMode,
+ 'volume' => $this->volume,
+ 'keep_screen_on' => $this->keepScreenOn,
+ ];
+ }
+}
diff --git a/app/Timer/TimerCursor.php b/app/Timer/TimerCursor.php
index 0a08451..0bfa415 100644
--- a/app/Timer/TimerCursor.php
+++ b/app/Timer/TimerCursor.php
@@ -42,64 +42,51 @@ public static function idle(): self
/** Mark the program as completed. */
public function complete(): self
{
- return clone($this, [
- 'phaseIndex' => $this->phaseIndex,
- 'repIndex' => $this->repIndex,
- 'state' => StateMachine::completed,
- 'remaining' => 0,
- 'totalRemaining' => 0,
- ]
+ /* PHP 8.5: return clone $this with { state: StateMachine::completed,
+ remaining: 0, totalRemaining: 0 }; */
+ return new self(
+ phaseIndex: $this->phaseIndex,
+ repIndex: $this->repIndex,
+ state: StateMachine::completed,
+ remaining: 0,
+ totalRemaining: 0,
);
}
/** Move into the cooldown state after the final rep of a phase. */
public function enterCooldown(int $cooldownDuration, int $totalRemaining): self
{
- return clone($this, [
- 'phaseIndex' => $this->phaseIndex,
- 'repIndex' => $this->repIndex,
- 'state' => StateMachine::cooldown,
- 'remaining' => $cooldownDuration,
- 'totalRemaining' => $totalRemaining,
- ]
+ /* PHP 8.5: return clone $this with { state: StateMachine::cooldown,
+ remaining: $cooldownDuration,
+ totalRemaining: $totalRemaining }; */
+ return new self(
+ phaseIndex: $this->phaseIndex,
+ repIndex: $this->repIndex,
+ state: StateMachine::cooldown,
+ remaining: $cooldownDuration,
+ totalRemaining: $totalRemaining,
);
}
/** Move into the pause state between repetitions. */
public function enterPause(int $pauseDuration, int $totalRemaining): self
{
- return clone($this, [
- 'phaseIndex' => $this->phaseIndex,
- 'repIndex' => $this->repIndex,
- 'state' => StateMachine::pause,
- 'remaining' => $pauseDuration,
- 'totalRemaining' => $totalRemaining,
- ]
- );
- }
-
- /** Enter the pre-start countdown before the first rep. */
- public function enterPrepare(int $seconds): self
- {
- return clone($this, [
- 'phaseIndex' => 0,
- 'repIndex' => 0,
- 'state' => StateMachine::prepare,
- 'remaining' => $seconds,
- 'totalRemaining' => $this->totalRemaining,
- ]
+ /* PHP 8.5: return clone $this with { state: StateMachine::pause,
+ remaining: $pauseDuration,
+ totalRemaining: $totalRemaining }; */
+ return new self(
+ phaseIndex: $this->phaseIndex,
+ repIndex: $this->repIndex,
+ state: StateMachine::pause,
+ remaining: $pauseDuration,
+ totalRemaining: $totalRemaining,
);
}
/** True whenever the timer is actively counting down (not user-paused, not idle). */
public function isActive(): bool
{
- return in_array($this->state, [
- StateMachine::prepare,
- StateMachine::running,
- StateMachine::pause,
- StateMachine::cooldown,
- ], true);
+ return in_array($this->state, [StateMachine::running, StateMachine::pause, StateMachine::cooldown], true);
}
public function isCompleted(): bool
@@ -129,61 +116,82 @@ public function isPaused(): bool
return $this->state === StateMachine::paused;
}
+ public function isRunning(): bool
+ {
+ return $this->state === StateMachine::running;
+ }
+
/** Advance to the first rep of the next phase. */
public function nextPhase(int $phaseIndex, int $repDuration, int $totalRemaining): self
{
- return clone($this, [
- 'phaseIndex' => $phaseIndex,
- 'repIndex' => 0,
- 'state' => StateMachine::running,
- 'remaining' => $repDuration,
- 'totalRemaining' => $totalRemaining,
- ]
+ /* PHP 8.5: return clone $this with { phaseIndex: $phaseIndex,
+ repIndex: 0,
+ state: StateMachine::running,
+ remaining: $repDuration,
+ totalRemaining: $totalRemaining }; */
+ return new self(
+ phaseIndex: $phaseIndex,
+ repIndex: 0,
+ state: StateMachine::running,
+ remaining: $repDuration,
+ totalRemaining: $totalRemaining,
);
}
/** Advance to the next repetition of the same phase. */
public function nextRep(int $repDuration, int $totalRemaining): self
{
- return clone($this, [
- 'phaseIndex' => $this->phaseIndex,
- 'repIndex' => $this->repIndex + 1,
- 'state' => StateMachine::running,
- 'remaining' => $repDuration,
- 'totalRemaining' => $totalRemaining,
- ]
+ /* PHP 8.5: return clone $this with { repIndex: $this->repIndex + 1,
+ state: StateMachine::running,
+ remaining: $repDuration,
+ totalRemaining: $totalRemaining }; */
+ return new self(
+ phaseIndex: $this->phaseIndex,
+ repIndex: $this->repIndex + 1,
+ state: StateMachine::running,
+ remaining: $repDuration,
+ totalRemaining: $totalRemaining,
);
}
/** User pressed pause (or phone call received). */
public function pause(): self
{
+ /* PHP 8.5: return clone $this with { state: StateMachine::paused }; */
+ return new self(
+ phaseIndex: $this->phaseIndex,
+ repIndex: $this->repIndex,
+ state: StateMachine::paused,
+ remaining: $this->remaining,
+ totalRemaining: $this->totalRemaining,
+ );
+ }
+
+ /** Resume into a named substate (the state that was active pre-pause). */
+ public function resumeAs(StateMachine $state): self
+ {
+ /* PHP 8.5: return clone $this with { state: $state }; */
return clone($this, [
'phaseIndex' => $this->phaseIndex,
'repIndex' => $this->repIndex,
- 'state' => StateMachine::paused,
+ 'state' => $state,
'remaining' => $this->remaining,
'totalRemaining' => $this->totalRemaining,
]
);
}
- /** Resume into a named substate (the state that was active pre-pause). */
- public function resumeAs(StateMachine $state): self
- {
- return clone($this, ['state' => $state]);
- }
-
/** Tick one second off the current segment and the total remaining. */
public function tick(): self
{
- return clone($this, [
- 'phaseIndex' => $this->phaseIndex,
- 'repIndex' => $this->repIndex,
- 'state' => $this->state,
- 'remaining' => max(0, $this->remaining - 1),
- 'totalRemaining' => max(0, $this->totalRemaining - 1),
- ]
+ /* PHP 8.5: return clone $this with { remaining: max (0, $this->remaining - 1),
+ totalRemaining: max(0, $this->totalRemaining - 1) }; */
+ return new self(
+ phaseIndex: $this->phaseIndex,
+ repIndex: $this->repIndex,
+ state: $this->state,
+ remaining: max(0, $this->remaining - 1),
+ totalRemaining: max(0, $this->totalRemaining - 1),
);
}
}
diff --git a/app/Timer/TimerProgram.php b/app/Timer/TimerProgram.php
new file mode 100644
index 0000000..6826737
--- /dev/null
+++ b/app/Timer/TimerProgram.php
@@ -0,0 +1,208 @@
+ — load() chains Storage::get → json_decode → hydrate.
+ * Shown in comments; falls back to explicit nesting for
+ * PHP < 8.5 host tooling.
+ * • #[\NoDiscard] — on totalDuration(); callers must use the return value.
+ * • array_first_value() — used in TimerRunner to grab the first phase.
+ */
+class TimerProgram
+{
+ public readonly string $id;
+ public string $name;
+ public string $createdAt;
+ public ?string $lastUsedAt;
+ public BeepLeadIn $beepLeadIn; // 3 | 5
+ public string $endSound; // 'triple' | 'chime'
+ /** @var Phase[] */
+ public array $phases; // max 10
+
+ private function __construct(
+ string $id,
+ string $name,
+ string $createdAt,
+ ?string $lastUsedAt,
+ BeepLeadIn $beepLeadIn,
+ string $endSound,
+ array $phases,
+ )
+ {
+ $this->id = $id;
+ $this->name = $name;
+ $this->createdAt = $createdAt;
+ $this->lastUsedAt = $lastUsedAt;
+ $this->beepLeadIn = $beepLeadIn;
+ $this->endSound = $endSound;
+ $this->phases = $phases;
+ }
+
+ /** Return all saved programs, newest first. */
+ public static function all(): array
+ {
+ return collect(Storage::files('programs'))
+ ->filter(fn(string $p) => str_ends_with($p, '.json'))
+ ->map(fn(string $p) => self::load(basename($p, '.json')))
+ ->sortByDesc(fn(self $prog) => $prog->createdAt)
+ ->values()
+ ->all();
+ }
+
+ /** Create a brand-new program seeded with global defaults. */
+ public static function create(string $name): self
+ {
+ $settings = AppSettings::load();
+
+ return new self(
+ id: (string)Str::uuid(),
+ name: $name,
+ createdAt: now()->toISOString(),
+ lastUsedAt: null,
+ beepLeadIn: $settings->defaultBeepLeadIn,
+ endSound: $settings->defaultEndSound,
+ phases: [],
+ );
+ }
+
+ /**
+ * Load from a JSON file.
+ *
+ * PHP 8.5 pipe-operator version (requires NativePHP's PHP 8.5 runtime):
+ *
+ * return Storage::get("programs/{$id}.json")
+ * |> json_decode($$, true, 512, JSON_THROW_ON_ERROR)
+ * |> self::hydrate($$);
+ *
+ * Equivalent without the pipe operator (PHP 8.4-safe):
+ * @throws JsonException
+ */
+ public static function load(string $id): self
+ {
+ $path = "programs/$id.json";
+
+ if (!Storage::exists($path)) {
+ throw new RuntimeException("Program not found: $id");
+ }
+
+ $json = Storage::get($path);
+ $data = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
+ return self::hydrate($data);
+ }
+
+ private static function hydrate(array $data): self
+ {
+ return new self(
+ id: $data['id'],
+ name: $data['name'],
+ createdAt: $data['created_at'],
+ lastUsedAt: $data['last_used_at'] ?? null,
+ beepLeadIn: BeepLeadIn::from($data['beep_lead_in'] ?? 3),
+ endSound: $data['end_sound'] ?? 'triple',
+ phases: array_map(
+ static fn(array $p) => Phase::fromArray($p),
+ $data['phases'] ?? [],
+ ),
+ );
+ }
+
+ /** Add a phase; max 10 per program. */
+ public function addPhase(Phase $phase): void
+ {
+ if (count($this->phases) >= 10) {
+ throw new OverflowException('A program may have at most 10 phases.');
+ }
+ $this->phases[] = $phase;
+ }
+
+ public function delete(): void
+ {
+ Storage::delete("programs/$this->id.json");
+ }
+
+ /** Human-readable total duration, e.g. "12:34". */
+ public function formattedDuration(): string
+ {
+ $total = $this->totalDuration();
+ $minutes = intdiv($total, 60);
+ $seconds = $total % 60;
+ return sprintf('%d:%02d', $minutes, $seconds);
+ }
+
+ public function touch(): void
+ {
+ $this->lastUsedAt = now()->toISOString();
+ $this->save();
+ }
+
+ /** Save the program to its JSON file.
+ */
+ public function save(): void
+ {
+ try {
+ Storage::put(
+ "programs/$this->id.json",
+ json_encode($this->toArray(), JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR),
+ );
+ } catch (JsonException $e) {
+ Storage::put(
+ "programs/$this->id.json",
+ json_encode([], JSON_PRETTY_PRINT),
+ );
+ }
+ }
+
+ public function toArray(): array
+ {
+ return [
+ 'id' => $this->id,
+ 'name' => $this->name,
+ 'created_at' => $this->createdAt,
+ 'last_used_at' => $this->lastUsedAt,
+ 'beep_lead_in' => $this->beepLeadIn,
+ 'end_sound' => $this->endSound,
+ 'total_duration' => $this->totalDuration(),
+ 'phases' => array_map(
+ static fn(Phase $p) => $p->toArray(),
+ $this->phases,
+ ),
+ ];
+ }
+
+ /**
+ * Computed total duration in seconds.
+ *
+ * Formula per phase:
+ * (duration × reps) + (pause × (reps − 1)) + cooldown
+ *
+ */
+ #[NoDiscard]
+ public function totalDuration(): int
+ {
+ return array_reduce(
+ $this->phases,
+ static function (int $carry, Phase $phase): int {
+ $repTime = $phase->duration * $phase->repetitions;
+ $pauses = $phase->pause * max(0, $phase->repetitions - 1);
+ return $carry + $repTime + $pauses + $phase->cooldown;
+ },
+ 0,
+ );
+ }
+}
diff --git a/app/Timer/TimerRunner.php b/app/Timer/TimerRunner.php
index 97506f2..b170e77 100644
--- a/app/Timer/TimerRunner.php
+++ b/app/Timer/TimerRunner.php
@@ -7,9 +7,6 @@
use App\Enum\StateMachine;
use App\Events\PhaseChanged;
use App\Events\ProgramCompleted;
-use App\Jobs\WriteHistoryEntry;
-use App\Models\Phase;
-use App\Models\Program;
use Closure;
use RuntimeException;
@@ -30,34 +27,31 @@
* Resume restores to the previous active state.
*
* Beep rule: fire the segment-appropriate end beep based on the expired state.
- * 'Running' → 'rep_end'
- * 'Pause' → 'pause_end'
- * 'Cooldown' → 'cooldown_end'
+ * running → 'rep_end'
+ * pause → 'pause_end'
+ * cooldown → 'cooldown_end'
*
* Countdown beep fires during the last N seconds of each segment (lead-in).
* If a segment < lead-in, the countdown starts from second 1.
*/
class TimerRunner
{
- private const PREPARE_SECONDS = 5;
-
- private ?Program $program = null;
- public TimerCursor $cursor {
- set {
- $this->cursor = $value;
- }
- }
+ private ?TimerProgram $program = null;
+ private TimerCursor $cursor;
/** State before the user pressed pause — needed for resume. */
private StateMachine $stateBeforePause = StateMachine::running;
/** Callable invoked each time the cursor changes: fn(TimerCursor) */
- private ?Closure $onTick = null;
+ private ?Closure $onTick = null;
/** Callable invoked when a beep should fire: fn(string $reason) */
- private ?Closure $onBeep = null;
+ private ?Closure $onBeep = null;
/** Callable for the single pause-beep: fn() */
private ?Closure $onPauseBeep = null;
+ /** Fake-clock override (seconds since epoch) — set in tests. */
+ private ?Closure $clockFn = null;
+
public function __construct()
{
$this->cursor = TimerCursor::idle();
@@ -65,143 +59,54 @@ public function __construct()
// ── Public accessors ──────────────────────────────────────────────────────
- public function cursor(): TimerCursor
- {
- return $this->cursor;
- }
-
- /** Silent discard — no history entry, no ProgramCompleted event. */
- public function discard(): void
- {
- $this->program = null;
- $this->cursor = TimerCursor::idle();
- }
-
- /** Load a program and reset the cursor to idle. */
- public function load(Program $program): void
- {
- $this->program = $program;
- $this->cursor = TimerCursor::idle();
- }
-
- public function onBeep(Closure $fn): void
- {
- $this->onBeep = $fn;
- }
+ public function cursor(): TimerCursor { return $this->cursor; }
+ public function program(): ?TimerProgram { return $this->program; }
+ public function isIdle(): bool { return $this->cursor->isIdle(); }
+ public function isRunning(): bool { return $this->cursor->isRunning(); }
+ public function isPaused(): bool { return $this->cursor->isPaused(); }
+ public function isCompleted(): bool { return $this->cursor->isCompleted(); }
// ── Callback registration ─────────────────────────────────────────────────
- public function onPauseBeep(Closure $fn): void
- {
- $this->onPauseBeep = $fn;
- }
+ public function onTick(Closure $fn): void { $this->onTick = $fn; }
+ public function onBeep(Closure $fn): void { $this->onBeep = $fn; }
+ public function onPauseBeep(Closure $fn): void { $this->onPauseBeep = $fn; }
- public function onTick(Closure $fn): void
- {
- $this->onTick = $fn;
- }
-
- /** User-initiated pause (or PhoneStateListener). Cannot pause during prepare. */
- public function pause(): void
- {
- if (!$this->cursor->isActive()) {
- return;
- }
-
- if ($this->cursor->state === StateMachine::prepare) {
- return;
- }
-
- $this->stateBeforePause = $this->cursor->state;
- $this->cursor = $this->cursor->pause();
- $this->fireOnPauseBeep();
- $this->notifyTick();
- }
-
- private function fireOnPauseBeep(): void
- {
- if ($this->onPauseBeep !== null) {
- ($this->onPauseBeep)();
- }
- }
-
- public function program(): ?Program
- {
- return $this->program;
- }
+ /** Override the clock for tests. fn(): int (seconds since epoch) */
+ public function setClock(Closure $fn): void { $this->clockFn = $fn; }
// ── Control surface ───────────────────────────────────────────────────────
- /** Resume from the user-paused state, restoring the pre-pause substate. */
- public function resume(): void
+ /** Load a program and reset the cursor to idle. */
+ public function load(TimerProgram $program): void
{
- if (!$this->cursor->isPaused()) {
- return;
- }
-
- $this->cursor = $this->cursor->resumeAs($this->stateBeforePause);
- $this->notifyTick();
+ $this->program = $program;
+ $this->cursor = TimerCursor::idle();
}
- /** Start the timer from idle — enters a 5-second PREPARE countdown first. */
+ /** Start the timer from idle. */
public function start(): void
{
$this->assertProgramLoaded();
- if (!$this->isIdle()) {
+ if (! $this->isIdle()) {
throw new RuntimeException(
"Cannot start: expected state 'idle', got '{$this->cursor->state->value}'.",
);
}
- $this->cursor = $this->cursor->enterPrepare(self::PREPARE_SECONDS);
- }
-
- /** Transition from prepare → running, initialising the first rep. */
- private function beginFirstRep(): void
- {
- $phase = $this->currentPhase();
+ $phase = $this->currentPhase();
$totalRemaining = $this->program->totalDuration();
$this->cursor = new TimerCursor(
- phaseIndex: 0,
- repIndex: 0,
- state: StateMachine::running,
- remaining: $phase->duration,
+ phaseIndex: 0,
+ repIndex: 0,
+ state: StateMachine::running,
+ remaining: $phase->duration,
totalRemaining: $totalRemaining,
);
PhaseChanged::dispatch($this->program->id, 0, $phase, 0);
- $this->notifyTick();
- }
-
- private function assertProgramLoaded(): void
- {
- if ($this->program === null) {
- throw new RuntimeException('No program loaded. Call load() first.');
- }
- if ($this->program->phases->isEmpty()) {
- throw new RuntimeException('Program has no phases.');
- }
- }
-
- // ── State machine ─────────────────────────────────────────────────────────
-
- public function isIdle(): bool
- {
- return $this->cursor->isIdle();
- }
-
- private function currentPhase(): Phase
- {
- return array_first($this->phases())
- ?? throw new RuntimeException('Program has no phases.');
- }
-
- /** @return Phase[] */
- private function phases(): array
- {
- return $this->program->phases->all();
}
/**
@@ -210,24 +115,12 @@ private function phases(): array
*/
public function tick(): void
{
- if (!$this->cursor->isActive()) {
+ if (! $this->cursor->isActive()) {
return;
}
$cursor = $this->cursor->tick();
- // Prepare: beep every second (bypass lead-in logic), transition when done
- if ($cursor->state === StateMachine::prepare) {
- if ($cursor->remaining > 0) {
- $this->fireBeep('prepare');
- $this->cursor = $cursor;
- $this->notifyTick();
- } else {
- $this->beginFirstRep();
- }
- return;
- }
-
if ($this->shouldBeep($cursor)) {
$this->fireBeep('countdown');
}
@@ -241,51 +134,38 @@ public function tick(): void
$this->advance($cursor);
}
- // ── Beep helpers ──────────────────────────────────────────────────────────
-
- /**
- * True when the cursor's remaining falls within the lead-in countdown window.
- * Uses beep_lead_in->value to extract the int from the BeepLeadIn backed enum.
- */
- private function shouldBeep(TimerCursor $cursor): bool
+ /** User-initiated pause (or PhoneStateListener). */
+ public function pause(): void
{
- if (!$cursor->isActive()) {
- return false;
+ if (! $this->cursor->isActive()) {
+ return;
}
- $leadIn = $this->program->beep_lead_in->value;
- $segmentTotal = $this->segmentDurationForCursor($cursor);
- $effectiveLead = ($segmentTotal < $leadIn) ? max(1, $segmentTotal - 1) : $leadIn;
-
- return $cursor->remaining <= $effectiveLead && $cursor->remaining > 0;
+ $this->stateBeforePause = $this->cursor->state;
+ $this->cursor = $this->cursor->pause();
+ $this->fireOnPauseBeep();
+ $this->notifyTick();
}
- private function segmentDurationForCursor(TimerCursor $cursor): int
+ /** Resume from the user-paused state, restoring the pre-pause substate. */
+ public function resume(): void
{
- $phase = $this->phases()[$cursor->phaseIndex];
+ if (! $this->cursor->isPaused()) {
+ return;
+ }
- return match ($cursor->state) {
- StateMachine::prepare => self::PREPARE_SECONDS,
- StateMachine::running => $phase->duration,
- StateMachine::pause => $phase->pause,
- StateMachine::cooldown => $phase->cooldown,
- default => 0,
- };
+ $this->cursor = $this->cursor->resumeAs($this->stateBeforePause);
+ $this->notifyTick();
}
- private function fireBeep(string $reason): void
+ /** Silent discard — no history entry, no ProgramCompleted event. */
+ public function discard(): void
{
- if ($this->onBeep !== null) {
- ($this->onBeep)($reason);
- }
+ $this->program = null;
+ $this->cursor = TimerCursor::idle();
}
- private function notifyTick(): void
- {
- if ($this->onTick !== null) {
- ($this->onTick)($this->cursor);
- }
- }
+ // ── State machine ─────────────────────────────────────────────────────────
/**
* Called when cursor->remaining hits 0.
@@ -293,8 +173,8 @@ private function notifyTick(): void
*/
private function advance(TimerCursor $cursor): void
{
- $phase = $this->phases()[$cursor->phaseIndex];
- $isLastRep = ($cursor->repIndex >= $phase->repetitions - 1);
+ $phase = $this->phases()[$cursor->phaseIndex];
+ $isLastRep = ($cursor->repIndex >= $phase->repetitions - 1);
$isLastPhase = ($cursor->phaseIndex >= count($this->phases()) - 1);
// ── Pause segment expired → next rep ──────────────────────────────
@@ -314,7 +194,7 @@ private function advance(TimerCursor $cursor): void
// ── Running rep expired ───────────────────────────────────────────
$this->fireBeep('rep_end');
- if (!$isLastRep && $phase->pause > 0) {
+ if (! $isLastRep && $phase->pause > 0) {
$this->cursor = $cursor->enterPause(
$phase->pause,
max(0, $cursor->totalRemaining),
@@ -323,13 +203,13 @@ private function advance(TimerCursor $cursor): void
return;
}
- if (!$isLastRep) {
+ if (! $isLastRep) {
$this->advanceToNextRep($cursor, $phase);
return;
}
// Last rep → cooldown (always executes, even as the last phase's last rep).
- if ($phase->cooldown > 0 && !$isLastPhase) {
+ if ($phase->cooldown > 0) {
$this->cursor = $cursor->enterCooldown(
$phase->cooldown,
max(0, $cursor->totalRemaining),
@@ -342,8 +222,6 @@ private function advance(TimerCursor $cursor): void
$this->advanceAfterCooldown($cursor, $isLastPhase);
}
- // ── Helpers ───────────────────────────────────────────────────────────────
-
private function advanceToNextRep(TimerCursor $cursor, Phase $phase): void
{
$this->cursor = $cursor->nextRep(
@@ -361,7 +239,7 @@ private function advanceAfterCooldown(TimerCursor $cursor, bool $isLastPhase): v
}
$nextPhaseIndex = $cursor->phaseIndex + 1;
- $nextPhase = $this->phases()[$nextPhaseIndex];
+ $nextPhase = $this->phases()[$nextPhaseIndex];
$this->cursor = $cursor->nextPhase(
$nextPhaseIndex,
@@ -377,22 +255,90 @@ private function complete(): void
{
$this->cursor = $this->cursor->complete();
- $totalDuration = $this->program->totalDuration();
-
ProgramCompleted::dispatch(
$this->program->id,
- $this->program->end_sound,
- $totalDuration,
- );
-
- WriteHistoryEntry::dispatch(
- $this->program->id,
- $this->program->name,
- now()->toISOString(),
- $totalDuration,
+ $this->program->endSound,
+ $this->program->totalDuration(),
);
$this->program->touch();
$this->notifyTick();
}
+
+ // ── Beep helpers ──────────────────────────────────────────────────────────
+
+ /**
+ * True when the cursor's remaining falls within the lead-in countdown window.
+ * Uses beepLeadIn->value to extract the int from the BeepLeadIn backed enum.
+ */
+ private function shouldBeep(TimerCursor $cursor): bool
+ {
+ if (! $cursor->isActive()) {
+ return false;
+ }
+
+ $leadIn = $this->program->beepLeadIn->value; // BeepLeadIn: int enum
+ $segmentTotal = $this->segmentDurationForCursor($cursor);
+ $effectiveLead = ($segmentTotal < $leadIn) ? max(1, $segmentTotal - 1) : $leadIn;
+
+ return $cursor->remaining <= $effectiveLead && $cursor->remaining > 0;
+ }
+
+ private function segmentDurationForCursor(TimerCursor $cursor): int
+ {
+ $phase = $this->phases()[$cursor->phaseIndex];
+
+ return match ($cursor->state) {
+ StateMachine::running => $phase->duration,
+ StateMachine::pause => $phase->pause,
+ StateMachine::cooldown => $phase->cooldown,
+ default => 0,
+ };
+ }
+
+ private function fireBeep(string $reason): void
+ {
+ if ($this->onBeep !== null) {
+ ($this->onBeep)($reason);
+ }
+ }
+
+ private function fireOnPauseBeep(): void
+ {
+ if ($this->onPauseBeep !== null) {
+ ($this->onPauseBeep)();
+ }
+ }
+
+ private function notifyTick(): void
+ {
+ if ($this->onTick !== null) {
+ ($this->onTick)($this->cursor);
+ }
+ }
+
+ // ── Helpers ───────────────────────────────────────────────────────────────
+
+ private function assertProgramLoaded(): void
+ {
+ if ($this->program === null) {
+ throw new RuntimeException('No program loaded. Call load() first.');
+ }
+ if (count($this->program->phases) === 0) {
+ throw new RuntimeException('Program has no phases.');
+ }
+ }
+
+ private function currentPhase(): Phase
+ {
+ // PHP 8.5: array_first_value($this->phases())
+ return ($this->phases()[0] ?? null)
+ ?? throw new RuntimeException('Program has no phases.');
+ }
+
+ /** @return Phase[] */
+ private function phases(): array
+ {
+ return $this->program->phases;
+ }
}
diff --git a/config/app.php b/config/app.php
index 135d3f3..85e71fd 100644
--- a/config/app.php
+++ b/config/app.php
@@ -15,19 +15,6 @@
'name' => env('APP_NAME', 'Interval Timer'),
- /*
- |--------------------------------------------------------------------------
- | Long-press Pause Platforms
- |--------------------------------------------------------------------------
- |
- | Controls which platforms get the hold-to-pause gesture on the timer screen.
- | 'all' — enabled in the browser web demo and on Android.
- | 'android' — enabled only when running inside the NativePHP Android WebView
- | (detected at runtime via the Android user-agent string).
- |
- */
- 'long_press_pause' => env('LONG_PRESS_PAUSE', 'all'),
-
/*
|--------------------------------------------------------------------------
| Application Environment
diff --git a/config/queue.php b/config/queue.php
deleted file mode 100644
index 8126210..0000000
--- a/config/queue.php
+++ /dev/null
@@ -1,54 +0,0 @@
- env('QUEUE_CONNECTION', 'database'),
-
- 'connections' => [
-
- 'sync' => [
- 'driver' => 'sync',
- ],
-
- 'database' => [
- 'driver' => 'database',
- 'connection' => env('DB_QUEUE_CONNECTION'),
- 'table' => env('DB_QUEUE_TABLE', 'jobs'),
- 'queue' => env('DB_QUEUE', 'default'),
- 'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90),
- 'after_commit' => false,
- ],
-
- 'null' => [
- 'driver' => 'null',
- ],
-
- ],
-
- /*
- |--------------------------------------------------------------------------
- | Failed Queue Jobs
- |--------------------------------------------------------------------------
- |
- | The background driver does not persist failed jobs. Set to null so
- | Laravel does not try to write to a missing `failed_jobs` table.
- |
- */
-
- 'failed' => [
- 'driver' => env('QUEUE_FAILED_DRIVER', 'null'),
- 'database' => null,
- 'table' => null,
- ],
-
-];
diff --git a/database/migrations/2026_04_09_122314_create_sessions_table.php b/database/migrations/2026_04_09_122314_create_sessions_table.php
deleted file mode 100644
index f60625b..0000000
--- a/database/migrations/2026_04_09_122314_create_sessions_table.php
+++ /dev/null
@@ -1,31 +0,0 @@
-string('id')->primary();
- $table->foreignId('user_id')->nullable()->index();
- $table->string('ip_address', 45)->nullable();
- $table->text('user_agent')->nullable();
- $table->longText('payload');
- $table->integer('last_activity')->index();
- });
- }
-
- /**
- * Reverse the migrations.
- */
- public function down(): void
- {
- Schema::dropIfExists('sessions');
- }
-};
diff --git a/resources/js/app.js b/resources/js/app.js
index 8724cee..abbdd11 100644
--- a/resources/js/app.js
+++ b/resources/js/app.js
@@ -4,55 +4,84 @@ import { initAudio } from './audio';
// Boot Alpine after Livewire (Livewire v3 integrates automatically)
document.addEventListener('alpine:init', () => {
Alpine.data('timerAudio', () => ({
- timer: null,
audio: null,
soundMode: 'beep',
volume: 0.8,
interval: null,
init() {
- this.soundMode = this.$wire.soundMode;
- this.volume = this.$wire.volume;
- this.program = this.$wire.program;
- this.audio = initAudio(this.volume);
+ console.log('timerAudio init', this.$wire);
+ this.audio = initAudio();
+ // Wait for Alpine and Livewire to be ready
+ const boot = () => {
+ if (typeof this.$wire === 'undefined') {
+ console.log('Waiting for $wire...');
+ // Not ready yet, retry on next tick
+ this.$nextTick(boot);
+ return;
+ }
+
+ console.log('$wire ready', this.$wire);
+
+ // Ticker logic: poll wire.tick() every 1 000 ms when the timer is active
+ this.$watch('$wire.state', state => {
+ // Handle Livewire Enum serialization (v3)
+ const stateName = (typeof state === 'string') ? state : state?.value || state?.s || state;
+ console.log('State changed:', stateName);
- // Ticker logic: poll wire.tick() every 1 000 ms when the timer is active
- this.$watch('$wire.state', state => {
- // Handle Livewire Enum serialization (v3)
- const stateName = state;
- console.log('State changed:', stateName);
+ clearInterval(this.interval);
+ if (['RUNNING', 'PAUSE', 'COOLDOWN'].includes(stateName)) {
+ this.interval = setInterval(() => this.$wire.tick(), 1000);
+ }
+ });
- clearInterval(this.interval);
- if (['PREPARE', 'RUNNING', 'PAUSE', 'COOLDOWN'].includes(stateName)) {
+ console.log('Wire state: ', this.$wire.phaseIndex);
+
+ // Boot on first render
+ const initialState = (typeof this.$wire.state === 'string') ? this.$wire.state : this.$wire.state?.value || this.$wire.state?.s || this.$wire.state;
+ if (['RUNNING', 'PAUSE', 'COOLDOWN'].includes(initialState)) {
this.interval = setInterval(() => this.$wire.tick(), 1000);
}
- });
- this.$wire.on('playBeep', ({reason}) => {
- console.log('playBeep', reason);
- if (this.soundMode === 'voice') {
- this.audio.speak(this.voiceText(reason));
- } else if (reason === 'prepare') {
- this.audio.prepareBeep();
- } else {
- this.audio.beep();
- }
- });
+ console.log('Initial state:', initialState);
- this.$wire.on('playEndSound', ({sound}) => {
- if (sound === 'triple') {
- this.audio.tripleBeep();
- } else {
- this.audio.chime();
- }
- });
+ // Receive settings from Livewire
+ this.$wire.on('settingsLoaded', ({ soundMode, volume, program }) => {
+ console.log('Program: ', {program});
+ console.log('settingsLoaded', { soundMode, volume });
+ this.soundMode = soundMode;
+ this.audio.setVolume(volume);
+ });
+
+ // Request initial settings if already loaded
+ this.$wire.requestSettings();
+
+ // Beep trigger from Livewire
+ this.$wire.on('playBeep', ({ reason }) => {
+ console.log('playBeep', reason);
+ if (this.soundMode === 'voice') {
+ this.audio.speak(this.voiceText(reason));
+ } else {
+ this.audio.beep();
+ }
+ });
+ // Pause beep
+ this.$wire.on('playPauseBeep', () => {
+ this.audio.pauseBeep();
+ });
+ // End sound
+ this.$wire.on('playEndSound', ({ sound }) => {
+ if (sound === 'triple') {
+ this.audio.tripleBeep();
+ } else {
+ this.audio.chime();
+ }
+ });
+ };
- this.$wire.on('playPauseBeep', () => {
- this.audio.pauseBeep();
- });
+ this.$nextTick(boot);
},
voiceText(reason) {
const map = {
- prepare: this.$wire?.countdownLabel ?? '',
countdown: this.$wire?.countdownLabel ?? 'Get ready',
rep_end: 'Done',
pause_end: 'Go',
diff --git a/resources/views/livewire/program-editor.blade.php b/resources/views/livewire/program-editor.blade.php
index 9b1cd4f..4897cad 100644
--- a/resources/views/livewire/program-editor.blade.php
+++ b/resources/views/livewire/program-editor.blade.php
@@ -120,7 +120,7 @@ class="w-3 h-10 rounded-full shrink-0"
@if($phase['pause'] > 0)
· {{ $phase['pause'] }}s pause
@endif
- @if($phase['cooldown'] > 0 && !$loop->last)
+ @if($phase['cooldown'] > 0)
· {{ $phase['cooldown'] }}s cooldown
@endif
@@ -247,24 +247,13 @@ class="w-full bg-gray-800 text-white rounded-xl px-4 py-3
@endif
-
+
Cooldown (sec)
-
- @if($this->editingIsLastPhase())
- not counted — add another phase
- @else
- After final rep
- @endif
-
+ After final rep
editingIsLastPhase()) disabled @endif
- class="w-full rounded-xl px-4 py-3 border text-center text-lg font-bold
- focus:outline-none transition-colors
- {{ $this->editingIsLastPhase()
- ? 'bg-gray-800/40 text-gray-600 border-white/5 cursor-not-allowed'
- : 'bg-gray-800 text-white border-white/10 focus:border-blue-500' }}">
+ class="w-full bg-gray-800 text-white rounded-xl px-4 py-3
+ border border-white/10 focus:border-blue-500 focus:outline-none text-center text-lg font-bold">
diff --git a/resources/views/livewire/timer-screen.blade.php b/resources/views/livewire/timer-screen.blade.php
index b405b1f..a63727f 100644
--- a/resources/views/livewire/timer-screen.blade.php
+++ b/resources/views/livewire/timer-screen.blade.php
@@ -9,249 +9,98 @@
--}}
- {{-- ── No program loaded: history list ───────────────────────────── --}}
+ {{-- ── No program loaded ───────────────────────────────────────────── --}}
@if(! $programId)
-
-
- {{-- Header --}}
-
-
Recent Runs
-
- Open Library
-
+
+
-
- {{-- History list --}}
- @if(count($history) === 0)
-
-
-
No runs yet — complete a program to see your history here.
-
- @else
-
- @foreach($history as $entry)
- @php
- $mins = intdiv($entry['total_duration'], 60);
- $secs = $entry['total_duration'] % 60;
- $formattedDuration = sprintf('%d:%02d', $mins, $secs);
- $formattedDate = \Carbon\Carbon::parse($entry['completed_at'])->diffForHumans();
- @endphp
-
- @if($entry['program_exists'])
-
-
-
-
{{ $entry['program_name'] }}
-
{{ $formattedDate }}
-
- {{ $formattedDuration }}
-
- @else
-
-
-
-
{{ $entry['program_name'] }}
-
{{ $formattedDate }} · deleted
-
-
{{ $formattedDuration }}
-
- @endif
- @endforeach
-
- @endif
-
+
No program selected
+
Go to Library and tap a program to start
+
+ Open Library
+
@else
{{-- ── Active timer ────────────────────────────────────────────────── --}}
- {{-- Phase strip — one proportional segment per phase --}}
- @php
- $totalDuration = collect($phases)->sum(function (array $p) {
- return ($p['duration'] * $p['repetitions'])
- + ($p['pause'] * max(0, $p['repetitions'] - 1));
- });
- // Last phase has no cooldown counted; others include it
- foreach ($phases as $i => $p) {
- if ($i < count($phases) - 1) {
- $totalDuration += $p['cooldown'];
- }
- }
- @endphp
-
- @foreach($phases as $i => $phase)
- @php
- $phaseDuration = ($phase['duration'] * $phase['repetitions'])
- + ($phase['pause'] * max(0, $phase['repetitions'] - 1))
- + ($i < count($phases) - 1 ? $phase['cooldown'] : 0);
- $pct = $totalDuration > 0 ? round($phaseDuration / $totalDuration * 100, 2) : 0;
- $isActive = $i === $phaseIndex && !in_array($state->value, ['IDLE', 'COMPLETED']);
- $isDone = $i < $phaseIndex || $state->value === 'COMPLETED';
- @endphp
+ {{-- Phase progress bar --}}
+
+ @if($phaseIndex < collect($phases ?? [])->count() || true)
- @endforeach
+ @endif
- @php
- $ringColor = match($state->value) {
- 'PAUSE', 'PAUSED' => '#6b7280',
- 'COOLDOWN' => '#f97316',
- 'COMPLETED' => '#22c55e',
- default => $phaseColor,
- };
- $ringOpacity = $state->value === 'PAUSED' ? '0.4' : '1';
- $circumference = 2 * M_PI * 120;
- $progress = ($programTotalDuration > 0 && !in_array($state->value, ['IDLE', 'PREPARE']))
- ? $totalRemaining / $programTotalDuration
- : 1.0;
- $dashOffset = $circumference * (1 - $progress);
- @endphp
-
- {{-- ── Ring + inner content ────────────────────────────────────────── --}}
+ {{-- Phase label strip --}}
+
+
+
+ {{ $this->segmentLabel() }}
+
+ @if($this->repLabel())
+ — Rep {{ $this->repLabel() }}
+ @endif
+
+
+ {{-- ── BIG countdown ───────────────────────────────────────────────── --}}
+
+
+ {{-- Main digit --}}
- {{-- SVG ring --}}
-
- {{-- Track --}}
-
- {{-- Progress --}}
-
-
-
- {{-- Inner content --}}
-
-
- {{-- Phase / state label --}}
-
- {{ $this->segmentLabel() }}
-
-
- {{-- Main countdown --}}
-
- {{ $state->value === 'COMPLETED' ? '✓' : $this->formattedRemaining() }}
-
-
- {{-- Rep counter --}}
- @if($this->repLabel())
-
- Rep {{ $this->repLabel() }}
-
- @endif
-
-
+ {{ $state->value === 'COMPLETED' ? '✓' : $this->formattedRemaining() }}
- {{-- State messages below ring --}}
+ {{-- Completed message --}}
@if($state->value === 'COMPLETED')
-
+
Program Complete!
- @elseif($state->value === 'COOLDOWN')
-
+ @endif
+
+ {{-- Cooldown breathing prompt --}}
+ @if($state->value === 'COOLDOWN')
+
Take deep breaths
@endif
+ {{-- Paused label --}}
+ @if($state->value === 'PAUSED')
+
Paused
+ @endif
+
{{-- Total remaining --}}
@if(in_array($state->value, ['RUNNING', 'PAUSE', 'COOLDOWN', 'PAUSED']) && $totalRemaining > 0)
-
+
{{ $this->formattedTotal() }} total remaining
@endif
- {{-- Hold-to-pause hint (ambient, non-intrusive) --}}
- @if(in_array($state->value, ['RUNNING', 'PAUSE', 'COOLDOWN']))
-
- Hold to pause
-
- @endif
-
{{-- ── Controls ───────────────────────────────────────────────────── --}}
@@ -267,9 +116,6 @@ class="w-full bg-blue-600 hover:bg-blue-500 active:bg-blue-700 text-white font-b
Start
- @elseif($state === StateMachine::prepare)
- {{-- No primary action during prepare — user just waits --}}
-
@elseif($state === StateMachine::paused)
$name,
- 'beep_lead_in' => BeepLeadIn::from($leadIn),
- ]);
+ Storage::fake();
- foreach ($phases as $index => $phase) {
- $prog->phases()->create(array_merge($phase, ['sort_order' => $index]));
- }
-
- return $prog;
-}
-
-function createBeepRunner(array $phases = [], int $leadIn = 3): object
-{
- $prog = createProgram('Beep Test', $phases, $leadIn);
+ $prog = TimerProgram::create('Beep Test');
+ $prog->beepLeadIn = BeepLeadIn::from($leadIn);
+ $prog->addPhase(new Phase('Work', $duration, $reps, $pause, $cooldown, '#3b82f6'));
+ $prog->save();
$ctx = new stdClass();
$ctx->beeps = [];
$ctx->runner = new TimerRunner();
- $ctx->runner->load($prog->load('phases'));
+ $ctx->runner->load(TimerProgram::load($prog->id));
$ctx->runner->onBeep(function (string $reason) use ($ctx): void {
$ctx->beeps[] = $reason;
@@ -39,59 +35,19 @@ function createBeepRunner(array $phases = [], int $leadIn = 3): object
$ctx->runner->start();
- // Advance through the 5-second PREPARE countdown before each test begins.
- for ($i = 0; $i < 5; $i++) $ctx->runner->tick();
- $ctx->beeps = []; // discard prepare countdown beeps
-
return $ctx;
}
-function createPhase(string $name, int $duration, int $reps = 1, int $pause = 0, int $cooldown = 0, string $color = "#3b82f6"): array
-{
- return [
- 'label' => $name,
- 'duration' => $duration,
- 'repetitions' => $reps,
- 'pause' => $pause,
- 'cooldown' => $cooldown,
- 'color' => $color,
- ];
-}
-
-// ── Prepare beep ──────────────────────────────────────────────────────────────
-
-test('prepare beep fires once per tick during the 5s prepare countdown', function (): void {
- $prog = createProgram('Prepare beep test', [
- createPhase('Work', duration: 10),
- ]);
-
- $beeps = [];
- $runner = new TimerRunner();
- $runner->load($prog->load('phases'));
- $runner->onBeep(function (string $reason) use (&$beeps): void {
- $beeps[] = $reason;
- });
- $runner->start(); // enters PREPARE (remaining = 5)
-
- for ($i = 0; $i < 5; $i++) $runner->tick();
-
- $prepareCount = count(array_filter($beeps, fn($r) => $r === 'prepare'));
- // Ticks bring remaining 5→4→3→2→1→0 (beginFirstRep); beep fires while remaining > 0
- expect($prepareCount)->toBe(4)
- ->and($beeps)->not->toContain('countdown');
-});
-
// ── Lead-in 3s ────────────────────────────────────────────────────────────────
-test('beep fires during last 3 seconds of a 10s rep (3s lead-in)',
+test(/**
+ * @throws JsonException
+ */ 'beep fires during last 3 seconds of a 10s rep (3s lead-in)',
/**
* @throws JsonException
*/
function (): void {
- $ctx = createBeepRunner([
- createPhase(name: 'Work', duration: 10),
- ],
- );
+ $ctx = beepRunner(duration: 10);
// 7 ticks bring remaining from 10 → 3 (the lead-in window)
for ($i = 0; $i < 7; $i++) $ctx->runner->tick();
@@ -100,11 +56,11 @@ function (): void {
});
test('beep fires exactly 3 times during last 3 seconds of 10s rep',
+ /**
+ * @throws JsonException
+ */
function (): void {
- $ctx = createBeepRunner([
- createPhase(name: 'Work', duration: 10),
- ],
- );
+ $ctx = beepRunner(duration: 10);
for ($i = 0; $i < 10; $i++) $ctx->runner->tick();
@@ -118,10 +74,7 @@ function (): void {
* @throws JsonException
*/
function (): void {
- $ctx = createBeepRunner([
- createPhase(name: 'Work', duration: 15),
- ], leadIn: 5,
- );
+ $ctx = beepRunner(duration: 15, leadIn: 5);
for ($i = 0; $i < 15; $i++) $ctx->runner->tick();
@@ -136,11 +89,7 @@ function (): void {
* @throws JsonException
*/
function (): void {
- $ctx = createBeepRunner(
- [
- createPhase(name: 'Work', duration: 2),
- ],
- );
+ $ctx = beepRunner(duration: 2);
for ($i = 0; $i < 2; $i++) $ctx->runner->tick();
@@ -155,11 +104,7 @@ function (): void {
* @throws JsonException
*/
function (): void {
- $ctx = createBeepRunner(
- [
- createPhase(name: 'Work', duration: 5),
- ],
- );
+ $ctx = beepRunner(duration: 5);
for ($i = 0; $i < 5; $i++) $ctx->runner->tick();
@@ -173,11 +118,7 @@ function (): void {
* @throws JsonException
*/
function (): void {
- $ctx = createBeepRunner(
- [
- createPhase(name: 'Work', duration: 5, reps: 2, pause: 3),
- ],
- );
+ $ctx = beepRunner(duration: 5, reps: 2, pause: 3);
// Rep 1 (5 ticks) then pause (3 ticks)
for ($i = 0; $i < 8; $i++) $ctx->runner->tick();
@@ -192,12 +133,7 @@ function (): void {
* @throws JsonException
*/
function (): void {
- $ctx = createBeepRunner(
- [
- createPhase(name: 'Work', duration: 5, cooldown: 3),
- createPhase(name: 'Rest', duration: 5, cooldown: 3),
- ],
- );
+ $ctx = beepRunner(duration: 5, cooldown: 3);
// Rep (5 ticks) + cooldown (3 ticks)
for ($i = 0; $i < 8; $i++) $ctx->runner->tick();
@@ -212,11 +148,7 @@ function (): void {
* @throws JsonException
*/
function (): void {
- $ctx = createBeepRunner(
- [
- createPhase(name: 'Work', duration: 5, reps: 2, pause: 3),
- ],
- );
+ $ctx = beepRunner(duration: 5, reps: 2, pause: 3);
// Only tick through the pause (ticks 6–8)
for ($i = 0; $i < 8; $i++) $ctx->runner->tick();
@@ -237,11 +169,13 @@ function (): void {
* @throws JsonException
*/
function (): void {
- $prog = Program::query()->create(['name' => 'Pause beep test']);
- $prog->phases()->create(['label' => 'Work', 'duration' => 20, 'repetitions' => 1, 'pause' => 0, 'cooldown' => 0, 'color' => '#3b82f6', 'sort_order' => 0]);
+ Storage::fake();
+ $prog = TimerProgram::create('Pause beep test');
+ $prog->addPhase(new Phase('Work', 20, 1, 0, 0, '#3b82f6'));
+ $prog->save();
$runner = new TimerRunner();
- $runner->load($prog->load('phases'));
+ $runner->load(TimerProgram::load($prog->id));
$pauseBeepCount = 0;
$runner->onPauseBeep(function () use (&$pauseBeepCount): void {
@@ -249,7 +183,6 @@ function (): void {
});
$runner->start();
- for ($i = 0; $i < 5; $i++) $runner->tick(); // skip prepare
$runner->tick();
$runner->pause();
@@ -261,11 +194,13 @@ function (): void {
* @throws JsonException
*/
function (): void {
- $prog = Program::query()->create(['name' => 'Resume test']);
- $prog->phases()->create(['label' => 'Work', 'duration' => 20, 'repetitions' => 1, 'pause' => 0, 'cooldown' => 0, 'color' => '#3b82f6', 'sort_order' => 0]);
+ Storage::fake();
+ $prog = TimerProgram::create('Resume test');
+ $prog->addPhase(new Phase('Work', 20, 1, 0, 0, '#3b82f6'));
+ $prog->save();
$runner = new TimerRunner();
- $runner->load($prog->load('phases'));
+ $runner->load(TimerProgram::load($prog->id));
$pauseBeepCount = 0;
$runner->onPauseBeep(function () use (&$pauseBeepCount): void {
@@ -273,7 +208,6 @@ function (): void {
});
$runner->start();
- for ($i = 0; $i < 5; $i++) $runner->tick(); // skip prepare
$runner->tick();
$runner->pause();
$runner->resume(); // must not fire another pause beep
From 9696231ea3fbe93b3fd485944c129d0c1b3326ff Mon Sep 17 00:00:00 2001
From: Nikola Bucic
Date: Tue, 7 Apr 2026 02:30:44 +0200
Subject: [PATCH 04/10] fix: resolve timer not starting and display bugs in
TimerScreen
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Four bugs prevented the HIIT timer from functioning:
1. $runner->start() was commented out in TimerScreen::start(), so tapping
Start (or wire:init auto-start) never transitioned the cursor from idle
to running — the JS ticker never fired and totalRemaining stayed at 0.
2. segmentLabel() used string literals ('pause', 'cooldown', etc.) in a
match against a StateMachine enum, so the match never hit and always
fell through to the default (phaseLabel).
3. resumeAs() in TimerCursor used clone(\$this, [...]) — not valid PHP —
causing a fatal error whenever the user resumed from pause.
4. repLabel() called in_array(\$this->state->value, ['pause', ...]) with
lowercase strings, but StateMachine values are uppercase ('PAUSE', etc.),
so the rep label rendered in states where it should be hidden.
Co-Authored-By: Claude Sonnet 4.6
---
app/Livewire/TimerScreen.php | 12 ++++++------
app/Timer/TimerCursor.php | 13 ++++++-------
2 files changed, 12 insertions(+), 13 deletions(-)
diff --git a/app/Livewire/TimerScreen.php b/app/Livewire/TimerScreen.php
index c50f60d..f76bcdb 100644
--- a/app/Livewire/TimerScreen.php
+++ b/app/Livewire/TimerScreen.php
@@ -142,7 +142,7 @@ public function render(): View
public function repLabel(): string
{
- if (in_array($this->state->value, ['pause', 'cooldown', 'paused', 'completed', 'idle'], true)) {
+ if (in_array($this->state, [StateMachine::pause, StateMachine::cooldown, StateMachine::paused, StateMachine::completed, StateMachine::idle], true)) {
return '';
}
return sprintf('%d / %d', $this->repIndex + 1, $this->phaseReps);
@@ -173,10 +173,10 @@ public function resume(): void
public function segmentLabel(): string
{
return match ($this->state) {
- 'pause' => 'Pause',
- 'cooldown' => 'Cooldown',
- 'paused' => 'Paused',
- 'completed' => 'Complete!',
+ StateMachine::pause => 'Pause',
+ StateMachine::cooldown => 'Cooldown',
+ StateMachine::paused => 'Paused',
+ StateMachine::completed => 'Complete!',
default => $this->phaseLabel,
};
}
@@ -196,7 +196,7 @@ public function start(): void
$this->dispatch('playPauseBeep');
});
-// $runner->start();
+ $runner->start();
$this->syncCursor($runner->cursor(), $program);
// EDGE top bar → program name
diff --git a/app/Timer/TimerCursor.php b/app/Timer/TimerCursor.php
index 0bfa415..bb44fa2 100644
--- a/app/Timer/TimerCursor.php
+++ b/app/Timer/TimerCursor.php
@@ -171,13 +171,12 @@ public function pause(): self
public function resumeAs(StateMachine $state): self
{
/* PHP 8.5: return clone $this with { state: $state }; */
- return clone($this, [
- 'phaseIndex' => $this->phaseIndex,
- 'repIndex' => $this->repIndex,
- 'state' => $state,
- 'remaining' => $this->remaining,
- 'totalRemaining' => $this->totalRemaining,
- ]
+ return new self(
+ phaseIndex: $this->phaseIndex,
+ repIndex: $this->repIndex,
+ state: $state,
+ remaining: $this->remaining,
+ totalRemaining: $this->totalRemaining,
);
}
From 4b1001c4be50454d00f7db54673c9e0276ba4c79 Mon Sep 17 00:00:00 2001
From: Nikola Bucic
Date: Tue, 7 Apr 2026 02:37:03 +0200
Subject: [PATCH 05/10] fix: use PHP 8.5 clone() syntax in resumeAs()
The original clone($this, [...]) was valid PHP 8.5 (RFC clone_with_v2,
voted 16-4, merged). My previous fix replacing it with new self() was
unnecessary. Restore the idiomatic form, simplified to only override
the one property that actually changes.
Co-Authored-By: Claude Sonnet 4.6
---
app/Timer/TimerCursor.php | 9 +--------
1 file changed, 1 insertion(+), 8 deletions(-)
diff --git a/app/Timer/TimerCursor.php b/app/Timer/TimerCursor.php
index bb44fa2..6a17bb7 100644
--- a/app/Timer/TimerCursor.php
+++ b/app/Timer/TimerCursor.php
@@ -170,14 +170,7 @@ public function pause(): self
/** Resume into a named substate (the state that was active pre-pause). */
public function resumeAs(StateMachine $state): self
{
- /* PHP 8.5: return clone $this with { state: $state }; */
- return new self(
- phaseIndex: $this->phaseIndex,
- repIndex: $this->repIndex,
- state: $state,
- remaining: $this->remaining,
- totalRemaining: $this->totalRemaining,
- );
+ return clone($this, ['state' => $state]);
}
/** Tick one second off the current segment and the total remaining. */
From 3c870ecdf08047952371e60f0423bb0826270b8b Mon Sep 17 00:00:00 2001
From: Nikola Bucic
Date: Tue, 7 Apr 2026 02:47:35 +0200
Subject: [PATCH 06/10] Minor Timer Runner and Screen fixes
---
app/Livewire/TimerScreen.php | 5 ++---
app/Timer/TimerRunner.php | 2 ++
2 files changed, 4 insertions(+), 3 deletions(-)
diff --git a/app/Livewire/TimerScreen.php b/app/Livewire/TimerScreen.php
index f76bcdb..d321194 100644
--- a/app/Livewire/TimerScreen.php
+++ b/app/Livewire/TimerScreen.php
@@ -76,6 +76,7 @@ public function mount(?string $id = null): void
$this->soundMode = $settings->soundMode;
$this->volume = $settings->volume;
+
if ($id) {
$this->loadProgram($id);
}
@@ -88,15 +89,12 @@ public function loadProgram(string $id): void
$runner->load($program);
- Log::info("Runner info: {$runner->program()->name}.");
-
$this->programId = $id;
$this->programName = $program->name;
$this->endSound = $program->endSound;
$this->syncCursor($runner->cursor(), $program);
- Log::info('Dispatching settings loaded');
// Push settings to JS audio layer
$this->dispatch('settingsLoaded', soundMode: $this->soundMode, volume: $this->volume, program: $program);
}
@@ -195,6 +193,7 @@ public function start(): void
$runner->onPauseBeep(function (): void {
$this->dispatch('playPauseBeep');
});
+ $runner->load($program);
$runner->start();
$this->syncCursor($runner->cursor(), $program);
diff --git a/app/Timer/TimerRunner.php b/app/Timer/TimerRunner.php
index b170e77..008f214 100644
--- a/app/Timer/TimerRunner.php
+++ b/app/Timer/TimerRunner.php
@@ -8,6 +8,7 @@
use App\Events\PhaseChanged;
use App\Events\ProgramCompleted;
use Closure;
+use Illuminate\Support\Facades\Log;
use RuntimeException;
/**
@@ -81,6 +82,7 @@ public function setClock(Closure $fn): void { $this->clockFn = $fn; }
public function load(TimerProgram $program): void
{
$this->program = $program;
+
$this->cursor = TimerCursor::idle();
}
From 321f5de5b87b5162de2c807c0ad011d9fa1c5b53 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Nikola=20Buci=C4=87?=
Date: Tue, 7 Apr 2026 20:32:55 +0200
Subject: [PATCH 07/10] Fixing timer iteration
---
app/Livewire/TimerScreen.php | 42 ++-
app/Timer/TimerCursor.php | 111 +++----
app/Timer/TimerProgram.php | 4 +-
app/Timer/TimerRunner.php | 291 ++++++++++--------
composer.json | 7 +-
resources/js/app.js | 100 ++----
.../views/livewire/program-editor.blade.php | 2 +-
.../views/livewire/timer-screen.blade.php | 5 +-
8 files changed, 279 insertions(+), 283 deletions(-)
diff --git a/app/Livewire/TimerScreen.php b/app/Livewire/TimerScreen.php
index d321194..f7e1567 100644
--- a/app/Livewire/TimerScreen.php
+++ b/app/Livewire/TimerScreen.php
@@ -76,7 +76,6 @@ public function mount(?string $id = null): void
$this->soundMode = $settings->soundMode;
$this->volume = $settings->volume;
-
if ($id) {
$this->loadProgram($id);
}
@@ -87,11 +86,10 @@ public function loadProgram(string $id): void
$runner = app(TimerRunner::class);
$program = TimerProgram::load($id);
- $runner->load($program);
-
$this->programId = $id;
$this->programName = $program->name;
$this->endSound = $program->endSound;
+ $this->rehydrateRunner($runner);
$this->syncCursor($runner->cursor(), $program);
@@ -127,6 +125,7 @@ private function syncCursor(TimerCursor $cursor, TimerProgram $program): void
public function pause(): void
{
$runner = app(TimerRunner::class);
+ $this->rehydrateRunner($runner);
$runner->pause();
$this->syncCursor($runner->cursor(), TimerProgram::load($this->programId));
}
@@ -164,6 +163,7 @@ public function restart(): void
public function resume(): void
{
$runner = app(TimerRunner::class);
+ $this->rehydrateRunner($runner);
$runner->resume();
$this->syncCursor($runner->cursor(), TimerProgram::load($this->programId));
}
@@ -187,12 +187,7 @@ public function start(): void
$runner = app(TimerRunner::class);
$program = TimerProgram::load($this->programId);
- $runner->onBeep(function (string $reason) use ($program): void {
- $this->handleBeep($reason, $program);
- });
- $runner->onPauseBeep(function (): void {
- $this->dispatch('playPauseBeep');
- });
+
$runner->load($program);
$runner->start();
@@ -208,7 +203,7 @@ private function handleBeep(string $reason, TimerProgram $program): void
{
// Determine the countdown label for voice mode
$this->countdownLabel = match ($reason) {
- 'countdown' => $this->remaining . ' seconds',
+ 'countdown' => (string)$this->remaining,
'rep_end' => 'Done',
'pause_end' => 'Go',
'cooldown_end' => 'Next',
@@ -223,6 +218,7 @@ private function handleBeep(string $reason, TimerProgram $program): void
public function tick(): void
{
$runner = app(TimerRunner::class);
+ $this->rehydrateRunner($runner);
if (!$runner->cursor()->isActive()) {
return;
@@ -235,8 +231,34 @@ public function tick(): void
$this->syncCursor($cursor, $program);
if ($cursor->isCompleted()) {
+ Log::info('Completed!');
$this->dispatch('playEndSound', sound: $this->endSound);
$this->dispatch('topbar-title', title: config('app.name'));
}
}
+
+ private function rehydrateRunner(TimerRunner $runner): void
+ {
+ if (!$this->programId) {
+ return;
+ }
+ $program = TimerProgram::load($this->programId);
+ $runner->load($program);
+
+ $cursor = new TimerCursor(
+ phaseIndex: $this->phaseIndex,
+ repIndex: $this->repIndex,
+ state: $this->state,
+ remaining: $this->remaining,
+ totalRemaining: $this->totalRemaining
+ );
+
+ $runner->cursor = $cursor;
+ $runner->onBeep(function (string $reason) use ($program): void {
+ $this->handleBeep($reason, $program);
+ });
+ $runner->onPauseBeep(function (): void {
+ $this->dispatch('playPauseBeep');
+ });
+ }
}
diff --git a/app/Timer/TimerCursor.php b/app/Timer/TimerCursor.php
index 6a17bb7..20f61a3 100644
--- a/app/Timer/TimerCursor.php
+++ b/app/Timer/TimerCursor.php
@@ -42,44 +42,39 @@ public static function idle(): self
/** Mark the program as completed. */
public function complete(): self
{
- /* PHP 8.5: return clone $this with { state: StateMachine::completed,
- remaining: 0, totalRemaining: 0 }; */
- return new self(
- phaseIndex: $this->phaseIndex,
- repIndex: $this->repIndex,
- state: StateMachine::completed,
- remaining: 0,
- totalRemaining: 0,
+ return clone($this, [
+ 'phaseIndex' => $this->phaseIndex,
+ 'repIndex' => $this->repIndex,
+ 'state' => StateMachine::completed,
+ 'remaining' => 0,
+ 'totalRemaining' => 0,
+ ]
);
}
/** Move into the cooldown state after the final rep of a phase. */
public function enterCooldown(int $cooldownDuration, int $totalRemaining): self
{
- /* PHP 8.5: return clone $this with { state: StateMachine::cooldown,
- remaining: $cooldownDuration,
- totalRemaining: $totalRemaining }; */
- return new self(
- phaseIndex: $this->phaseIndex,
- repIndex: $this->repIndex,
- state: StateMachine::cooldown,
- remaining: $cooldownDuration,
- totalRemaining: $totalRemaining,
+ return clone($this, [
+ 'phaseIndex' => $this->phaseIndex,
+ 'repIndex' => $this->repIndex,
+ 'state' => StateMachine::cooldown,
+ 'remaining' => $cooldownDuration,
+ 'totalRemaining' => $totalRemaining,
+ ]
);
}
/** Move into the pause state between repetitions. */
public function enterPause(int $pauseDuration, int $totalRemaining): self
{
- /* PHP 8.5: return clone $this with { state: StateMachine::pause,
- remaining: $pauseDuration,
- totalRemaining: $totalRemaining }; */
- return new self(
- phaseIndex: $this->phaseIndex,
- repIndex: $this->repIndex,
- state: StateMachine::pause,
- remaining: $pauseDuration,
- totalRemaining: $totalRemaining,
+ return clone($this, [
+ 'phaseIndex' => $this->phaseIndex,
+ 'repIndex' => $this->repIndex,
+ 'state' => StateMachine::pause,
+ 'remaining' => $pauseDuration,
+ 'totalRemaining' => $totalRemaining,
+ ]
);
}
@@ -124,46 +119,39 @@ public function isRunning(): bool
/** Advance to the first rep of the next phase. */
public function nextPhase(int $phaseIndex, int $repDuration, int $totalRemaining): self
{
- /* PHP 8.5: return clone $this with { phaseIndex: $phaseIndex,
- repIndex: 0,
- state: StateMachine::running,
- remaining: $repDuration,
- totalRemaining: $totalRemaining }; */
- return new self(
- phaseIndex: $phaseIndex,
- repIndex: 0,
- state: StateMachine::running,
- remaining: $repDuration,
- totalRemaining: $totalRemaining,
+ return clone($this, [
+ 'phaseIndex' => $phaseIndex,
+ 'repIndex' => 0,
+ 'state' => StateMachine::running,
+ 'remaining' => $repDuration,
+ 'totalRemaining' => $totalRemaining,
+ ]
);
}
/** Advance to the next repetition of the same phase. */
public function nextRep(int $repDuration, int $totalRemaining): self
{
- /* PHP 8.5: return clone $this with { repIndex: $this->repIndex + 1,
- state: StateMachine::running,
- remaining: $repDuration,
- totalRemaining: $totalRemaining }; */
- return new self(
- phaseIndex: $this->phaseIndex,
- repIndex: $this->repIndex + 1,
- state: StateMachine::running,
- remaining: $repDuration,
- totalRemaining: $totalRemaining,
+ return clone($this, [
+ 'phaseIndex' => $this->phaseIndex,
+ 'repIndex' => $this->repIndex + 1,
+ 'state' => StateMachine::running,
+ 'remaining' => $repDuration,
+ 'totalRemaining' => $totalRemaining,
+ ]
);
}
/** User pressed pause (or phone call received). */
public function pause(): self
{
- /* PHP 8.5: return clone $this with { state: StateMachine::paused }; */
- return new self(
- phaseIndex: $this->phaseIndex,
- repIndex: $this->repIndex,
- state: StateMachine::paused,
- remaining: $this->remaining,
- totalRemaining: $this->totalRemaining,
+ return clone($this, [
+ 'phaseIndex' => $this->phaseIndex,
+ 'repIndex' => $this->repIndex,
+ 'state' => StateMachine::paused,
+ 'remaining' => $this->remaining,
+ 'totalRemaining' => $this->totalRemaining,
+ ]
);
}
@@ -176,14 +164,13 @@ public function resumeAs(StateMachine $state): self
/** Tick one second off the current segment and the total remaining. */
public function tick(): self
{
- /* PHP 8.5: return clone $this with { remaining: max (0, $this->remaining - 1),
- totalRemaining: max(0, $this->totalRemaining - 1) }; */
- return new self(
- phaseIndex: $this->phaseIndex,
- repIndex: $this->repIndex,
- state: $this->state,
- remaining: max(0, $this->remaining - 1),
- totalRemaining: max(0, $this->totalRemaining - 1),
+ return clone($this, [
+ 'phaseIndex' => $this->phaseIndex,
+ 'repIndex' => $this->repIndex,
+ 'state' => $this->state,
+ 'remaining' => max(0, $this->remaining - 1),
+ 'totalRemaining' => max(0, $this->totalRemaining - 1),
+ ]
);
}
}
diff --git a/app/Timer/TimerProgram.php b/app/Timer/TimerProgram.php
index 6826737..1f23eba 100644
--- a/app/Timer/TimerProgram.php
+++ b/app/Timer/TimerProgram.php
@@ -195,7 +195,7 @@ public function toArray(): array
#[NoDiscard]
public function totalDuration(): int
{
- return array_reduce(
+ $totalDuration = array_reduce(
$this->phases,
static function (int $carry, Phase $phase): int {
$repTime = $phase->duration * $phase->repetitions;
@@ -204,5 +204,7 @@ static function (int $carry, Phase $phase): int {
},
0,
);
+
+ return $totalDuration - array_last($this->phases)->cooldown;
}
}
diff --git a/app/Timer/TimerRunner.php b/app/Timer/TimerRunner.php
index 008f214..be756c7 100644
--- a/app/Timer/TimerRunner.php
+++ b/app/Timer/TimerRunner.php
@@ -8,7 +8,6 @@
use App\Events\PhaseChanged;
use App\Events\ProgramCompleted;
use Closure;
-use Illuminate\Support\Facades\Log;
use RuntimeException;
/**
@@ -28,9 +27,9 @@
* Resume restores to the previous active state.
*
* Beep rule: fire the segment-appropriate end beep based on the expired state.
- * running → 'rep_end'
- * pause → 'pause_end'
- * cooldown → 'cooldown_end'
+ * 'Running' → 'rep_end'
+ * 'Pause' → 'pause_end'
+ * 'Cooldown' → 'cooldown_end'
*
* Countdown beep fires during the last N seconds of each segment (lead-in).
* If a segment < lead-in, the countdown starts from second 1.
@@ -38,21 +37,22 @@
class TimerRunner
{
private ?TimerProgram $program = null;
- private TimerCursor $cursor;
+ public TimerCursor $cursor {
+ set {
+ $this->cursor = $value;
+ }
+ }
/** State before the user pressed pause — needed for resume. */
private StateMachine $stateBeforePause = StateMachine::running;
/** Callable invoked each time the cursor changes: fn(TimerCursor) */
- private ?Closure $onTick = null;
+ private ?Closure $onTick = null;
/** Callable invoked when a beep should fire: fn(string $reason) */
- private ?Closure $onBeep = null;
+ private ?Closure $onBeep = null;
/** Callable for the single pause-beep: fn() */
private ?Closure $onPauseBeep = null;
- /** Fake-clock override (seconds since epoch) — set in tests. */
- private ?Closure $clockFn = null;
-
public function __construct()
{
$this->cursor = TimerCursor::idle();
@@ -60,30 +60,84 @@ public function __construct()
// ── Public accessors ──────────────────────────────────────────────────────
- public function cursor(): TimerCursor { return $this->cursor; }
- public function program(): ?TimerProgram { return $this->program; }
- public function isIdle(): bool { return $this->cursor->isIdle(); }
- public function isRunning(): bool { return $this->cursor->isRunning(); }
- public function isPaused(): bool { return $this->cursor->isPaused(); }
- public function isCompleted(): bool { return $this->cursor->isCompleted(); }
+ public function cursor(): TimerCursor
+ {
+ return $this->cursor;
+ }
+
+ /** Silent discard — no history entry, no ProgramCompleted event. */
+ public function discard(): void
+ {
+ $this->program = null;
+ $this->cursor = TimerCursor::idle();
+ }
+
+ public function isRunning(): bool
+ {
+ return $this->cursor->isRunning();
+ }
+
+ /** Load a program and reset the cursor to idle. */
+ public function load(TimerProgram $program): void
+ {
+ $this->program = $program;
+
+ $this->cursor = TimerCursor::idle();
+ }
+
+ public function onBeep(Closure $fn): void
+ {
+ $this->onBeep = $fn;
+ }
// ── Callback registration ─────────────────────────────────────────────────
- public function onTick(Closure $fn): void { $this->onTick = $fn; }
- public function onBeep(Closure $fn): void { $this->onBeep = $fn; }
- public function onPauseBeep(Closure $fn): void { $this->onPauseBeep = $fn; }
+ public function onPauseBeep(Closure $fn): void
+ {
+ $this->onPauseBeep = $fn;
+ }
+
+ public function onTick(Closure $fn): void
+ {
+ $this->onTick = $fn;
+ }
+
+ /** User-initiated pause (or PhoneStateListener). */
+ public function pause(): void
+ {
+ if (!$this->cursor->isActive()) {
+ return;
+ }
+
+ $this->stateBeforePause = $this->cursor->state;
+ $this->cursor = $this->cursor->pause();
+ $this->fireOnPauseBeep();
+ $this->notifyTick();
+ }
- /** Override the clock for tests. fn(): int (seconds since epoch) */
- public function setClock(Closure $fn): void { $this->clockFn = $fn; }
+ private function fireOnPauseBeep(): void
+ {
+ if ($this->onPauseBeep !== null) {
+ ($this->onPauseBeep)();
+ }
+ }
+
+ public function program(): ?TimerProgram
+ {
+ return $this->program;
+ }
// ── Control surface ───────────────────────────────────────────────────────
- /** Load a program and reset the cursor to idle. */
- public function load(TimerProgram $program): void
+ /** Resume from the user-paused state, restoring the pre-pause substate. */
+ public function resume(): void
{
- $this->program = $program;
+ if (!$this->cursor->isPaused()) {
+ return;
+ }
- $this->cursor = TimerCursor::idle();
+ $this->cursor = $this->cursor->resumeAs($this->stateBeforePause);
+ $this->notifyTick();
}
/** Start the timer from idle. */
@@ -91,33 +145,63 @@ public function start(): void
{
$this->assertProgramLoaded();
- if (! $this->isIdle()) {
+ if (!$this->isIdle()) {
throw new RuntimeException(
"Cannot start: expected state 'idle', got '{$this->cursor->state->value}'.",
);
}
- $phase = $this->currentPhase();
+ $phase = $this->currentPhase();
$totalRemaining = $this->program->totalDuration();
$this->cursor = new TimerCursor(
- phaseIndex: 0,
- repIndex: 0,
- state: StateMachine::running,
- remaining: $phase->duration,
+ phaseIndex: 0,
+ repIndex: 0,
+ state: StateMachine::running,
+ remaining: $phase->duration,
totalRemaining: $totalRemaining,
);
PhaseChanged::dispatch($this->program->id, 0, $phase, 0);
}
+ private function assertProgramLoaded(): void
+ {
+ if ($this->program === null) {
+ throw new RuntimeException('No program loaded. Call load() first.');
+ }
+ if (count($this->program->phases) === 0) {
+ throw new RuntimeException('Program has no phases.');
+ }
+ }
+
+ // ── State machine ─────────────────────────────────────────────────────────
+
+ public function isIdle(): bool
+ {
+ return $this->cursor->isIdle();
+ }
+
+ private function currentPhase(): Phase
+ {
+ // PHP 8.5: array_first_value($this->phases())
+ return ($this->phases()[0] ?? null)
+ ?? throw new RuntimeException('Program has no phases.');
+ }
+
+ /** @return Phase[] */
+ private function phases(): array
+ {
+ return $this->program->phases;
+ }
+
/**
* Advance the timer by one second.
* Called every second from Alpine's setInterval in timer-screen.blade.php.
*/
public function tick(): void
{
- if (! $this->cursor->isActive()) {
+ if (!$this->cursor->isActive()) {
return;
}
@@ -136,38 +220,50 @@ public function tick(): void
$this->advance($cursor);
}
- /** User-initiated pause (or PhoneStateListener). */
- public function pause(): void
+ // ── Beep helpers ──────────────────────────────────────────────────────────
+
+ /**
+ * True when the cursor's remaining falls within the lead-in countdown window.
+ * Uses beepLeadIn->value to extract the int from the BeepLeadIn backed enum.
+ */
+ private function shouldBeep(TimerCursor $cursor): bool
{
- if (! $this->cursor->isActive()) {
- return;
+ if (!$cursor->isActive()) {
+ return false;
}
- $this->stateBeforePause = $this->cursor->state;
- $this->cursor = $this->cursor->pause();
- $this->fireOnPauseBeep();
- $this->notifyTick();
+ $leadIn = $this->program->beepLeadIn->value; // BeepLeadIn: int enum
+ $segmentTotal = $this->segmentDurationForCursor($cursor);
+ $effectiveLead = ($segmentTotal < $leadIn) ? max(1, $segmentTotal - 1) : $leadIn;
+
+ return $cursor->remaining <= $effectiveLead && $cursor->remaining > 0;
}
- /** Resume from the user-paused state, restoring the pre-pause substate. */
- public function resume(): void
+ private function segmentDurationForCursor(TimerCursor $cursor): int
{
- if (! $this->cursor->isPaused()) {
- return;
- }
+ $phase = $this->phases()[$cursor->phaseIndex];
- $this->cursor = $this->cursor->resumeAs($this->stateBeforePause);
- $this->notifyTick();
+ return match ($cursor->state) {
+ StateMachine::running => $phase->duration,
+ StateMachine::pause => $phase->pause,
+ StateMachine::cooldown => $phase->cooldown,
+ default => 0,
+ };
}
- /** Silent discard — no history entry, no ProgramCompleted event. */
- public function discard(): void
+ private function fireBeep(string $reason): void
{
- $this->program = null;
- $this->cursor = TimerCursor::idle();
+ if ($this->onBeep !== null) {
+ ($this->onBeep)($reason);
+ }
}
- // ── State machine ─────────────────────────────────────────────────────────
+ private function notifyTick(): void
+ {
+ if ($this->onTick !== null) {
+ ($this->onTick)($this->cursor);
+ }
+ }
/**
* Called when cursor->remaining hits 0.
@@ -175,8 +271,8 @@ public function discard(): void
*/
private function advance(TimerCursor $cursor): void
{
- $phase = $this->phases()[$cursor->phaseIndex];
- $isLastRep = ($cursor->repIndex >= $phase->repetitions - 1);
+ $phase = $this->phases()[$cursor->phaseIndex];
+ $isLastRep = ($cursor->repIndex >= $phase->repetitions - 1);
$isLastPhase = ($cursor->phaseIndex >= count($this->phases()) - 1);
// ── Pause segment expired → next rep ──────────────────────────────
@@ -196,7 +292,7 @@ private function advance(TimerCursor $cursor): void
// ── Running rep expired ───────────────────────────────────────────
$this->fireBeep('rep_end');
- if (! $isLastRep && $phase->pause > 0) {
+ if (!$isLastRep && $phase->pause > 0) {
$this->cursor = $cursor->enterPause(
$phase->pause,
max(0, $cursor->totalRemaining),
@@ -205,13 +301,13 @@ private function advance(TimerCursor $cursor): void
return;
}
- if (! $isLastRep) {
+ if (!$isLastRep) {
$this->advanceToNextRep($cursor, $phase);
return;
}
// Last rep → cooldown (always executes, even as the last phase's last rep).
- if ($phase->cooldown > 0) {
+ if ($phase->cooldown > 0 && !$isLastPhase) {
$this->cursor = $cursor->enterCooldown(
$phase->cooldown,
max(0, $cursor->totalRemaining),
@@ -224,6 +320,8 @@ private function advance(TimerCursor $cursor): void
$this->advanceAfterCooldown($cursor, $isLastPhase);
}
+ // ── Helpers ───────────────────────────────────────────────────────────────
+
private function advanceToNextRep(TimerCursor $cursor, Phase $phase): void
{
$this->cursor = $cursor->nextRep(
@@ -241,7 +339,7 @@ private function advanceAfterCooldown(TimerCursor $cursor, bool $isLastPhase): v
}
$nextPhaseIndex = $cursor->phaseIndex + 1;
- $nextPhase = $this->phases()[$nextPhaseIndex];
+ $nextPhase = $this->phases()[$nextPhaseIndex];
$this->cursor = $cursor->nextPhase(
$nextPhaseIndex,
@@ -266,81 +364,4 @@ private function complete(): void
$this->program->touch();
$this->notifyTick();
}
-
- // ── Beep helpers ──────────────────────────────────────────────────────────
-
- /**
- * True when the cursor's remaining falls within the lead-in countdown window.
- * Uses beepLeadIn->value to extract the int from the BeepLeadIn backed enum.
- */
- private function shouldBeep(TimerCursor $cursor): bool
- {
- if (! $cursor->isActive()) {
- return false;
- }
-
- $leadIn = $this->program->beepLeadIn->value; // BeepLeadIn: int enum
- $segmentTotal = $this->segmentDurationForCursor($cursor);
- $effectiveLead = ($segmentTotal < $leadIn) ? max(1, $segmentTotal - 1) : $leadIn;
-
- return $cursor->remaining <= $effectiveLead && $cursor->remaining > 0;
- }
-
- private function segmentDurationForCursor(TimerCursor $cursor): int
- {
- $phase = $this->phases()[$cursor->phaseIndex];
-
- return match ($cursor->state) {
- StateMachine::running => $phase->duration,
- StateMachine::pause => $phase->pause,
- StateMachine::cooldown => $phase->cooldown,
- default => 0,
- };
- }
-
- private function fireBeep(string $reason): void
- {
- if ($this->onBeep !== null) {
- ($this->onBeep)($reason);
- }
- }
-
- private function fireOnPauseBeep(): void
- {
- if ($this->onPauseBeep !== null) {
- ($this->onPauseBeep)();
- }
- }
-
- private function notifyTick(): void
- {
- if ($this->onTick !== null) {
- ($this->onTick)($this->cursor);
- }
- }
-
- // ── Helpers ───────────────────────────────────────────────────────────────
-
- private function assertProgramLoaded(): void
- {
- if ($this->program === null) {
- throw new RuntimeException('No program loaded. Call load() first.');
- }
- if (count($this->program->phases) === 0) {
- throw new RuntimeException('Program has no phases.');
- }
- }
-
- private function currentPhase(): Phase
- {
- // PHP 8.5: array_first_value($this->phases())
- return ($this->phases()[0] ?? null)
- ?? throw new RuntimeException('Program has no phases.');
- }
-
- /** @return Phase[] */
- private function phases(): array
- {
- return $this->program->phases;
- }
}
diff --git a/composer.json b/composer.json
index eec89f2..07b4d89 100644
--- a/composer.json
+++ b/composer.json
@@ -41,13 +41,12 @@
"composer install",
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
"@php artisan key:generate",
- "@php artisan migrate --force",
"npm install --ignore-scripts",
"npm run build"
],
"dev": [
"Composer\\Config::disableProcessTimeout",
- "npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
+ "npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185\" \"php artisan serve\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,logs,vite --kill-others"
],
"test": [
"@php artisan config:clear --ansi",
@@ -64,9 +63,7 @@
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
],
"post-create-project-cmd": [
- "@php artisan key:generate --ansi",
- "@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
- "@php artisan migrate --graceful --ansi"
+ "@php artisan key:generate --ansi"
],
"pre-package-uninstall": [
"Illuminate\\Foundation\\ComposerScripts::prePackageUninstall"
diff --git a/resources/js/app.js b/resources/js/app.js
index abbdd11..5bb3aca 100644
--- a/resources/js/app.js
+++ b/resources/js/app.js
@@ -4,81 +4,49 @@ import { initAudio } from './audio';
// Boot Alpine after Livewire (Livewire v3 integrates automatically)
document.addEventListener('alpine:init', () => {
Alpine.data('timerAudio', () => ({
+ timer: null,
audio: null,
soundMode: 'beep',
volume: 0.8,
interval: null,
init() {
- console.log('timerAudio init', this.$wire);
- this.audio = initAudio();
- // Wait for Alpine and Livewire to be ready
- const boot = () => {
- if (typeof this.$wire === 'undefined') {
- console.log('Waiting for $wire...');
- // Not ready yet, retry on next tick
- this.$nextTick(boot);
- return;
- }
-
- console.log('$wire ready', this.$wire);
-
- // Ticker logic: poll wire.tick() every 1 000 ms when the timer is active
- this.$watch('$wire.state', state => {
- // Handle Livewire Enum serialization (v3)
- const stateName = (typeof state === 'string') ? state : state?.value || state?.s || state;
- console.log('State changed:', stateName);
-
- clearInterval(this.interval);
- if (['RUNNING', 'PAUSE', 'COOLDOWN'].includes(stateName)) {
- this.interval = setInterval(() => this.$wire.tick(), 1000);
- }
- });
-
- console.log('Wire state: ', this.$wire.phaseIndex);
-
- // Boot on first render
- const initialState = (typeof this.$wire.state === 'string') ? this.$wire.state : this.$wire.state?.value || this.$wire.state?.s || this.$wire.state;
- if (['RUNNING', 'PAUSE', 'COOLDOWN'].includes(initialState)) {
+ this.soundMode = this.$wire.soundMode;
+ this.volume = this.$wire.volume;
+ this.program = this.$wire.program;
+ this.audio = initAudio(this.volume);
+
+ // Ticker logic: poll wire.tick() every 1 000 ms when the timer is active
+ this.$watch('$wire.state', state => {
+ // Handle Livewire Enum serialization (v3)
+ const stateName = state;
+ console.log('State changed:', stateName);
+
+ clearInterval(this.interval);
+ if (['RUNNING', 'PAUSE', 'COOLDOWN'].includes(stateName)) {
this.interval = setInterval(() => this.$wire.tick(), 1000);
}
+ });
+
+ this.$wire.on('playBeep', ({reason}) => {
+ console.log('playBeep', reason);
+ if (this.soundMode === 'voice') {
+ this.audio.speak(this.voiceText(reason));
+ } else {
+ this.audio.beep();
+ }
+ });
- console.log('Initial state:', initialState);
-
- // Receive settings from Livewire
- this.$wire.on('settingsLoaded', ({ soundMode, volume, program }) => {
- console.log('Program: ', {program});
- console.log('settingsLoaded', { soundMode, volume });
- this.soundMode = soundMode;
- this.audio.setVolume(volume);
- });
-
- // Request initial settings if already loaded
- this.$wire.requestSettings();
-
- // Beep trigger from Livewire
- this.$wire.on('playBeep', ({ reason }) => {
- console.log('playBeep', reason);
- if (this.soundMode === 'voice') {
- this.audio.speak(this.voiceText(reason));
- } else {
- this.audio.beep();
- }
- });
- // Pause beep
- this.$wire.on('playPauseBeep', () => {
- this.audio.pauseBeep();
- });
- // End sound
- this.$wire.on('playEndSound', ({ sound }) => {
- if (sound === 'triple') {
- this.audio.tripleBeep();
- } else {
- this.audio.chime();
- }
- });
- };
+ this.$wire.on('playEndSound', ({sound}) => {
+ if (sound === 'triple') {
+ this.audio.tripleBeep();
+ } else {
+ this.audio.chime();
+ }
+ });
- this.$nextTick(boot);
+ this.$wire.on('playPauseBeep', () => {
+ this.audio.pauseBeep();
+ });
},
voiceText(reason) {
const map = {
diff --git a/resources/views/livewire/program-editor.blade.php b/resources/views/livewire/program-editor.blade.php
index 4897cad..48f5161 100644
--- a/resources/views/livewire/program-editor.blade.php
+++ b/resources/views/livewire/program-editor.blade.php
@@ -120,7 +120,7 @@ class="w-3 h-10 rounded-full shrink-0"
@if($phase['pause'] > 0)
· {{ $phase['pause'] }}s pause
@endif
- @if($phase['cooldown'] > 0)
+ @if($phase['cooldown'] > 0 && !$loop->last)
· {{ $phase['cooldown'] }}s cooldown
@endif
diff --git a/resources/views/livewire/timer-screen.blade.php b/resources/views/livewire/timer-screen.blade.php
index a63727f..b5f4254 100644
--- a/resources/views/livewire/timer-screen.blade.php
+++ b/resources/views/livewire/timer-screen.blade.php
@@ -9,8 +9,7 @@
--}}
{{-- ── No program loaded ───────────────────────────────────────────── --}}
@if(! $programId)
@@ -51,7 +50,7 @@ class="flex items-center justify-center px-4 pt-4 pb-2"
class="w-3 h-3 rounded-full shrink-0"
style="background: {{ $state->value === 'PAUSE' ? '#6b7280' : ($state->value === 'COOLDOWN' ? '#f97316' : $phaseColor) }}"
>
-
+
{{ $this->segmentLabel() }}
@if($this->repLabel())
From 699898f24495f2a5f49a7819794387d1f8500030 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Nikola=20Buci=C4=87?=
Date: Wed, 8 Apr 2026 15:51:57 +0200
Subject: [PATCH 08/10] feat: history log, ring UI, PREPARE state, and
long-press pause
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Run history:
- HistoryEntry readonly value object + HistoryLog JSON persistence (capped at 20)
- WriteHistoryEntry queued job dispatched on program completion
- config/queue.php with background driver (sync in tests via phpunit.xml)
- TimerScreen shows last 20 runs on idle tab; greyed out for deleted programs
Timer screen restyle:
- SVG circular ring (5 px stroke, ~92 vw) as total-time visual countdown
- Ring color follows phase/state (phase color → gray pause → orange cooldown → green complete)
- Ring stays full and static during IDLE and PREPARE
- Program name pushed to the top bar on program load (not only on Start)
- Phase name, countdown digit, and rep counter stacked inside the ring
- Phase strip kept at top for structural context
PREPARE state (5-second "Get Ready" before first rep):
- New StateMachine::prepare case; added to TimerCursor::isActive()
- TimerRunner::start() enters prepare; tick() transitions to running after 5 s
- Pause blocked during prepare (discard only)
- Custom triple beep every second of the countdown; JS ticker extended to PREPARE
Long-press pause gesture:
- Hold anywhere in the ring area for 1.5 s to pause (RUNNING/PAUSE/COOLDOWN only)
- LONG_PRESS_PAUSE=all|android env flag controls platform scope
- Subtle ring dim on hold; ambient "Hold to pause" hint text
Other:
- Program editor last-phase cooldown field greyed with tooltip
- TimerProgram::all() sorts by last_used_at (was created_at)
- PHP 8.5 pipe operator in TimerProgram::load() and TimerScreen::syncCursor()
- array_first() used in TimerRunner::currentPhase()
- 81 tests, all passing
Co-Authored-By: Claude Sonnet 4.6
---
app/Jobs/WriteHistoryEntry.php | 31 +-
app/Livewire/ProgramEditor.php | 17 ++
app/Livewire/TimerScreen.php | 143 +++++----
app/Timer/HistoryEntry.php | 41 +++
app/Timer/HistoryLog.php | 63 ++++
app/Timer/TimerCursor.php | 25 +-
app/Timer/TimerProgram.php | 22 +-
app/Timer/TimerRunner.php | 56 +++-
config/app.php | 13 +
config/queue.php | 53 ++++
resources/js/app.js | 5 +-
.../views/livewire/program-editor.blade.php | 19 +-
.../views/livewire/timer-screen.blade.php | 273 ++++++++++++++----
tests/Unit/Timer/BeepLogicTest.php | 89 +++++-
tests/Unit/Timer/TimerRunnerTest.php | 68 ++---
15 files changed, 698 insertions(+), 220 deletions(-)
create mode 100644 app/Timer/HistoryEntry.php
create mode 100644 app/Timer/HistoryLog.php
create mode 100644 config/queue.php
diff --git a/app/Jobs/WriteHistoryEntry.php b/app/Jobs/WriteHistoryEntry.php
index c6e7e10..6ba7e20 100644
--- a/app/Jobs/WriteHistoryEntry.php
+++ b/app/Jobs/WriteHistoryEntry.php
@@ -4,10 +4,21 @@
namespace App\Jobs;
-use App\Models\HistoryEntry;
+use App\Timer\HistoryEntry;
+use App\Timer\HistoryLog;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
+/**
+ * Writes a single history entry to storage/app/history.json.
+ *
+ * Dispatched from TimerRunner::complete() via the "background" queue driver
+ * (Laravel 13 process-based concurrency — no Redis, no SQLite required).
+ *
+ * The background driver serialises this job into a base64 env-var and spawns
+ * a dedicated `artisan invoke-serialized-closure` process, so the JSON write
+ * happens asynchronously without blocking the UI tick.
+ */
class WriteHistoryEntry implements ShouldQueue
{
use Queueable;
@@ -21,17 +32,11 @@ public function __construct(
public function handle(): void
{
- HistoryEntry::create([
- 'program_id' => $this->programId,
- 'program_name' => $this->programName,
- 'completed_at' => $this->completedAt,
- 'total_duration' => $this->totalDuration,
- ]);
-
- // Keep only the 20 most recent entries
- $toDelete = HistoryEntry::latest('completed_at')->limit(10)->skip(20)->pluck('id');
- if ($toDelete->isNotEmpty()) {
- HistoryEntry::whereIn('id', $toDelete)->delete();
- }
+ HistoryLog::append(new HistoryEntry(
+ programId: $this->programId,
+ programName: $this->programName,
+ completedAt: $this->completedAt,
+ totalDuration: $this->totalDuration,
+ ));
}
}
diff --git a/app/Livewire/ProgramEditor.php b/app/Livewire/ProgramEditor.php
index 9c2af1e..6fe9b41 100644
--- a/app/Livewire/ProgramEditor.php
+++ b/app/Livewire/ProgramEditor.php
@@ -147,6 +147,23 @@ public function openAddPhase(): void
// ── Computed ─────────────────────────────────────────────────────────
+ /**
+ * True when the phase form is open for the last phase in the list
+ * (or for a new phase that will become the last).
+ * Used in the view to grey out the cooldown field.
+ */
+ public function editingIsLastPhase(): bool
+ {
+ $count = count($this->phases);
+ if ($count === 0) {
+ return true; // first (and only) phase being added → will be last
+ }
+ if ($this->editingPhaseIndex === null) {
+ return true; // adding a new phase → will be appended as last
+ }
+ return $this->editingPhaseIndex === $count - 1;
+ }
+
private function resetPhaseForm(): void
{
$this->phaseLabel = '';
diff --git a/app/Livewire/TimerScreen.php b/app/Livewire/TimerScreen.php
index f7e1567..aa6012a 100644
--- a/app/Livewire/TimerScreen.php
+++ b/app/Livewire/TimerScreen.php
@@ -6,10 +6,14 @@
use App\Enum\StateMachine;
use App\Timer\AppSettings;
+use App\Timer\HistoryEntry;
+use App\Timer\HistoryLog;
+use App\Timer\Phase;
use App\Timer\TimerCursor;
use App\Timer\TimerProgram;
use App\Timer\TimerRunner;
use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\Storage;
use Illuminate\View\View;
use JsonException;
use Livewire\Attributes\Layout;
@@ -35,15 +39,24 @@ class TimerScreen extends Component
public string $phaseLabel = '';
public string $phaseColor = '#3b82f6';
public int $phaseReps = 1;
+ /** @var array[] Serialised Phase rows for the phase strip */
+ public array $phases = [];
// ── Settings (for the JS audio layer) ────────────────────────────────
public string $soundMode = 'beep';
public float $volume = 0.8;
public string $endSound = 'triple';
+ // ── Ring countdown ────────────────────────────────────────────────────
+ public int $programTotalDuration = 0;
+
// ── Beep lead-in (so JS can display a countdown label) ───────────────
public string $countdownLabel = '';
+ // ── History (shown on Timer tab when no program is loaded) ───────────
+ /** @var array[] serialised HistoryEntry rows + 'program_exists' bool */
+ public array $history = [];
+
public function discard(): void
{
app(TimerRunner::class)->discard();
@@ -67,9 +80,6 @@ public function formattedTotal(): string
return sprintf('%d:%02d', intdiv($s, 60), $s % 60);
}
- /**
- * @throws JsonException
- */
public function mount(?string $id = null): void
{
$settings = AppSettings::load();
@@ -78,6 +88,8 @@ public function mount(?string $id = null): void
if ($id) {
$this->loadProgram($id);
+ } else {
+ $this->loadHistory();
}
}
@@ -89,18 +101,54 @@ public function loadProgram(string $id): void
$this->programId = $id;
$this->programName = $program->name;
$this->endSound = $program->endSound;
+ $this->programTotalDuration = $program->totalDuration();
$this->rehydrateRunner($runner);
$this->syncCursor($runner->cursor(), $program);
+ // Show program name in the top bar as soon as program is loaded
+ $this->dispatch('topbar-title', title: $program->name);
+
// Push settings to JS audio layer
$this->dispatch('settingsLoaded', soundMode: $this->soundMode, volume: $this->volume, program: $program);
}
- public function requestSettings(): void
+ private function rehydrateRunner(TimerRunner $runner): void
{
- $program = $this->programId ? TimerProgram::load($this->programId) : null;
- $this->dispatch('settingsLoaded', soundMode: $this->soundMode, volume: $this->volume, program: $program);
+ if (!$this->programId) {
+ return;
+ }
+ $program = TimerProgram::load($this->programId);
+ $runner->load($program);
+
+ $cursor = new TimerCursor(
+ phaseIndex: $this->phaseIndex,
+ repIndex: $this->repIndex,
+ state: $this->state,
+ remaining: $this->remaining,
+ totalRemaining: $this->totalRemaining,
+ );
+
+ $runner->cursor = $cursor;
+ $runner->onBeep(function (string $reason): void {
+ $this->handleBeep($reason);
+ });
+ $runner->onPauseBeep(function (): void {
+ $this->dispatch('playPauseBeep');
+ });
+ }
+
+ private function handleBeep(string $reason): void
+ {
+ // Determine the countdown label for voice mode
+ $this->countdownLabel = match ($reason) {
+ 'prepare', 'countdown' => (string)$this->remaining,
+ 'rep_end' => 'Done',
+ 'pause_end' => 'Go',
+ 'cooldown_end' => 'Next',
+ default => '',
+ };
+ $this->dispatch('playBeep', reason: $reason);
}
private function syncCursor(TimerCursor $cursor, TimerProgram $program): void
@@ -111,6 +159,11 @@ private function syncCursor(TimerCursor $cursor, TimerProgram $program): void
$this->phaseIndex = $cursor->phaseIndex;
$this->repIndex = $cursor->repIndex;
+ $this->phases = $program->phases |> (fn($phase) => array_map(
+ static fn(Phase $p) => $p->toArray(),
+ $phase,
+ ));
+
if (isset($program->phases[$cursor->phaseIndex])) {
$phase = $program->phases[$cursor->phaseIndex];
$this->phaseLabel = $phase->label;
@@ -119,6 +172,20 @@ private function syncCursor(TimerCursor $cursor, TimerProgram $program): void
}
}
+ private function loadHistory(): void
+ {
+ $this->history = array_map(
+ static function (array $entry): array {
+ $entry['program_exists'] = Storage::exists("programs/{$entry['program_id']}.json");
+ return $entry;
+ },
+ array_map(
+ static fn(HistoryEntry $e) => $e->toArray(),
+ HistoryLog::all(),
+ ),
+ );
+ }
+
/**
* @throws JsonException
*/
@@ -135,23 +202,25 @@ public function render(): View
return view('livewire.timer-screen');
}
- // ── Computed display helpers ──────────────────────────────────────────
-
public function repLabel(): string
{
- if (in_array($this->state, [StateMachine::pause, StateMachine::cooldown, StateMachine::paused, StateMachine::completed, StateMachine::idle], true)) {
+ if (in_array($this->state, [StateMachine::prepare, StateMachine::cooldown, StateMachine::completed], true)) {
return '';
}
return sprintf('%d / %d', $this->repIndex + 1, $this->phaseReps);
}
- /**
- * @throws JsonException
- */
+ public function requestSettings(): void
+ {
+ $program = $this->programId ? TimerProgram::load($this->programId) : null;
+ $this->dispatch('settingsLoaded', soundMode: $this->soundMode, volume: $this->volume, program: $program);
+ }
+
public function restart(): void
{
$runner = app(TimerRunner::class);
$program = TimerProgram::load($this->programId);
+ $this->programTotalDuration = $program->totalDuration();
$runner->load($program);
$this->syncCursor($runner->cursor(), $program);
$this->dispatch('topbar-title', title: config('app.name'));
@@ -171,23 +240,21 @@ public function resume(): void
public function segmentLabel(): string
{
return match ($this->state) {
- StateMachine::pause => 'Pause',
+ StateMachine::prepare => 'Get Ready',
+ StateMachine::pause => 'Pause',
StateMachine::cooldown => 'Cooldown',
- StateMachine::paused => 'Paused',
+ StateMachine::paused => 'Paused',
StateMachine::completed => 'Complete!',
default => $this->phaseLabel,
};
}
- /**
- * @throws JsonException
- */
public function start(): void
{
$runner = app(TimerRunner::class);
$program = TimerProgram::load($this->programId);
-
+ $this->programTotalDuration = $program->totalDuration();
$runner->load($program);
$runner->start();
@@ -197,21 +264,6 @@ public function start(): void
$this->dispatch('topbar-title', title: $this->programName);
}
- // ── Internals ─────────────────────────────────────────────────────────
-
- private function handleBeep(string $reason, TimerProgram $program): void
- {
- // Determine the countdown label for voice mode
- $this->countdownLabel = match ($reason) {
- 'countdown' => (string)$this->remaining,
- 'rep_end' => 'Done',
- 'pause_end' => 'Go',
- 'cooldown_end' => 'Next',
- default => '',
- };
- $this->dispatch('playBeep', reason: $reason);
- }
-
/** Called every second from JS setInterval via wire:poll equivalent.
* @throws JsonException
*/
@@ -236,29 +288,4 @@ public function tick(): void
$this->dispatch('topbar-title', title: config('app.name'));
}
}
-
- private function rehydrateRunner(TimerRunner $runner): void
- {
- if (!$this->programId) {
- return;
- }
- $program = TimerProgram::load($this->programId);
- $runner->load($program);
-
- $cursor = new TimerCursor(
- phaseIndex: $this->phaseIndex,
- repIndex: $this->repIndex,
- state: $this->state,
- remaining: $this->remaining,
- totalRemaining: $this->totalRemaining
- );
-
- $runner->cursor = $cursor;
- $runner->onBeep(function (string $reason) use ($program): void {
- $this->handleBeep($reason, $program);
- });
- $runner->onPauseBeep(function (): void {
- $this->dispatch('playPauseBeep');
- });
- }
}
diff --git a/app/Timer/HistoryEntry.php b/app/Timer/HistoryEntry.php
new file mode 100644
index 0000000..4bd7110
--- /dev/null
+++ b/app/Timer/HistoryEntry.php
@@ -0,0 +1,41 @@
+ $this->programId,
+ 'program_name' => $this->programName,
+ 'completed_at' => $this->completedAt,
+ 'total_duration' => $this->totalDuration,
+ ];
+ }
+}
diff --git a/app/Timer/HistoryLog.php b/app/Timer/HistoryLog.php
new file mode 100644
index 0000000..9725d48
--- /dev/null
+++ b/app/Timer/HistoryLog.php
@@ -0,0 +1,63 @@
+ (fn($arr) => [$entry, ...$arr])
+ |> (fn($arr) => array_slice($arr, 0, self::MAX_ENTRIES))
+ |> (fn($arr) => array_map(static fn(HistoryEntry $e) => $e->toArray(), $arr))
+ |> (fn($x) => json_encode($x, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR))
+ |> (fn($x) => Storage::put(self::PATH, $x));
+
+ }
+
+ /**
+ * Return all history entries, newest first.
+ *
+ * @return HistoryEntry[]
+ */
+ public static function all(): array
+ {
+ if (!Storage::exists(self::PATH)) {
+ return [];
+ }
+
+ try {
+ return Storage::get(self::PATH)
+ |> (fn($x) => json_decode($x, true, 512, JSON_THROW_ON_ERROR))
+ |> (fn($x) => array_map(static fn(array $row) => HistoryEntry::fromArray($row),
+ $x));
+ } catch (JsonException) {
+ return [];
+ }
+
+
+ }
+
+ public static function clear(): void
+ {
+ Storage::delete(self::PATH);
+ }
+}
diff --git a/app/Timer/TimerCursor.php b/app/Timer/TimerCursor.php
index 20f61a3..0a08451 100644
--- a/app/Timer/TimerCursor.php
+++ b/app/Timer/TimerCursor.php
@@ -78,10 +78,28 @@ public function enterPause(int $pauseDuration, int $totalRemaining): self
);
}
+ /** Enter the pre-start countdown before the first rep. */
+ public function enterPrepare(int $seconds): self
+ {
+ return clone($this, [
+ 'phaseIndex' => 0,
+ 'repIndex' => 0,
+ 'state' => StateMachine::prepare,
+ 'remaining' => $seconds,
+ 'totalRemaining' => $this->totalRemaining,
+ ]
+ );
+ }
+
/** True whenever the timer is actively counting down (not user-paused, not idle). */
public function isActive(): bool
{
- return in_array($this->state, [StateMachine::running, StateMachine::pause, StateMachine::cooldown], true);
+ return in_array($this->state, [
+ StateMachine::prepare,
+ StateMachine::running,
+ StateMachine::pause,
+ StateMachine::cooldown,
+ ], true);
}
public function isCompleted(): bool
@@ -111,11 +129,6 @@ public function isPaused(): bool
return $this->state === StateMachine::paused;
}
- public function isRunning(): bool
- {
- return $this->state === StateMachine::running;
- }
-
/** Advance to the first rep of the next phase. */
public function nextPhase(int $phaseIndex, int $repDuration, int $totalRemaining): self
{
diff --git a/app/Timer/TimerProgram.php b/app/Timer/TimerProgram.php
index 1f23eba..e274cd1 100644
--- a/app/Timer/TimerProgram.php
+++ b/app/Timer/TimerProgram.php
@@ -54,13 +54,13 @@ private function __construct(
$this->phases = $phases;
}
- /** Return all saved programs, newest first. */
+ /** Return all saved programs, sorted by last_used_at desc (falling back to created_at). */
public static function all(): array
{
return collect(Storage::files('programs'))
->filter(fn(string $p) => str_ends_with($p, '.json'))
->map(fn(string $p) => self::load(basename($p, '.json')))
- ->sortByDesc(fn(self $prog) => $prog->createdAt)
+ ->sortByDesc(fn(self $prog) => $prog->lastUsedAt ?? $prog->createdAt)
->values()
->all();
}
@@ -84,14 +84,8 @@ public static function create(string $name): self
/**
* Load from a JSON file.
*
- * PHP 8.5 pipe-operator version (requires NativePHP's PHP 8.5 runtime):
+ * PHP 8.5 pipe operator chains Storage::get → json_decode → hydrate.
*
- * return Storage::get("programs/{$id}.json")
- * |> json_decode($$, true, 512, JSON_THROW_ON_ERROR)
- * |> self::hydrate($$);
- *
- * Equivalent without the pipe operator (PHP 8.4-safe):
- * @throws JsonException
*/
public static function load(string $id): self
{
@@ -101,9 +95,9 @@ public static function load(string $id): self
throw new RuntimeException("Program not found: $id");
}
- $json = Storage::get($path);
- $data = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
- return self::hydrate($data);
+ return Storage::get($path)
+ |> (fn($j) => json_decode($j, true, 512, JSON_THROW_ON_ERROR))
+ |> self::hydrate(...);
}
private static function hydrate(array $data): self
@@ -160,7 +154,7 @@ public function save(): void
"programs/$this->id.json",
json_encode($this->toArray(), JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR),
);
- } catch (JsonException $e) {
+ } catch (JsonException) {
Storage::put(
"programs/$this->id.json",
json_encode([], JSON_PRETTY_PRINT),
@@ -205,6 +199,6 @@ static function (int $carry, Phase $phase): int {
0,
);
- return $totalDuration - array_last($this->phases)->cooldown;
+ return $totalDuration - array_last($this->phases)?->cooldown;
}
}
diff --git a/app/Timer/TimerRunner.php b/app/Timer/TimerRunner.php
index be756c7..f83df59 100644
--- a/app/Timer/TimerRunner.php
+++ b/app/Timer/TimerRunner.php
@@ -7,6 +7,7 @@
use App\Enum\StateMachine;
use App\Events\PhaseChanged;
use App\Events\ProgramCompleted;
+use App\Jobs\WriteHistoryEntry;
use Closure;
use RuntimeException;
@@ -36,6 +37,8 @@
*/
class TimerRunner
{
+ private const PREPARE_SECONDS = 5;
+
private ?TimerProgram $program = null;
public TimerCursor $cursor {
set {
@@ -72,11 +75,6 @@ public function discard(): void
$this->cursor = TimerCursor::idle();
}
- public function isRunning(): bool
- {
- return $this->cursor->isRunning();
- }
-
/** Load a program and reset the cursor to idle. */
public function load(TimerProgram $program): void
{
@@ -102,13 +100,17 @@ public function onTick(Closure $fn): void
$this->onTick = $fn;
}
- /** User-initiated pause (or PhoneStateListener). */
+ /** User-initiated pause (or PhoneStateListener). Cannot pause during prepare. */
public function pause(): void
{
if (!$this->cursor->isActive()) {
return;
}
+ if ($this->cursor->state === StateMachine::prepare) {
+ return;
+ }
+
$this->stateBeforePause = $this->cursor->state;
$this->cursor = $this->cursor->pause();
$this->fireOnPauseBeep();
@@ -140,7 +142,7 @@ public function resume(): void
$this->notifyTick();
}
- /** Start the timer from idle. */
+ /** Start the timer from idle — enters a 5-second PREPARE countdown first. */
public function start(): void
{
$this->assertProgramLoaded();
@@ -151,6 +153,12 @@ public function start(): void
);
}
+ $this->cursor = $this->cursor->enterPrepare(self::PREPARE_SECONDS);
+ }
+
+ /** Transition from prepare → running, initialising the first rep. */
+ private function beginFirstRep(): void
+ {
$phase = $this->currentPhase();
$totalRemaining = $this->program->totalDuration();
@@ -163,6 +171,7 @@ public function start(): void
);
PhaseChanged::dispatch($this->program->id, 0, $phase, 0);
+ $this->notifyTick();
}
private function assertProgramLoaded(): void
@@ -184,8 +193,7 @@ public function isIdle(): bool
private function currentPhase(): Phase
{
- // PHP 8.5: array_first_value($this->phases())
- return ($this->phases()[0] ?? null)
+ return array_first($this->phases())
?? throw new RuntimeException('Program has no phases.');
}
@@ -207,6 +215,18 @@ public function tick(): void
$cursor = $this->cursor->tick();
+ // Prepare: beep every second (bypass lead-in logic), transition when done
+ if ($cursor->state === StateMachine::prepare) {
+ if ($cursor->remaining > 0) {
+ $this->fireBeep('prepare');
+ $this->cursor = $cursor;
+ $this->notifyTick();
+ } else {
+ $this->beginFirstRep();
+ }
+ return;
+ }
+
if ($this->shouldBeep($cursor)) {
$this->fireBeep('countdown');
}
@@ -244,10 +264,11 @@ private function segmentDurationForCursor(TimerCursor $cursor): int
$phase = $this->phases()[$cursor->phaseIndex];
return match ($cursor->state) {
- StateMachine::running => $phase->duration,
- StateMachine::pause => $phase->pause,
+ StateMachine::prepare => self::PREPARE_SECONDS,
+ StateMachine::running => $phase->duration,
+ StateMachine::pause => $phase->pause,
StateMachine::cooldown => $phase->cooldown,
- default => 0,
+ default => 0,
};
}
@@ -355,10 +376,19 @@ private function complete(): void
{
$this->cursor = $this->cursor->complete();
+ $totalDuration = $this->program->totalDuration();
+
ProgramCompleted::dispatch(
$this->program->id,
$this->program->endSound,
- $this->program->totalDuration(),
+ $totalDuration,
+ );
+
+ WriteHistoryEntry::dispatch(
+ $this->program->id,
+ $this->program->name,
+ now()->toISOString(),
+ $totalDuration,
);
$this->program->touch();
diff --git a/config/app.php b/config/app.php
index 85e71fd..135d3f3 100644
--- a/config/app.php
+++ b/config/app.php
@@ -15,6 +15,19 @@
'name' => env('APP_NAME', 'Interval Timer'),
+ /*
+ |--------------------------------------------------------------------------
+ | Long-press Pause Platforms
+ |--------------------------------------------------------------------------
+ |
+ | Controls which platforms get the hold-to-pause gesture on the timer screen.
+ | 'all' — enabled in the browser web demo and on Android.
+ | 'android' — enabled only when running inside the NativePHP Android WebView
+ | (detected at runtime via the Android user-agent string).
+ |
+ */
+ 'long_press_pause' => env('LONG_PRESS_PAUSE', 'all'),
+
/*
|--------------------------------------------------------------------------
| Application Environment
diff --git a/config/queue.php b/config/queue.php
new file mode 100644
index 0000000..65123df
--- /dev/null
+++ b/config/queue.php
@@ -0,0 +1,53 @@
+ env('QUEUE_CONNECTION', 'background'),
+
+ 'connections' => [
+
+ 'sync' => [
+ 'driver' => 'sync',
+ ],
+
+ 'background' => [
+ 'driver' => 'background',
+ 'after_commit' => false,
+ ],
+
+ 'null' => [
+ 'driver' => 'null',
+ ],
+
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Failed Queue Jobs
+ |--------------------------------------------------------------------------
+ |
+ | The background driver does not persist failed jobs. Set to null so
+ | Laravel does not try to write to a missing `failed_jobs` table.
+ |
+ */
+
+ 'failed' => [
+ 'driver' => env('QUEUE_FAILED_DRIVER', 'null'),
+ 'database' => null,
+ 'table' => null,
+ ],
+
+];
diff --git a/resources/js/app.js b/resources/js/app.js
index 5bb3aca..8724cee 100644
--- a/resources/js/app.js
+++ b/resources/js/app.js
@@ -22,7 +22,7 @@ document.addEventListener('alpine:init', () => {
console.log('State changed:', stateName);
clearInterval(this.interval);
- if (['RUNNING', 'PAUSE', 'COOLDOWN'].includes(stateName)) {
+ if (['PREPARE', 'RUNNING', 'PAUSE', 'COOLDOWN'].includes(stateName)) {
this.interval = setInterval(() => this.$wire.tick(), 1000);
}
});
@@ -31,6 +31,8 @@ document.addEventListener('alpine:init', () => {
console.log('playBeep', reason);
if (this.soundMode === 'voice') {
this.audio.speak(this.voiceText(reason));
+ } else if (reason === 'prepare') {
+ this.audio.prepareBeep();
} else {
this.audio.beep();
}
@@ -50,6 +52,7 @@ document.addEventListener('alpine:init', () => {
},
voiceText(reason) {
const map = {
+ prepare: this.$wire?.countdownLabel ?? '',
countdown: this.$wire?.countdownLabel ?? 'Get ready',
rep_end: 'Done',
pause_end: 'Go',
diff --git a/resources/views/livewire/program-editor.blade.php b/resources/views/livewire/program-editor.blade.php
index 48f5161..9b1cd4f 100644
--- a/resources/views/livewire/program-editor.blade.php
+++ b/resources/views/livewire/program-editor.blade.php
@@ -247,13 +247,24 @@ class="w-full bg-gray-800 text-white rounded-xl px-4 py-3
@endif
-
+
Cooldown (sec)
- After final rep
+
+ @if($this->editingIsLastPhase())
+ not counted — add another phase
+ @else
+ After final rep
+ @endif
+
+ @if($this->editingIsLastPhase()) disabled @endif
+ class="w-full rounded-xl px-4 py-3 border text-center text-lg font-bold
+ focus:outline-none transition-colors
+ {{ $this->editingIsLastPhase()
+ ? 'bg-gray-800/40 text-gray-600 border-white/5 cursor-not-allowed'
+ : 'bg-gray-800 text-white border-white/10 focus:border-blue-500' }}">
diff --git a/resources/views/livewire/timer-screen.blade.php b/resources/views/livewire/timer-screen.blade.php
index b5f4254..b405b1f 100644
--- a/resources/views/livewire/timer-screen.blade.php
+++ b/resources/views/livewire/timer-screen.blade.php
@@ -11,95 +11,247 @@
class="flex flex-col h-full"
x-data="timerAudio"
>
- {{-- ── No program loaded ───────────────────────────────────────────── --}}
+ {{-- ── No program loaded: history list ───────────────────────────── --}}
@if(! $programId)
-
-
-
-
-
+
+
+ {{-- Header --}}
+
-
No program selected
-
Go to Library and tap a program to start
-
- Open Library
-
+
+ {{-- History list --}}
+ @if(count($history) === 0)
+
+
+
No runs yet — complete a program to see your history here.
+
+ @else
+
+ @foreach($history as $entry)
+ @php
+ $mins = intdiv($entry['total_duration'], 60);
+ $secs = $entry['total_duration'] % 60;
+ $formattedDuration = sprintf('%d:%02d', $mins, $secs);
+ $formattedDate = \Carbon\Carbon::parse($entry['completed_at'])->diffForHumans();
+ @endphp
+
+ @if($entry['program_exists'])
+
+
+
+
{{ $entry['program_name'] }}
+
{{ $formattedDate }}
+
+ {{ $formattedDuration }}
+
+ @else
+
+
+
+
{{ $entry['program_name'] }}
+
{{ $formattedDate }} · deleted
+
+
{{ $formattedDuration }}
+
+ @endif
+ @endforeach
+
+ @endif
+
@else
{{-- ── Active timer ────────────────────────────────────────────────── --}}
- {{-- Phase progress bar --}}
-
- @if($phaseIndex < collect($phases ?? [])->count() || true)
+ {{-- Phase strip — one proportional segment per phase --}}
+ @php
+ $totalDuration = collect($phases)->sum(function (array $p) {
+ return ($p['duration'] * $p['repetitions'])
+ + ($p['pause'] * max(0, $p['repetitions'] - 1));
+ });
+ // Last phase has no cooldown counted; others include it
+ foreach ($phases as $i => $p) {
+ if ($i < count($phases) - 1) {
+ $totalDuration += $p['cooldown'];
+ }
+ }
+ @endphp
+
+ @foreach($phases as $i => $phase)
+ @php
+ $phaseDuration = ($phase['duration'] * $phase['repetitions'])
+ + ($phase['pause'] * max(0, $phase['repetitions'] - 1))
+ + ($i < count($phases) - 1 ? $phase['cooldown'] : 0);
+ $pct = $totalDuration > 0 ? round($phaseDuration / $totalDuration * 100, 2) : 0;
+ $isActive = $i === $phaseIndex && !in_array($state->value, ['IDLE', 'COMPLETED']);
+ $isDone = $i < $phaseIndex || $state->value === 'COMPLETED';
+ @endphp
- @endif
+ @endforeach
- {{-- Phase label strip --}}
+ @php
+ $ringColor = match($state->value) {
+ 'PAUSE', 'PAUSED' => '#6b7280',
+ 'COOLDOWN' => '#f97316',
+ 'COMPLETED' => '#22c55e',
+ default => $phaseColor,
+ };
+ $ringOpacity = $state->value === 'PAUSED' ? '0.4' : '1';
+ $circumference = 2 * M_PI * 120;
+ $progress = ($programTotalDuration > 0 && !in_array($state->value, ['IDLE', 'PREPARE']))
+ ? $totalRemaining / $programTotalDuration
+ : 1.0;
+ $dashOffset = $circumference * (1 - $progress);
+ @endphp
+
+ {{-- ── Ring + inner content ────────────────────────────────────────── --}}
-
-
-
- {{ $this->segmentLabel() }}
-
- @if($this->repLabel())
- — Rep {{ $this->repLabel() }}
- @endif
-
-
- {{-- ── BIG countdown ───────────────────────────────────────────────── --}}
-
-
- {{-- Main digit --}}
- {{ $state->value === 'COMPLETED' ? '✓' : $this->formattedRemaining() }}
+ {{-- SVG ring --}}
+
+ {{-- Track --}}
+
+ {{-- Progress --}}
+
+
+
+ {{-- Inner content --}}
+
+
+ {{-- Phase / state label --}}
+
+ {{ $this->segmentLabel() }}
+
+
+ {{-- Main countdown --}}
+
+ {{ $state->value === 'COMPLETED' ? '✓' : $this->formattedRemaining() }}
+
+
+ {{-- Rep counter --}}
+ @if($this->repLabel())
+
+ Rep {{ $this->repLabel() }}
+
+ @endif
+
+
- {{-- Completed message --}}
+ {{-- State messages below ring --}}
@if($state->value === 'COMPLETED')
-
+
Program Complete!
- @endif
-
- {{-- Cooldown breathing prompt --}}
- @if($state->value === 'COOLDOWN')
-
+ @elseif($state->value === 'COOLDOWN')
+
Take deep breaths
@endif
- {{-- Paused label --}}
- @if($state->value === 'PAUSED')
-
Paused
- @endif
-
{{-- Total remaining --}}
@if(in_array($state->value, ['RUNNING', 'PAUSE', 'COOLDOWN', 'PAUSED']) && $totalRemaining > 0)
-
+
{{ $this->formattedTotal() }} total remaining
@endif
+ {{-- Hold-to-pause hint (ambient, non-intrusive) --}}
+ @if(in_array($state->value, ['RUNNING', 'PAUSE', 'COOLDOWN']))
+
+ Hold to pause
+
+ @endif
+
{{-- ── Controls ───────────────────────────────────────────────────── --}}
@@ -115,6 +267,9 @@ class="w-full bg-blue-600 hover:bg-blue-500 active:bg-blue-700 text-white font-b
Start
+ @elseif($state === StateMachine::prepare)
+ {{-- No primary action during prepare — user just waits --}}
+
@elseif($state === StateMachine::paused)
beepLeadIn = BeepLeadIn::from($leadIn);
- $prog->addPhase(new Phase('Work', $duration, $reps, $pause, $cooldown, '#3b82f6'));
+ foreach ($phases as $phase) {
+ $prog->addPhase($phase);
+ }
$prog->save();
$ctx = new stdClass();
@@ -35,9 +37,44 @@ function beepRunner(int $duration, int $reps = 1, int $pause = 0, int $cooldown
$ctx->runner->start();
+ // Advance through the 5-second PREPARE countdown before each test begins.
+ for ($i = 0; $i < 5; $i++) $ctx->runner->tick();
+ $ctx->beeps = []; // discard prepare countdown beeps
+
return $ctx;
}
+function createPhase(string $name, int $duration, int $reps = 1, int $pause = 0, int $cooldown = 0, string $color = "#3b82f6"): Phase
+{
+ return new Phase($name, $duration, $reps, $pause, $cooldown, $color);
+}
+
+// ── Prepare beep ──────────────────────────────────────────────────────────────
+
+test('prepare beep fires once per tick during the 5s prepare countdown', function (): void {
+ Storage::fake();
+
+ $prog = TimerProgram::create('Prepare beep test');
+ $prog->addPhase(new Phase('Work', 10, 1, 0, 0, '#3b82f6'));
+ $prog->save();
+
+ $beeps = [];
+ $runner = new TimerRunner();
+ $runner->load(TimerProgram::load($prog->id));
+ $runner->onBeep(function (string $reason) use (&$beeps): void {
+ $beeps[] = $reason;
+ });
+ $runner->start(); // enters PREPARE (remaining = 5)
+
+ for ($i = 0; $i < 5; $i++) $runner->tick();
+
+ $prepareCount = count(array_filter($beeps, fn ($r) => $r === 'prepare'));
+ // Ticks bring remaining 5→4→3→2→1→0 (beginFirstRep); beep fires while remaining > 0
+ expect($prepareCount)->toBe(4);
+ // No 'countdown' beeps should fire during prepare
+ expect($beeps)->not->toContain('countdown');
+});
+
// ── Lead-in 3s ────────────────────────────────────────────────────────────────
test(/**
@@ -47,7 +84,10 @@ function beepRunner(int $duration, int $reps = 1, int $pause = 0, int $cooldown
* @throws JsonException
*/
function (): void {
- $ctx = beepRunner(duration: 10);
+ $ctx = createBeepRunner([
+ createPhase(name: 'Work', duration: 10),
+ ],
+ );
// 7 ticks bring remaining from 10 → 3 (the lead-in window)
for ($i = 0; $i < 7; $i++) $ctx->runner->tick();
@@ -60,7 +100,10 @@ function (): void {
* @throws JsonException
*/
function (): void {
- $ctx = beepRunner(duration: 10);
+ $ctx = createBeepRunner([
+ createPhase(name: 'Work', duration: 10),
+ ],
+ );
for ($i = 0; $i < 10; $i++) $ctx->runner->tick();
@@ -74,7 +117,10 @@ function (): void {
* @throws JsonException
*/
function (): void {
- $ctx = beepRunner(duration: 15, leadIn: 5);
+ $ctx = createBeepRunner([
+ createPhase(name: 'Work', duration: 15),
+ ], leadIn: 5,
+ );
for ($i = 0; $i < 15; $i++) $ctx->runner->tick();
@@ -89,7 +135,11 @@ function (): void {
* @throws JsonException
*/
function (): void {
- $ctx = beepRunner(duration: 2);
+ $ctx = createBeepRunner(
+ [
+ createPhase(name: 'Work', duration: 2),
+ ],
+ );
for ($i = 0; $i < 2; $i++) $ctx->runner->tick();
@@ -104,7 +154,11 @@ function (): void {
* @throws JsonException
*/
function (): void {
- $ctx = beepRunner(duration: 5);
+ $ctx = createBeepRunner(
+ [
+ createPhase(name: 'Work', duration: 5),
+ ],
+ );
for ($i = 0; $i < 5; $i++) $ctx->runner->tick();
@@ -118,7 +172,11 @@ function (): void {
* @throws JsonException
*/
function (): void {
- $ctx = beepRunner(duration: 5, reps: 2, pause: 3);
+ $ctx = createBeepRunner(
+ [
+ createPhase(name: 'Work', duration: 5, reps: 2, pause: 3),
+ ],
+ );
// Rep 1 (5 ticks) then pause (3 ticks)
for ($i = 0; $i < 8; $i++) $ctx->runner->tick();
@@ -133,7 +191,12 @@ function (): void {
* @throws JsonException
*/
function (): void {
- $ctx = beepRunner(duration: 5, cooldown: 3);
+ $ctx = createBeepRunner(
+ [
+ createPhase(name: 'Work', duration: 5, cooldown: 3),
+ createPhase(name: 'Rest', duration: 5, cooldown: 3),
+ ],
+ );
// Rep (5 ticks) + cooldown (3 ticks)
for ($i = 0; $i < 8; $i++) $ctx->runner->tick();
@@ -148,7 +211,11 @@ function (): void {
* @throws JsonException
*/
function (): void {
- $ctx = beepRunner(duration: 5, reps: 2, pause: 3);
+ $ctx = createBeepRunner(
+ [
+ createPhase(name: 'Work', duration: 5, reps: 2, pause: 3),
+ ],
+ );
// Only tick through the pause (ticks 6–8)
for ($i = 0; $i < 8; $i++) $ctx->runner->tick();
@@ -183,6 +250,7 @@ function (): void {
});
$runner->start();
+ for ($i = 0; $i < 5; $i++) $runner->tick(); // skip prepare
$runner->tick();
$runner->pause();
@@ -208,6 +276,7 @@ function (): void {
});
$runner->start();
+ for ($i = 0; $i < 5; $i++) $runner->tick(); // skip prepare
$runner->tick();
$runner->pause();
$runner->resume(); // must not fire another pause beep
diff --git a/tests/Unit/Timer/TimerRunnerTest.php b/tests/Unit/Timer/TimerRunnerTest.php
index c416b89..5fc0b2b 100644
--- a/tests/Unit/Timer/TimerRunnerTest.php
+++ b/tests/Unit/Timer/TimerRunnerTest.php
@@ -3,9 +3,11 @@
declare(strict_types=1);
use App\Enum\StateMachine;
-use App\Models\Program;
+use App\Timer\Phase;
use App\Timer\TimerCursor;
+use App\Timer\TimerProgram;
use App\Timer\TimerRunner;
+use Illuminate\Support\Facades\Storage;
// ── Helpers ───────────────────────────────────────────────────────────────────
@@ -20,43 +22,23 @@ function skipPrepare(TimerRunner $runner): void
for ($i = 0; $i < 5; $i++) $runner->tick();
}
-function onePhaseProgram(int $duration = 10, int $reps = 1, int $pause = 0, int $cooldown = 0): Program
+function onePhaseProgram(int $duration = 10, int $reps = 1, int $pause = 0, int $cooldown = 0): TimerProgram
{
- $prog = Program::create(['name' => 'Single Phase']);
- $prog->phases()->create([
- 'label' => 'Work',
- 'duration' => $duration,
- 'repetitions' => $reps,
- 'pause' => $pause,
- 'cooldown' => $cooldown,
- 'color' => '#3b82f6',
- 'sort_order' => 0,
- ]);
- return $prog->load('phases');
+ Storage::fake();
+ $prog = TimerProgram::create('Single Phase');
+ $prog->addPhase(new Phase('Work', $duration, $reps, $pause, $cooldown, '#3b82f6'));
+ $prog->save();
+ return TimerProgram::load($prog->id);
}
-function twoPhaseProgram(): Program
+function twoPhaseProgram(): TimerProgram
{
- $prog = Program::create(['name' => 'Two Phases']);
- $prog->phases()->create([
- 'label' => 'Work',
- 'duration' => 5,
- 'repetitions' => 2,
- 'pause' => 2,
- 'cooldown' => 3,
- 'color' => '#3b82f6',
- 'sort_order' => 0,
- ]);
- $prog->phases()->create([
- 'label' => 'Rest',
- 'duration' => 8,
- 'repetitions' => 1,
- 'pause' => 0,
- 'cooldown' => 0,
- 'color' => '#22c55e',
- 'sort_order' => 1,
- ]);
- return $prog->load('phases');
+ Storage::fake();
+ $prog = TimerProgram::create('Two Phases');
+ $prog->addPhase(new Phase('Work', 5, 2, 2, 3, '#3b82f6'));
+ $prog->addPhase(new Phase('Rest', 8, 1, 0, 0, '#22c55e'));
+ $prog->save();
+ return TimerProgram::load($prog->id);
}
// ── idle state ────────────────────────────────────────────────────────────────
@@ -73,14 +55,15 @@ function twoPhaseProgram(): Program
});
test('start without load throws RuntimeException', function (): void {
- expect(fn() => freshRunner()->start())->toThrow(\RuntimeException::class);
+ expect(fn () => freshRunner()->start())->toThrow(\RuntimeException::class);
});
test('start with empty program throws RuntimeException', function (): void {
- $prog = Program::create(['name' => 'Empty']);
+ Storage::fake();
+ $prog = TimerProgram::create('Empty'); $prog->save();
$runner = freshRunner();
- $runner->load($prog);
- expect(fn() => $runner->start())->toThrow(\RuntimeException::class);
+ $runner->load(TimerProgram::load($prog->id));
+ expect(fn () => $runner->start())->toThrow(\RuntimeException::class);
});
// ── idle → prepare ────────────────────────────────────────────────────────────
@@ -162,7 +145,7 @@ function twoPhaseProgram(): Program
$completed = false;
$runner = freshRunner();
- $prog = onePhaseProgram(duration: 3, reps: 1, cooldown: 2);
+ $prog = onePhaseProgram(duration: 3, reps: 1, cooldown: 2);
$runner->load($prog);
$runner->start();
skipPrepare($runner);
@@ -237,11 +220,12 @@ function twoPhaseProgram(): Program
// ── 10-phase limit ────────────────────────────────────────────────────────────
test('program rejects 11th phase', function (): void {
- $prog = Program::create(['name' => 'Overflow']);
+ Storage::fake();
+ $prog = TimerProgram::create('Overflow');
for ($i = 0; $i < 10; $i++) {
- $prog->addPhase(['label' => "P{$i}", 'duration' => 5, 'repetitions' => 1, 'pause' => 0, 'cooldown' => 0, 'color' => '#3b82f6']);
+ $prog->addPhase(new Phase("P{$i}", 5, 1, 0, 0, '#3b82f6'));
}
- expect(fn() => $prog->addPhase(['label' => 'P11', 'duration' => 5, 'repetitions' => 1, 'pause' => 0, 'cooldown' => 0, 'color' => '#3b82f6']))
+ expect(fn () => $prog->addPhase(new Phase('P11', 5, 1, 0, 0, '#3b82f6')))
->toThrow(\OverflowException::class);
});
From aabb74198ac3157dad415c3e1a8e45fc30e3d28f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Nikola=20Buci=C4=87?=
Date: Wed, 8 Apr 2026 15:51:57 +0200
Subject: [PATCH 09/10] feat: history log, ring UI, PREPARE state, and
long-press pause
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Run history:
- HistoryEntry readonly value object + HistoryLog JSON persistence (capped at 20)
- WriteHistoryEntry queued job dispatched on program completion
- config/queue.php with background driver (sync in tests via phpunit.xml)
- TimerScreen shows last 20 runs on idle tab; greyed out for deleted programs
Timer screen restyle:
- SVG circular ring (5 px stroke, ~92 vw) as total-time visual countdown
- Ring color follows phase/state (phase color → gray pause → orange cooldown → green complete)
- Ring stays full and static during IDLE and PREPARE
- Program name pushed to the top bar on program load (not only on Start)
- Phase name, countdown digit, and rep counter stacked inside the ring
- Phase strip kept at top for structural context
PREPARE state (5-second "Get Ready" before first rep):
- New StateMachine::prepare case; added to TimerCursor::isActive()
- TimerRunner::start() enters prepare; tick() transitions to running after 5 s
- Pause blocked during prepare (discard only)
- Custom triple beep every second of the countdown; JS ticker extended to PREPARE
Long-press pause gesture:
- Hold anywhere in the ring area for 1.5 s to pause (RUNNING/PAUSE/COOLDOWN only)
- LONG_PRESS_PAUSE=all|android env flag controls platform scope
- Subtle ring dim on hold; ambient "Hold to pause" hint text
Other:
- Program editor last-phase cooldown field greyed with tooltip
- TimerProgram::all() sorts by last_used_at (was created_at)
- PHP 8.5 pipe operator in TimerProgram::load() and TimerScreen::syncCursor()
- array_first() used in TimerRunner::currentPhase()
- 81 tests, all passing
Co-Authored-By: Claude Sonnet 4.6
---
.github/workflows/bump-version.yml | 5 +-
app/Jobs/WriteHistoryEntry.php | 31 +-
app/Livewire/Library.php | 38 +-
app/Livewire/ProgramEditor.php | 234 +++++++------
app/Livewire/Settings.php | 38 +-
app/Livewire/TimerScreen.php | 327 +++++++++---------
app/Timer/AppSettings.php | 74 ----
app/Timer/HistoryEntry.php | 41 ---
app/Timer/HistoryLog.php | 63 ----
app/Timer/TimerProgram.php | 204 -----------
app/Timer/TimerRunner.php | 19 +-
composer.json | 7 +-
config/queue.php | 17 +-
...026_04_09_122314_create_sessions_table.php | 31 ++
tests/Unit/Timer/BeepLogicTest.php | 81 +++--
tests/Unit/Timer/TimerRunnerTest.php | 68 ++--
16 files changed, 466 insertions(+), 812 deletions(-)
delete mode 100644 app/Timer/AppSettings.php
delete mode 100644 app/Timer/HistoryEntry.php
delete mode 100644 app/Timer/HistoryLog.php
delete mode 100644 app/Timer/TimerProgram.php
create mode 100644 database/migrations/2026_04_09_122314_create_sessions_table.php
diff --git a/.github/workflows/bump-version.yml b/.github/workflows/bump-version.yml
index eb666a5..6ccda15 100644
--- a/.github/workflows/bump-version.yml
+++ b/.github/workflows/bump-version.yml
@@ -2,10 +2,7 @@ name: Bump Version
on:
push:
- branches:
- - master
- workflow_dispatch:
-
+ branches: [main]
jobs:
bump:
diff --git a/app/Jobs/WriteHistoryEntry.php b/app/Jobs/WriteHistoryEntry.php
index 6ba7e20..c6e7e10 100644
--- a/app/Jobs/WriteHistoryEntry.php
+++ b/app/Jobs/WriteHistoryEntry.php
@@ -4,21 +4,10 @@
namespace App\Jobs;
-use App\Timer\HistoryEntry;
-use App\Timer\HistoryLog;
+use App\Models\HistoryEntry;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
-/**
- * Writes a single history entry to storage/app/history.json.
- *
- * Dispatched from TimerRunner::complete() via the "background" queue driver
- * (Laravel 13 process-based concurrency — no Redis, no SQLite required).
- *
- * The background driver serialises this job into a base64 env-var and spawns
- * a dedicated `artisan invoke-serialized-closure` process, so the JSON write
- * happens asynchronously without blocking the UI tick.
- */
class WriteHistoryEntry implements ShouldQueue
{
use Queueable;
@@ -32,11 +21,17 @@ public function __construct(
public function handle(): void
{
- HistoryLog::append(new HistoryEntry(
- programId: $this->programId,
- programName: $this->programName,
- completedAt: $this->completedAt,
- totalDuration: $this->totalDuration,
- ));
+ HistoryEntry::create([
+ 'program_id' => $this->programId,
+ 'program_name' => $this->programName,
+ 'completed_at' => $this->completedAt,
+ 'total_duration' => $this->totalDuration,
+ ]);
+
+ // Keep only the 20 most recent entries
+ $toDelete = HistoryEntry::latest('completed_at')->limit(10)->skip(20)->pluck('id');
+ if ($toDelete->isNotEmpty()) {
+ HistoryEntry::whereIn('id', $toDelete)->delete();
+ }
}
}
diff --git a/app/Livewire/Library.php b/app/Livewire/Library.php
index c945711..620f5f2 100644
--- a/app/Livewire/Library.php
+++ b/app/Livewire/Library.php
@@ -4,15 +4,12 @@
namespace App\Livewire;
-use App\Timer\AppSettings;
-use App\Timer\TimerProgram;
-use Illuminate\Support\Facades\Log;
+use App\Models\Program;
+use App\Models\Setting;
use Illuminate\View\View;
-use JsonException;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
-use RuntimeException;
#[Layout('layouts.app')]
#[Title('Library — Interval Timer')]
@@ -21,7 +18,6 @@ class Library extends Component
public string $newName = '';
public bool $showCreate = false;
- /** @var TimerProgram[] */
public array $programs = [];
public function mount(): void
@@ -31,15 +27,15 @@ public function mount(): void
public function loadPrograms(): void
{
- $this->programs = array_map(
- static fn(TimerProgram $p) => $p->toArray(),
- TimerProgram::all()
- );
+ $this->programs = Program::with('phases')
+ ->orderByRaw('COALESCE(last_used_at, created_at) DESC')
+ ->get()
+ ->toArray();
}
public function openCreate(): void
{
- $this->newName = '';
+ $this->newName = '';
$this->showCreate = true;
}
@@ -53,13 +49,13 @@ public function createProgram(): void
{
$this->validate(['newName' => 'required|string|max:60']);
- $settings = AppSettings::load();
- $program = TimerProgram::create(trim($this->newName));
- $program->beepLeadIn = $settings->defaultBeepLeadIn;
- $program->endSound = $settings->defaultEndSound;
- $program->save();
+ $settings = Setting::current();
- Log::info('Message', ['data' => $this]);
+ $program = Program::create([
+ 'name' => trim($this->newName),
+ 'beep_lead_in' => $settings->default_beep_lead_in,
+ 'end_sound' => $settings->default_end_sound,
+ ]);
$this->showCreate = false;
$this->newName = '';
@@ -69,13 +65,7 @@ public function createProgram(): void
public function deleteProgram(string $id): void
{
- try {
- $program = TimerProgram::load($id);
- $program->delete();
- } catch (RuntimeException|JsonException) {
- // Already gone — ignore.
- }
-
+ Program::find($id)?->delete();
$this->loadPrograms();
}
diff --git a/app/Livewire/ProgramEditor.php b/app/Livewire/ProgramEditor.php
index 6fe9b41..76559d8 100644
--- a/app/Livewire/ProgramEditor.php
+++ b/app/Livewire/ProgramEditor.php
@@ -5,12 +5,10 @@
namespace App\Livewire;
use App\Enum\BeepLeadIn;
-use App\Timer\AppSettings;
-use App\Timer\Phase;
-use App\Timer\TimerProgram;
+use App\Models\Program;
+use App\Models\Setting;
use Illuminate\Validation\Rules\Enum;
use Illuminate\View\View;
-use JsonException;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
@@ -36,7 +34,7 @@ class ProgramEditor extends Component
public bool $showPhaseForm = false;
- /** @var array[] Raw phase arrays (toArray) for display, mutated in-place */
+ /** @var array[] Raw phase arrays for display, mutated in-place */
public array $phases = [];
// Color palette for quick-pick
@@ -51,14 +49,39 @@ class ProgramEditor extends Component
'#06b6d4', // cyan
];
+ public function mount(string $id): void
+ {
+ if ($id === 'create') {
+ $settings = Setting::current();
+ $this->beepLeadIn = $settings->default_beep_lead_in;
+ $this->endSound = $settings->default_end_sound;
+ } else {
+ $program = Program::with('phases')->findOrFail($id);
+ $this->programId = $program->id;
+ $this->name = $program->name;
+ $this->beepLeadIn = $program->beep_lead_in;
+ $this->endSound = $program->end_sound;
+ $this->phases = $program->phases
+ ->map(fn($p) => [
+ 'label' => $p->label,
+ 'duration' => $p->duration,
+ 'repetitions' => $p->repetitions,
+ 'pause' => $p->pause,
+ 'cooldown' => $p->cooldown,
+ 'color' => $p->color,
+ ])
+ ->all();
+ }
+ }
+
+ // ── Phase form ────────────────────────────────────────────────────────
+
public function cancelPhaseForm(): void
{
$this->showPhaseForm = false;
$this->resetPhaseForm();
}
- // ── Phase form ────────────────────────────────────────────────────────
-
public function deletePhase(int $index): void
{
array_splice($this->phases, $index, 1);
@@ -67,58 +90,16 @@ public function deletePhase(int $index): void
public function editPhase(int $index): void
{
$p = $this->phases[$index];
- $this->phaseLabel = $p['label'];
+ $this->phaseLabel = $p['label'];
$this->phaseDuration = $p['duration'];
- $this->phaseReps = $p['repetitions'];
- $this->phasePause = $p['pause'];
+ $this->phaseReps = $p['repetitions'];
+ $this->phasePause = $p['pause'];
$this->phaseCooldown = $p['cooldown'];
- $this->phaseColor = $p['color'];
+ $this->phaseColor = $p['color'];
$this->editingPhaseIndex = $index;
$this->showPhaseForm = true;
}
- public function formattedDuration(): string
- {
- $total = $this->totalDuration();
- return sprintf('%d:%02d', intdiv($total, 60), $total % 60);
- }
-
- public function totalDuration(): int
- {
- return array_reduce(
- $this->phases,
- static function (int $carry, array $p): int {
- $repTime = $p['duration'] * $p['repetitions'];
- $pauses = $p['pause'] * max(0, $p['repetitions'] - 1);
- return $carry + $repTime + $pauses + $p['cooldown'];
- },
- 0,
- );
- }
-
- /**
- * @throws JsonException
- */
- public function mount(string $id): void
- {
- if ($id === 'create') {
- // New program with defaults from settings
- $settings = AppSettings::load();
- $this->beepLeadIn = $settings->defaultBeepLeadIn;
- $this->endSound = $settings->defaultEndSound;
- } else {
- $program = TimerProgram::load($id);
- $this->programId = $program->id;
- $this->name = $program->name;
- $this->beepLeadIn = $program->beepLeadIn;
- $this->endSound = $program->endSound;
- $this->phases = array_map(
- static fn(Phase $p) => $p->toArray(),
- $program->phases,
- );
- }
- }
-
public function movePhaseDown(int $index): void
{
if ($index >= count($this->phases) - 1) return;
@@ -133,8 +114,6 @@ public function movePhaseUp(int $index): void
[$this->phases[$index], $this->phases[$index - 1]];
}
- // ── Program save/delete ───────────────────────────────────────────────
-
public function openAddPhase(): void
{
if (count($this->phases) >= 10) {
@@ -145,48 +124,13 @@ public function openAddPhase(): void
$this->showPhaseForm = true;
}
- // ── Computed ─────────────────────────────────────────────────────────
-
- /**
- * True when the phase form is open for the last phase in the list
- * (or for a new phase that will become the last).
- * Used in the view to grey out the cooldown field.
- */
- public function editingIsLastPhase(): bool
- {
- $count = count($this->phases);
- if ($count === 0) {
- return true; // first (and only) phase being added → will be last
- }
- if ($this->editingPhaseIndex === null) {
- return true; // adding a new phase → will be appended as last
- }
- return $this->editingPhaseIndex === $count - 1;
- }
-
- private function resetPhaseForm(): void
- {
- $this->phaseLabel = '';
- $this->phaseDuration = 30;
- $this->phaseReps = 3;
- $this->phasePause = 0;
- $this->phaseCooldown = 0;
- $this->phaseColor = '#3b82f6';
- $this->editingPhaseIndex = null;
- }
-
- public function render(): View
- {
- return view('livewire.program-editor');
- }
-
public function savePhase(): void
{
$this->validate([
- 'phaseLabel' => 'required|string|max:40',
+ 'phaseLabel' => 'required|string|max:40',
'phaseDuration' => 'required|integer|min:1|max:3600',
- 'phaseReps' => 'required|integer|min:1|max:50',
- 'phasePause' => 'required|integer|min:0|max:3600',
+ 'phaseReps' => 'required|integer|min:1|max:50',
+ 'phasePause' => 'required|integer|min:0|max:3600',
'phaseCooldown' => 'required|integer|min:0|max:3600',
]);
@@ -195,12 +139,12 @@ public function savePhase(): void
}
$phaseArray = [
- 'label' => trim($this->phaseLabel),
- 'duration' => $this->phaseDuration,
+ 'label' => trim($this->phaseLabel),
+ 'duration' => $this->phaseDuration,
'repetitions' => $this->phaseReps,
- 'pause' => $this->phasePause,
- 'cooldown' => $this->phaseCooldown,
- 'color' => $this->phaseColor,
+ 'pause' => $this->phasePause,
+ 'cooldown' => $this->phaseCooldown,
+ 'color' => $this->phaseColor,
];
if ($this->editingPhaseIndex !== null) {
@@ -220,36 +164,102 @@ public function savePhaseAndAddNew(): void
$this->showPhaseForm = true;
}
- // ── Internals ─────────────────────────────────────────────────────────
+ // ── Program save ──────────────────────────────────────────────────────
- /**
- * @throws JsonException
- */
public function saveProgram(): void
{
$this->validate([
- 'name' => 'required|string|max:60',
+ 'name' => 'required|string|max:60',
'beepLeadIn' => ['required', new Enum(BeepLeadIn::class)],
- 'endSound' => 'required|in:triple,chime',
+ 'endSound' => 'required|in:triple,chime',
]);
if ($this->programId === '') {
- $program = TimerProgram::create($this->name);
+ $settings = Setting::current();
+ $program = Program::create([
+ 'name' => $this->name,
+ 'beep_lead_in' => $settings->default_beep_lead_in,
+ 'end_sound' => $settings->default_end_sound,
+ ]);
$this->programId = $program->id;
} else {
- $program = TimerProgram::load($this->programId);
+ $program = Program::findOrFail($this->programId);
$program->name = $this->name;
}
- $program->beepLeadIn = $this->beepLeadIn;
- $program->endSound = $this->endSound;
- $program->phases = array_map(
- static fn(array $p) => Phase::fromArray($p),
+ $program->beep_lead_in = $this->beepLeadIn;
+ $program->end_sound = $this->endSound;
+ $program->save();
+
+ $program->phases()->delete();
+ foreach ($this->phases as $index => $phaseData) {
+ $program->phases()->create([
+ 'sort_order' => $index,
+ 'label' => $phaseData['label'],
+ 'duration' => (int) $phaseData['duration'],
+ 'repetitions' => (int) $phaseData['repetitions'],
+ 'pause' => (int) $phaseData['pause'],
+ 'cooldown' => (int) $phaseData['cooldown'],
+ 'color' => $phaseData['color'],
+ ]);
+ }
+
+ $this->redirect("/timer/$program->id");
+ }
+
+ // ── Computed ─────────────────────────────────────────────────────────
+
+ public function formattedDuration(): string
+ {
+ $total = $this->totalDuration();
+ return sprintf('%d:%02d', intdiv($total, 60), $total % 60);
+ }
+
+ public function totalDuration(): int
+ {
+ return array_reduce(
$this->phases,
+ static function (int $carry, array $p): int {
+ $repTime = $p['duration'] * $p['repetitions'];
+ $pauses = $p['pause'] * max(0, $p['repetitions'] - 1);
+ return $carry + $repTime + $pauses + $p['cooldown'];
+ },
+ 0,
);
+ }
- $program->save();
+ /**
+ * True when the phase form is open for the last phase in the list
+ * (or for a new phase that will become the last).
+ * Used in the view to grey out the cooldown field.
+ */
+ public function editingIsLastPhase(): bool
+ {
+ $count = count($this->phases);
+ if ($count === 0) {
+ return true;
+ }
+ if ($this->editingPhaseIndex === null) {
+ return true;
+ }
+ return $this->editingPhaseIndex === $count - 1;
+ }
- $this->redirect("/timer/$program->id");
+ public function render(): View
+ {
+ return view('livewire.program-editor');
+ }
+
+ // ── Internals ─────────────────────────────────────────────────────────
+
+ private function resetPhaseForm(): void
+ {
+ $this->phaseLabel = '';
+ $this->phaseDuration = 30;
+ $this->phaseReps = 3;
+ $this->phasePause = 0;
+ $this->phaseCooldown = 0;
+ $this->phaseColor = '#3b82f6';
+ $this->editingPhaseIndex = null;
}
}
diff --git a/app/Livewire/Settings.php b/app/Livewire/Settings.php
index a8b3823..305b63c 100644
--- a/app/Livewire/Settings.php
+++ b/app/Livewire/Settings.php
@@ -5,10 +5,9 @@
namespace App\Livewire;
use App\Enum\BeepLeadIn;
-use App\Timer\AppSettings;
+use App\Models\Setting;
use Illuminate\Validation\Rules\Enum;
use Illuminate\View\View;
-use JsonException;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
@@ -27,13 +26,13 @@ class Settings extends Component
public function mount(): void
{
- $settings = AppSettings::load();
+ $settings = Setting::current();
- $this->defaultBeepLeadIn = $settings->defaultBeepLeadIn;
- $this->defaultEndSound = $settings->defaultEndSound;
- $this->soundMode = $settings->soundMode;
- $this->volume = $settings->volume;
- $this->keepScreenOn = $settings->keepScreenOn;
+ $this->defaultBeepLeadIn = $settings->default_beep_lead_in;
+ $this->defaultEndSound = $settings->default_end_sound;
+ $this->soundMode = $settings->sound_mode;
+ $this->volume = $settings->volume;
+ $this->keepScreenOn = $settings->keep_screen_on;
}
public function render(): View
@@ -41,26 +40,23 @@ public function render(): View
return view('livewire.settings');
}
- /**
- * @throws JsonException
- */
public function save(): void
{
$this->validate([
'defaultBeepLeadIn' => ['required', new Enum(BeepLeadIn::class)],
- 'defaultEndSound' => 'required|in:triple,chime',
- 'soundMode' => 'required|in:beep,voice',
- 'volume' => 'required|numeric|min:0|max:1',
- 'keepScreenOn' => 'boolean',
+ 'defaultEndSound' => 'required|in:triple,chime',
+ 'soundMode' => 'required|in:beep,voice',
+ 'volume' => 'required|numeric|min:0|max:1',
+ 'keepScreenOn' => 'boolean',
]);
- $settings = AppSettings::load();
+ $settings = Setting::current();
- $settings->defaultBeepLeadIn = $this->defaultBeepLeadIn;
- $settings->defaultEndSound = $this->defaultEndSound;
- $settings->soundMode = $this->soundMode;
- $settings->volume = round((float)$this->volume, 2);
- $settings->keepScreenOn = $this->keepScreenOn;
+ $settings->default_beep_lead_in = $this->defaultBeepLeadIn;
+ $settings->default_end_sound = $this->defaultEndSound;
+ $settings->sound_mode = $this->soundMode;
+ $settings->volume = round((float) $this->volume, 2);
+ $settings->keep_screen_on = $this->keepScreenOn;
$settings->save();
diff --git a/app/Livewire/TimerScreen.php b/app/Livewire/TimerScreen.php
index aa6012a..abb7958 100644
--- a/app/Livewire/TimerScreen.php
+++ b/app/Livewire/TimerScreen.php
@@ -5,17 +5,14 @@
namespace App\Livewire;
use App\Enum\StateMachine;
-use App\Timer\AppSettings;
-use App\Timer\HistoryEntry;
-use App\Timer\HistoryLog;
-use App\Timer\Phase;
+use App\Models\HistoryEntry;
+use App\Models\Phase;
+use App\Models\Program;
+use App\Models\Setting;
use App\Timer\TimerCursor;
-use App\Timer\TimerProgram;
use App\Timer\TimerRunner;
use Illuminate\Support\Facades\Log;
-use Illuminate\Support\Facades\Storage;
use Illuminate\View\View;
-use JsonException;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
@@ -29,7 +26,7 @@ class TimerScreen extends Component
public string $programName = '';
// ── Cursor snapshot (serializable scalars for Livewire) ──────────────
- public StateMachine $state = StateMachine::idle; // mirrors TimerCursor->state
+ public StateMachine $state = StateMachine::idle;
public int $remaining = 0;
public int $totalRemaining = 0;
public int $phaseIndex = 0;
@@ -57,34 +54,11 @@ class TimerScreen extends Component
/** @var array[] serialised HistoryEntry rows + 'program_exists' bool */
public array $history = [];
- public function discard(): void
- {
- app(TimerRunner::class)->discard();
- $this->state = StateMachine::idle;
- $this->remaining = 0;
- $this->totalRemaining = 0;
- $this->dispatch('topbar-title', title: config('app.name'));
- }
-
- public function formattedRemaining(): string
- {
- $s = $this->remaining;
- return sprintf('%d:%02d', intdiv($s, 60), $s % 60);
- }
-
- // ── Timer controls ────────────────────────────────────────────────────
-
- public function formattedTotal(): string
- {
- $s = $this->totalRemaining;
- return sprintf('%d:%02d', intdiv($s, 60), $s % 60);
- }
-
public function mount(?string $id = null): void
{
- $settings = AppSettings::load();
- $this->soundMode = $settings->soundMode;
- $this->volume = $settings->volume;
+ $settings = Setting::current();
+ $this->soundMode = $settings->sound_mode;
+ $this->volume = $settings->volume;
if ($id) {
$this->loadProgram($id);
@@ -93,108 +67,100 @@ public function mount(?string $id = null): void
}
}
+ // ── Timer controls ────────────────────────────────────────────────────
+
public function loadProgram(string $id): void
{
- $runner = app(TimerRunner::class);
- $program = TimerProgram::load($id);
+ $runner = app(TimerRunner::class);
+ $program = Program::with('phases')->findOrFail($id);
- $this->programId = $id;
- $this->programName = $program->name;
- $this->endSound = $program->endSound;
+ $this->programId = $id;
+ $this->programName = $program->name;
+ $this->endSound = $program->end_sound;
$this->programTotalDuration = $program->totalDuration();
$this->rehydrateRunner($runner);
$this->syncCursor($runner->cursor(), $program);
- // Show program name in the top bar as soon as program is loaded
$this->dispatch('topbar-title', title: $program->name);
-
- // Push settings to JS audio layer
$this->dispatch('settingsLoaded', soundMode: $this->soundMode, volume: $this->volume, program: $program);
}
- private function rehydrateRunner(TimerRunner $runner): void
+ public function discard(): void
{
- if (!$this->programId) {
- return;
- }
- $program = TimerProgram::load($this->programId);
- $runner->load($program);
+ app(TimerRunner::class)->discard();
+ $this->state = StateMachine::idle;
+ $this->remaining = 0;
+ $this->totalRemaining = 0;
+ $this->dispatch('topbar-title', title: config('app.name'));
+ }
- $cursor = new TimerCursor(
- phaseIndex: $this->phaseIndex,
- repIndex: $this->repIndex,
- state: $this->state,
- remaining: $this->remaining,
- totalRemaining: $this->totalRemaining,
- );
+ public function pause(): void
+ {
+ $runner = app(TimerRunner::class);
+ $this->rehydrateRunner($runner);
+ $runner->pause();
+ $this->syncCursor($runner->cursor(), Program::with('phases')->findOrFail($this->programId));
+ }
- $runner->cursor = $cursor;
- $runner->onBeep(function (string $reason): void {
- $this->handleBeep($reason);
- });
- $runner->onPauseBeep(function (): void {
- $this->dispatch('playPauseBeep');
- });
+ public function resume(): void
+ {
+ $runner = app(TimerRunner::class);
+ $this->rehydrateRunner($runner);
+ $runner->resume();
+ $this->syncCursor($runner->cursor(), Program::with('phases')->findOrFail($this->programId));
}
- private function handleBeep(string $reason): void
+ public function restart(): void
{
- // Determine the countdown label for voice mode
- $this->countdownLabel = match ($reason) {
- 'prepare', 'countdown' => (string)$this->remaining,
- 'rep_end' => 'Done',
- 'pause_end' => 'Go',
- 'cooldown_end' => 'Next',
- default => '',
- };
- $this->dispatch('playBeep', reason: $reason);
+ $runner = app(TimerRunner::class);
+ $program = Program::with('phases')->findOrFail($this->programId);
+ $this->programTotalDuration = $program->totalDuration();
+ $runner->load($program);
+ $this->syncCursor($runner->cursor(), $program);
+ $this->dispatch('topbar-title', title: config('app.name'));
}
- private function syncCursor(TimerCursor $cursor, TimerProgram $program): void
+ public function start(): void
{
- $this->state = $cursor->state;
- $this->remaining = $cursor->remaining;
- $this->totalRemaining = $cursor->totalRemaining;
- $this->phaseIndex = $cursor->phaseIndex;
- $this->repIndex = $cursor->repIndex;
+ $runner = app(TimerRunner::class);
+ $program = Program::with('phases')->findOrFail($this->programId);
- $this->phases = $program->phases |> (fn($phase) => array_map(
- static fn(Phase $p) => $p->toArray(),
- $phase,
- ));
+ $this->programTotalDuration = $program->totalDuration();
+ $runner->load($program);
+ $runner->start();
+ $this->syncCursor($runner->cursor(), $program);
- if (isset($program->phases[$cursor->phaseIndex])) {
- $phase = $program->phases[$cursor->phaseIndex];
- $this->phaseLabel = $phase->label;
- $this->phaseColor = $phase->color;
- $this->phaseReps = $phase->repetitions;
- }
+ $this->dispatch('topbar-title', title: $this->programName);
}
- private function loadHistory(): void
+ /** Called every second from JS setInterval via wire:poll equivalent. */
+ public function tick(): void
{
- $this->history = array_map(
- static function (array $entry): array {
- $entry['program_exists'] = Storage::exists("programs/{$entry['program_id']}.json");
- return $entry;
- },
- array_map(
- static fn(HistoryEntry $e) => $e->toArray(),
- HistoryLog::all(),
- ),
- );
+ $runner = app(TimerRunner::class);
+ $this->rehydrateRunner($runner);
+
+ if (!$runner->cursor()->isActive()) {
+ return;
+ }
+
+ $runner->tick();
+ $cursor = $runner->cursor();
+ $program = Program::with('phases')->findOrFail($this->programId);
+
+ $this->syncCursor($cursor, $program);
+
+ if ($cursor->isCompleted()) {
+ Log::info('Completed!');
+ $this->dispatch('playEndSound', sound: $this->endSound);
+ $this->dispatch('topbar-title', title: config('app.name'));
+ }
}
- /**
- * @throws JsonException
- */
- public function pause(): void
+ public function requestSettings(): void
{
- $runner = app(TimerRunner::class);
- $this->rehydrateRunner($runner);
- $runner->pause();
- $this->syncCursor($runner->cursor(), TimerProgram::load($this->programId));
+ $program = $this->programId ? Program::with('phases')->find($this->programId) : null;
+ $this->dispatch('settingsLoaded', soundMode: $this->soundMode, volume: $this->volume, program: $program);
}
public function render(): View
@@ -202,90 +168,123 @@ public function render(): View
return view('livewire.timer-screen');
}
- public function repLabel(): string
- {
- if (in_array($this->state, [StateMachine::prepare, StateMachine::cooldown, StateMachine::completed], true)) {
- return '';
- }
- return sprintf('%d / %d', $this->repIndex + 1, $this->phaseReps);
- }
+ // ── Display helpers ───────────────────────────────────────────────────
- public function requestSettings(): void
+ public function formattedRemaining(): string
{
- $program = $this->programId ? TimerProgram::load($this->programId) : null;
- $this->dispatch('settingsLoaded', soundMode: $this->soundMode, volume: $this->volume, program: $program);
+ $s = $this->remaining;
+ return sprintf('%d:%02d', intdiv($s, 60), $s % 60);
}
- public function restart(): void
+ public function formattedTotal(): string
{
- $runner = app(TimerRunner::class);
- $program = TimerProgram::load($this->programId);
- $this->programTotalDuration = $program->totalDuration();
- $runner->load($program);
- $this->syncCursor($runner->cursor(), $program);
- $this->dispatch('topbar-title', title: config('app.name'));
+ $s = $this->totalRemaining;
+ return sprintf('%d:%02d', intdiv($s, 60), $s % 60);
}
- /**
- * @throws JsonException
- */
- public function resume(): void
+ public function repLabel(): string
{
- $runner = app(TimerRunner::class);
- $this->rehydrateRunner($runner);
- $runner->resume();
- $this->syncCursor($runner->cursor(), TimerProgram::load($this->programId));
+ if (in_array($this->state, [StateMachine::prepare, StateMachine::cooldown, StateMachine::completed], true)) {
+ return '';
+ }
+ return sprintf('%d / %d', $this->repIndex + 1, $this->phaseReps);
}
public function segmentLabel(): string
{
return match ($this->state) {
- StateMachine::prepare => 'Get Ready',
- StateMachine::pause => 'Pause',
- StateMachine::cooldown => 'Cooldown',
- StateMachine::paused => 'Paused',
+ StateMachine::prepare => 'Get Ready',
+ StateMachine::pause => 'Pause',
+ StateMachine::cooldown => 'Cooldown',
+ StateMachine::paused => 'Paused',
StateMachine::completed => 'Complete!',
- default => $this->phaseLabel,
+ default => $this->phaseLabel,
};
}
- public function start(): void
+ // ── Internals ─────────────────────────────────────────────────────────
+
+ private function rehydrateRunner(TimerRunner $runner): void
{
- $runner = app(TimerRunner::class);
- $program = TimerProgram::load($this->programId);
+ if (!$this->programId) {
+ return;
+ }
- $this->programTotalDuration = $program->totalDuration();
+ $program = Program::with('phases')->findOrFail($this->programId);
$runner->load($program);
- $runner->start();
- $this->syncCursor($runner->cursor(), $program);
+ $cursor = new TimerCursor(
+ phaseIndex: $this->phaseIndex,
+ repIndex: $this->repIndex,
+ state: $this->state,
+ remaining: $this->remaining,
+ totalRemaining: $this->totalRemaining,
+ );
- // EDGE top bar → program name
- $this->dispatch('topbar-title', title: $this->programName);
+ $runner->cursor = $cursor;
+ $runner->onBeep(function (string $reason): void {
+ $this->handleBeep($reason);
+ });
+ $runner->onPauseBeep(function (): void {
+ $this->dispatch('playPauseBeep');
+ });
}
- /** Called every second from JS setInterval via wire:poll equivalent.
- * @throws JsonException
- */
- public function tick(): void
+ private function handleBeep(string $reason): void
{
- $runner = app(TimerRunner::class);
- $this->rehydrateRunner($runner);
-
- if (!$runner->cursor()->isActive()) {
- return;
- }
-
- $runner->tick();
- $cursor = $runner->cursor();
- $program = TimerProgram::load($this->programId);
+ $this->countdownLabel = match ($reason) {
+ 'prepare', 'countdown' => (string) $this->remaining,
+ 'rep_end' => 'Done',
+ 'pause_end' => 'Go',
+ 'cooldown_end' => 'Next',
+ default => '',
+ };
+ $this->dispatch('playBeep', reason: $reason);
+ }
- $this->syncCursor($cursor, $program);
+ private function syncCursor(TimerCursor $cursor, Program $program): void
+ {
+ $this->state = $cursor->state;
+ $this->remaining = $cursor->remaining;
+ $this->totalRemaining = $cursor->totalRemaining;
+ $this->phaseIndex = $cursor->phaseIndex;
+ $this->repIndex = $cursor->repIndex;
+
+ $this->phases = $program->phases
+ ->map(fn(Phase $p) => [
+ 'label' => $p->label,
+ 'duration' => $p->duration,
+ 'repetitions' => $p->repetitions,
+ 'pause' => $p->pause,
+ 'cooldown' => $p->cooldown,
+ 'color' => $p->color,
+ ])
+ ->all();
- if ($cursor->isCompleted()) {
- Log::info('Completed!');
- $this->dispatch('playEndSound', sound: $this->endSound);
- $this->dispatch('topbar-title', title: config('app.name'));
+ if (isset($program->phases[$cursor->phaseIndex])) {
+ $phase = $program->phases[$cursor->phaseIndex];
+ $this->phaseLabel = $phase->label;
+ $this->phaseColor = $phase->color;
+ $this->phaseReps = $phase->repetitions;
}
}
+
+ private function loadHistory(): void
+ {
+ $entries = HistoryEntry::latest('completed_at')->limit(20)->get();
+
+ $existingIds = Program::whereIn('id', $entries->pluck('program_id')->filter())
+ ->pluck('id')
+ ->flip();
+
+ $this->history = $entries
+ ->map(fn(HistoryEntry $e) => [
+ 'program_id' => $e->program_id,
+ 'program_name' => $e->program_name,
+ 'completed_at' => $e->completed_at->toISOString(),
+ 'total_duration' => $e->total_duration,
+ 'program_exists' => $e->program_id !== null && $existingIds->has($e->program_id),
+ ])
+ ->all();
+ }
}
diff --git a/app/Timer/AppSettings.php b/app/Timer/AppSettings.php
deleted file mode 100644
index 2a52be9..0000000
--- a/app/Timer/AppSettings.php
+++ /dev/null
@@ -1,74 +0,0 @@
-defaultBeepLeadIn = BeepLeadIn::from((int)($data['default_beep_lead_in'] ?? 3));
- $settings->defaultEndSound = $data['default_end_sound'] ?? 'triple';
- $settings->soundMode = $data['sound_mode'] ?? 'beep';
- $settings->volume = (float)($data['volume'] ?? 0.8);
- $settings->keepScreenOn = (bool)($data['keep_screen_on'] ?? true);
- }
-
- return $settings;
- }
-
- /**
- * @throws JsonException
- */
- public function save(): void
- {
- Storage::put(
- self::PATH,
- json_encode($this->toArray(), JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR),
- );
- }
-
- public function toArray(): array
- {
- return [
- 'default_beep_lead_in' => $this->defaultBeepLeadIn,
- 'default_end_sound' => $this->defaultEndSound,
- 'sound_mode' => $this->soundMode,
- 'volume' => $this->volume,
- 'keep_screen_on' => $this->keepScreenOn,
- ];
- }
-}
diff --git a/app/Timer/HistoryEntry.php b/app/Timer/HistoryEntry.php
deleted file mode 100644
index 4bd7110..0000000
--- a/app/Timer/HistoryEntry.php
+++ /dev/null
@@ -1,41 +0,0 @@
- $this->programId,
- 'program_name' => $this->programName,
- 'completed_at' => $this->completedAt,
- 'total_duration' => $this->totalDuration,
- ];
- }
-}
diff --git a/app/Timer/HistoryLog.php b/app/Timer/HistoryLog.php
deleted file mode 100644
index 9725d48..0000000
--- a/app/Timer/HistoryLog.php
+++ /dev/null
@@ -1,63 +0,0 @@
- (fn($arr) => [$entry, ...$arr])
- |> (fn($arr) => array_slice($arr, 0, self::MAX_ENTRIES))
- |> (fn($arr) => array_map(static fn(HistoryEntry $e) => $e->toArray(), $arr))
- |> (fn($x) => json_encode($x, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR))
- |> (fn($x) => Storage::put(self::PATH, $x));
-
- }
-
- /**
- * Return all history entries, newest first.
- *
- * @return HistoryEntry[]
- */
- public static function all(): array
- {
- if (!Storage::exists(self::PATH)) {
- return [];
- }
-
- try {
- return Storage::get(self::PATH)
- |> (fn($x) => json_decode($x, true, 512, JSON_THROW_ON_ERROR))
- |> (fn($x) => array_map(static fn(array $row) => HistoryEntry::fromArray($row),
- $x));
- } catch (JsonException) {
- return [];
- }
-
-
- }
-
- public static function clear(): void
- {
- Storage::delete(self::PATH);
- }
-}
diff --git a/app/Timer/TimerProgram.php b/app/Timer/TimerProgram.php
deleted file mode 100644
index e274cd1..0000000
--- a/app/Timer/TimerProgram.php
+++ /dev/null
@@ -1,204 +0,0 @@
- — load() chains Storage::get → json_decode → hydrate.
- * Shown in comments; falls back to explicit nesting for
- * PHP < 8.5 host tooling.
- * • #[\NoDiscard] — on totalDuration(); callers must use the return value.
- * • array_first_value() — used in TimerRunner to grab the first phase.
- */
-class TimerProgram
-{
- public readonly string $id;
- public string $name;
- public string $createdAt;
- public ?string $lastUsedAt;
- public BeepLeadIn $beepLeadIn; // 3 | 5
- public string $endSound; // 'triple' | 'chime'
- /** @var Phase[] */
- public array $phases; // max 10
-
- private function __construct(
- string $id,
- string $name,
- string $createdAt,
- ?string $lastUsedAt,
- BeepLeadIn $beepLeadIn,
- string $endSound,
- array $phases,
- )
- {
- $this->id = $id;
- $this->name = $name;
- $this->createdAt = $createdAt;
- $this->lastUsedAt = $lastUsedAt;
- $this->beepLeadIn = $beepLeadIn;
- $this->endSound = $endSound;
- $this->phases = $phases;
- }
-
- /** Return all saved programs, sorted by last_used_at desc (falling back to created_at). */
- public static function all(): array
- {
- return collect(Storage::files('programs'))
- ->filter(fn(string $p) => str_ends_with($p, '.json'))
- ->map(fn(string $p) => self::load(basename($p, '.json')))
- ->sortByDesc(fn(self $prog) => $prog->lastUsedAt ?? $prog->createdAt)
- ->values()
- ->all();
- }
-
- /** Create a brand-new program seeded with global defaults. */
- public static function create(string $name): self
- {
- $settings = AppSettings::load();
-
- return new self(
- id: (string)Str::uuid(),
- name: $name,
- createdAt: now()->toISOString(),
- lastUsedAt: null,
- beepLeadIn: $settings->defaultBeepLeadIn,
- endSound: $settings->defaultEndSound,
- phases: [],
- );
- }
-
- /**
- * Load from a JSON file.
- *
- * PHP 8.5 pipe operator chains Storage::get → json_decode → hydrate.
- *
- */
- public static function load(string $id): self
- {
- $path = "programs/$id.json";
-
- if (!Storage::exists($path)) {
- throw new RuntimeException("Program not found: $id");
- }
-
- return Storage::get($path)
- |> (fn($j) => json_decode($j, true, 512, JSON_THROW_ON_ERROR))
- |> self::hydrate(...);
- }
-
- private static function hydrate(array $data): self
- {
- return new self(
- id: $data['id'],
- name: $data['name'],
- createdAt: $data['created_at'],
- lastUsedAt: $data['last_used_at'] ?? null,
- beepLeadIn: BeepLeadIn::from($data['beep_lead_in'] ?? 3),
- endSound: $data['end_sound'] ?? 'triple',
- phases: array_map(
- static fn(array $p) => Phase::fromArray($p),
- $data['phases'] ?? [],
- ),
- );
- }
-
- /** Add a phase; max 10 per program. */
- public function addPhase(Phase $phase): void
- {
- if (count($this->phases) >= 10) {
- throw new OverflowException('A program may have at most 10 phases.');
- }
- $this->phases[] = $phase;
- }
-
- public function delete(): void
- {
- Storage::delete("programs/$this->id.json");
- }
-
- /** Human-readable total duration, e.g. "12:34". */
- public function formattedDuration(): string
- {
- $total = $this->totalDuration();
- $minutes = intdiv($total, 60);
- $seconds = $total % 60;
- return sprintf('%d:%02d', $minutes, $seconds);
- }
-
- public function touch(): void
- {
- $this->lastUsedAt = now()->toISOString();
- $this->save();
- }
-
- /** Save the program to its JSON file.
- */
- public function save(): void
- {
- try {
- Storage::put(
- "programs/$this->id.json",
- json_encode($this->toArray(), JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR),
- );
- } catch (JsonException) {
- Storage::put(
- "programs/$this->id.json",
- json_encode([], JSON_PRETTY_PRINT),
- );
- }
- }
-
- public function toArray(): array
- {
- return [
- 'id' => $this->id,
- 'name' => $this->name,
- 'created_at' => $this->createdAt,
- 'last_used_at' => $this->lastUsedAt,
- 'beep_lead_in' => $this->beepLeadIn,
- 'end_sound' => $this->endSound,
- 'total_duration' => $this->totalDuration(),
- 'phases' => array_map(
- static fn(Phase $p) => $p->toArray(),
- $this->phases,
- ),
- ];
- }
-
- /**
- * Computed total duration in seconds.
- *
- * Formula per phase:
- * (duration × reps) + (pause × (reps − 1)) + cooldown
- *
- */
- #[NoDiscard]
- public function totalDuration(): int
- {
- $totalDuration = array_reduce(
- $this->phases,
- static function (int $carry, Phase $phase): int {
- $repTime = $phase->duration * $phase->repetitions;
- $pauses = $phase->pause * max(0, $phase->repetitions - 1);
- return $carry + $repTime + $pauses + $phase->cooldown;
- },
- 0,
- );
-
- return $totalDuration - array_last($this->phases)?->cooldown;
- }
-}
diff --git a/app/Timer/TimerRunner.php b/app/Timer/TimerRunner.php
index f83df59..97506f2 100644
--- a/app/Timer/TimerRunner.php
+++ b/app/Timer/TimerRunner.php
@@ -8,6 +8,8 @@
use App\Events\PhaseChanged;
use App\Events\ProgramCompleted;
use App\Jobs\WriteHistoryEntry;
+use App\Models\Phase;
+use App\Models\Program;
use Closure;
use RuntimeException;
@@ -39,7 +41,7 @@ class TimerRunner
{
private const PREPARE_SECONDS = 5;
- private ?TimerProgram $program = null;
+ private ?Program $program = null;
public TimerCursor $cursor {
set {
$this->cursor = $value;
@@ -76,10 +78,9 @@ public function discard(): void
}
/** Load a program and reset the cursor to idle. */
- public function load(TimerProgram $program): void
+ public function load(Program $program): void
{
$this->program = $program;
-
$this->cursor = TimerCursor::idle();
}
@@ -124,7 +125,7 @@ private function fireOnPauseBeep(): void
}
}
- public function program(): ?TimerProgram
+ public function program(): ?Program
{
return $this->program;
}
@@ -179,7 +180,7 @@ private function assertProgramLoaded(): void
if ($this->program === null) {
throw new RuntimeException('No program loaded. Call load() first.');
}
- if (count($this->program->phases) === 0) {
+ if ($this->program->phases->isEmpty()) {
throw new RuntimeException('Program has no phases.');
}
}
@@ -200,7 +201,7 @@ private function currentPhase(): Phase
/** @return Phase[] */
private function phases(): array
{
- return $this->program->phases;
+ return $this->program->phases->all();
}
/**
@@ -244,7 +245,7 @@ public function tick(): void
/**
* True when the cursor's remaining falls within the lead-in countdown window.
- * Uses beepLeadIn->value to extract the int from the BeepLeadIn backed enum.
+ * Uses beep_lead_in->value to extract the int from the BeepLeadIn backed enum.
*/
private function shouldBeep(TimerCursor $cursor): bool
{
@@ -252,7 +253,7 @@ private function shouldBeep(TimerCursor $cursor): bool
return false;
}
- $leadIn = $this->program->beepLeadIn->value; // BeepLeadIn: int enum
+ $leadIn = $this->program->beep_lead_in->value;
$segmentTotal = $this->segmentDurationForCursor($cursor);
$effectiveLead = ($segmentTotal < $leadIn) ? max(1, $segmentTotal - 1) : $leadIn;
@@ -380,7 +381,7 @@ private function complete(): void
ProgramCompleted::dispatch(
$this->program->id,
- $this->program->endSound,
+ $this->program->end_sound,
$totalDuration,
);
diff --git a/composer.json b/composer.json
index 07b4d89..eec89f2 100644
--- a/composer.json
+++ b/composer.json
@@ -41,12 +41,13 @@
"composer install",
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
"@php artisan key:generate",
+ "@php artisan migrate --force",
"npm install --ignore-scripts",
"npm run build"
],
"dev": [
"Composer\\Config::disableProcessTimeout",
- "npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185\" \"php artisan serve\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,logs,vite --kill-others"
+ "npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
],
"test": [
"@php artisan config:clear --ansi",
@@ -63,7 +64,9 @@
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
],
"post-create-project-cmd": [
- "@php artisan key:generate --ansi"
+ "@php artisan key:generate --ansi",
+ "@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
+ "@php artisan migrate --graceful --ansi"
],
"pre-package-uninstall": [
"Illuminate\\Foundation\\ComposerScripts::prePackageUninstall"
diff --git a/config/queue.php b/config/queue.php
index 65123df..8126210 100644
--- a/config/queue.php
+++ b/config/queue.php
@@ -7,15 +7,12 @@
| Default Queue Connection Name
|--------------------------------------------------------------------------
|
- | Laravel 13 "background" driver: serialises each job closure into a
- | base64 env-var and spawns a dedicated `artisan invoke-serialized-closure`
- | process. No database, no Redis, no persistent storage — purely in-process
- | / temporary. Perfect for NativePHP where neither Redis nor SQLite are
- | available on-device.
+ | The Android runtime hardcodes QUEUE_CONNECTION=database, so the database
+ | driver is always used on-device. The jobs table is created via migration.
|
*/
- 'default' => env('QUEUE_CONNECTION', 'background'),
+ 'default' => env('QUEUE_CONNECTION', 'database'),
'connections' => [
@@ -23,8 +20,12 @@
'driver' => 'sync',
],
- 'background' => [
- 'driver' => 'background',
+ 'database' => [
+ 'driver' => 'database',
+ 'connection' => env('DB_QUEUE_CONNECTION'),
+ 'table' => env('DB_QUEUE_TABLE', 'jobs'),
+ 'queue' => env('DB_QUEUE', 'default'),
+ 'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90),
'after_commit' => false,
],
diff --git a/database/migrations/2026_04_09_122314_create_sessions_table.php b/database/migrations/2026_04_09_122314_create_sessions_table.php
new file mode 100644
index 0000000..f60625b
--- /dev/null
+++ b/database/migrations/2026_04_09_122314_create_sessions_table.php
@@ -0,0 +1,31 @@
+string('id')->primary();
+ $table->foreignId('user_id')->nullable()->index();
+ $table->string('ip_address', 45)->nullable();
+ $table->text('user_agent')->nullable();
+ $table->longText('payload');
+ $table->integer('last_activity')->index();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('sessions');
+ }
+};
diff --git a/tests/Unit/Timer/BeepLogicTest.php b/tests/Unit/Timer/BeepLogicTest.php
index 050c2b1..d252dd3 100644
--- a/tests/Unit/Timer/BeepLogicTest.php
+++ b/tests/Unit/Timer/BeepLogicTest.php
@@ -3,33 +3,35 @@
declare(strict_types=1);
use App\Enum\BeepLeadIn;
-use App\Timer\Phase;
-use App\Timer\TimerProgram;
+use App\Models\Program;
use App\Timer\TimerRunner;
-use Illuminate\Support\Facades\Storage;
// ── Helper ─────────────────────────────────────────────────────────────────────
// Returns an object so mutations from the closure are visible to the caller.
// (PHP array destructuring copies values; stdClass passes by handle.)
-/**
- * @throws JsonException
- */
-function createBeepRunner(array $phases = [], int $leadIn = 3): object
+function createProgram(string $name, array $phases = [], int $leadIn = 3): Program
{
- Storage::fake();
+ $prog = Program::create([
+ 'name' => $name,
+ 'beep_lead_in' => BeepLeadIn::from($leadIn),
+ ]);
- $prog = TimerProgram::create('Beep Test');
- $prog->beepLeadIn = BeepLeadIn::from($leadIn);
- foreach ($phases as $phase) {
- $prog->addPhase($phase);
+ foreach ($phases as $index => $phase) {
+ $prog->phases()->create(array_merge($phase, ['sort_order' => $index]));
}
- $prog->save();
+
+ return $prog;
+}
+
+function createBeepRunner(array $phases = [], int $leadIn = 3): object
+{
+ $prog = createProgram('Beep Test', $phases, $leadIn);
$ctx = new stdClass();
$ctx->beeps = [];
$ctx->runner = new TimerRunner();
- $ctx->runner->load(TimerProgram::load($prog->id));
+ $ctx->runner->load($prog->load('phases'));
$ctx->runner->onBeep(function (string $reason) use ($ctx): void {
$ctx->beeps[] = $reason;
@@ -44,23 +46,28 @@ function createBeepRunner(array $phases = [], int $leadIn = 3): object
return $ctx;
}
-function createPhase(string $name, int $duration, int $reps = 1, int $pause = 0, int $cooldown = 0, string $color = "#3b82f6"): Phase
+function createPhase(string $name, int $duration, int $reps = 1, int $pause = 0, int $cooldown = 0, string $color = "#3b82f6"): array
{
- return new Phase($name, $duration, $reps, $pause, $cooldown, $color);
+ return [
+ 'label' => $name,
+ 'duration' => $duration,
+ 'repetitions' => $reps,
+ 'pause' => $pause,
+ 'cooldown' => $cooldown,
+ 'color' => $color,
+ ];
}
// ── Prepare beep ──────────────────────────────────────────────────────────────
test('prepare beep fires once per tick during the 5s prepare countdown', function (): void {
- Storage::fake();
-
- $prog = TimerProgram::create('Prepare beep test');
- $prog->addPhase(new Phase('Work', 10, 1, 0, 0, '#3b82f6'));
- $prog->save();
+ $prog = createProgram('Prepare beep test', [
+ createPhase('Work', duration: 10),
+ ]);
$beeps = [];
$runner = new TimerRunner();
- $runner->load(TimerProgram::load($prog->id));
+ $runner->load($prog->load('phases'));
$runner->onBeep(function (string $reason) use (&$beeps): void {
$beeps[] = $reason;
});
@@ -68,18 +75,15 @@ function createPhase(string $name, int $duration, int $reps = 1, int $pause = 0,
for ($i = 0; $i < 5; $i++) $runner->tick();
- $prepareCount = count(array_filter($beeps, fn ($r) => $r === 'prepare'));
+ $prepareCount = count(array_filter($beeps, fn($r) => $r === 'prepare'));
// Ticks bring remaining 5→4→3→2→1→0 (beginFirstRep); beep fires while remaining > 0
- expect($prepareCount)->toBe(4);
- // No 'countdown' beeps should fire during prepare
- expect($beeps)->not->toContain('countdown');
+ expect($prepareCount)->toBe(4)
+ ->and($beeps)->not->toContain('countdown');
});
// ── Lead-in 3s ────────────────────────────────────────────────────────────────
-test(/**
- * @throws JsonException
- */ 'beep fires during last 3 seconds of a 10s rep (3s lead-in)',
+test('beep fires during last 3 seconds of a 10s rep (3s lead-in)',
/**
* @throws JsonException
*/
@@ -96,9 +100,6 @@ function (): void {
});
test('beep fires exactly 3 times during last 3 seconds of 10s rep',
- /**
- * @throws JsonException
- */
function (): void {
$ctx = createBeepRunner([
createPhase(name: 'Work', duration: 10),
@@ -236,13 +237,11 @@ function (): void {
* @throws JsonException
*/
function (): void {
- Storage::fake();
- $prog = TimerProgram::create('Pause beep test');
- $prog->addPhase(new Phase('Work', 20, 1, 0, 0, '#3b82f6'));
- $prog->save();
+ $prog = Program::query()->create(['name' => 'Pause beep test']);
+ $prog->phases()->create(['label' => 'Work', 'duration' => 20, 'repetitions' => 1, 'pause' => 0, 'cooldown' => 0, 'color' => '#3b82f6', 'sort_order' => 0]);
$runner = new TimerRunner();
- $runner->load(TimerProgram::load($prog->id));
+ $runner->load($prog->load('phases'));
$pauseBeepCount = 0;
$runner->onPauseBeep(function () use (&$pauseBeepCount): void {
@@ -262,13 +261,11 @@ function (): void {
* @throws JsonException
*/
function (): void {
- Storage::fake();
- $prog = TimerProgram::create('Resume test');
- $prog->addPhase(new Phase('Work', 20, 1, 0, 0, '#3b82f6'));
- $prog->save();
+ $prog = Program::query()->create(['name' => 'Resume test']);
+ $prog->phases()->create(['label' => 'Work', 'duration' => 20, 'repetitions' => 1, 'pause' => 0, 'cooldown' => 0, 'color' => '#3b82f6', 'sort_order' => 0]);
$runner = new TimerRunner();
- $runner->load(TimerProgram::load($prog->id));
+ $runner->load($prog->load('phases'));
$pauseBeepCount = 0;
$runner->onPauseBeep(function () use (&$pauseBeepCount): void {
diff --git a/tests/Unit/Timer/TimerRunnerTest.php b/tests/Unit/Timer/TimerRunnerTest.php
index 5fc0b2b..c416b89 100644
--- a/tests/Unit/Timer/TimerRunnerTest.php
+++ b/tests/Unit/Timer/TimerRunnerTest.php
@@ -3,11 +3,9 @@
declare(strict_types=1);
use App\Enum\StateMachine;
-use App\Timer\Phase;
+use App\Models\Program;
use App\Timer\TimerCursor;
-use App\Timer\TimerProgram;
use App\Timer\TimerRunner;
-use Illuminate\Support\Facades\Storage;
// ── Helpers ───────────────────────────────────────────────────────────────────
@@ -22,23 +20,43 @@ function skipPrepare(TimerRunner $runner): void
for ($i = 0; $i < 5; $i++) $runner->tick();
}
-function onePhaseProgram(int $duration = 10, int $reps = 1, int $pause = 0, int $cooldown = 0): TimerProgram
+function onePhaseProgram(int $duration = 10, int $reps = 1, int $pause = 0, int $cooldown = 0): Program
{
- Storage::fake();
- $prog = TimerProgram::create('Single Phase');
- $prog->addPhase(new Phase('Work', $duration, $reps, $pause, $cooldown, '#3b82f6'));
- $prog->save();
- return TimerProgram::load($prog->id);
+ $prog = Program::create(['name' => 'Single Phase']);
+ $prog->phases()->create([
+ 'label' => 'Work',
+ 'duration' => $duration,
+ 'repetitions' => $reps,
+ 'pause' => $pause,
+ 'cooldown' => $cooldown,
+ 'color' => '#3b82f6',
+ 'sort_order' => 0,
+ ]);
+ return $prog->load('phases');
}
-function twoPhaseProgram(): TimerProgram
+function twoPhaseProgram(): Program
{
- Storage::fake();
- $prog = TimerProgram::create('Two Phases');
- $prog->addPhase(new Phase('Work', 5, 2, 2, 3, '#3b82f6'));
- $prog->addPhase(new Phase('Rest', 8, 1, 0, 0, '#22c55e'));
- $prog->save();
- return TimerProgram::load($prog->id);
+ $prog = Program::create(['name' => 'Two Phases']);
+ $prog->phases()->create([
+ 'label' => 'Work',
+ 'duration' => 5,
+ 'repetitions' => 2,
+ 'pause' => 2,
+ 'cooldown' => 3,
+ 'color' => '#3b82f6',
+ 'sort_order' => 0,
+ ]);
+ $prog->phases()->create([
+ 'label' => 'Rest',
+ 'duration' => 8,
+ 'repetitions' => 1,
+ 'pause' => 0,
+ 'cooldown' => 0,
+ 'color' => '#22c55e',
+ 'sort_order' => 1,
+ ]);
+ return $prog->load('phases');
}
// ── idle state ────────────────────────────────────────────────────────────────
@@ -55,15 +73,14 @@ function twoPhaseProgram(): TimerProgram
});
test('start without load throws RuntimeException', function (): void {
- expect(fn () => freshRunner()->start())->toThrow(\RuntimeException::class);
+ expect(fn() => freshRunner()->start())->toThrow(\RuntimeException::class);
});
test('start with empty program throws RuntimeException', function (): void {
- Storage::fake();
- $prog = TimerProgram::create('Empty'); $prog->save();
+ $prog = Program::create(['name' => 'Empty']);
$runner = freshRunner();
- $runner->load(TimerProgram::load($prog->id));
- expect(fn () => $runner->start())->toThrow(\RuntimeException::class);
+ $runner->load($prog);
+ expect(fn() => $runner->start())->toThrow(\RuntimeException::class);
});
// ── idle → prepare ────────────────────────────────────────────────────────────
@@ -145,7 +162,7 @@ function twoPhaseProgram(): TimerProgram
$completed = false;
$runner = freshRunner();
- $prog = onePhaseProgram(duration: 3, reps: 1, cooldown: 2);
+ $prog = onePhaseProgram(duration: 3, reps: 1, cooldown: 2);
$runner->load($prog);
$runner->start();
skipPrepare($runner);
@@ -220,12 +237,11 @@ function twoPhaseProgram(): TimerProgram
// ── 10-phase limit ────────────────────────────────────────────────────────────
test('program rejects 11th phase', function (): void {
- Storage::fake();
- $prog = TimerProgram::create('Overflow');
+ $prog = Program::create(['name' => 'Overflow']);
for ($i = 0; $i < 10; $i++) {
- $prog->addPhase(new Phase("P{$i}", 5, 1, 0, 0, '#3b82f6'));
+ $prog->addPhase(['label' => "P{$i}", 'duration' => 5, 'repetitions' => 1, 'pause' => 0, 'cooldown' => 0, 'color' => '#3b82f6']);
}
- expect(fn () => $prog->addPhase(new Phase('P11', 5, 1, 0, 0, '#3b82f6')))
+ expect(fn() => $prog->addPhase(['label' => 'P11', 'duration' => 5, 'repetitions' => 1, 'pause' => 0, 'cooldown' => 0, 'color' => '#3b82f6']))
->toThrow(\OverflowException::class);
});
From 9d00d0cae81a00244a9a3cfb5a81fb1de0b1406e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Nikola=20Buci=C4=87?=
Date: Mon, 13 Apr 2026 16:45:50 +0200
Subject: [PATCH 10/10] fix: register Tests\ namespace in autoload-dev
Pest could not resolve Tests\TestCase because the namespace was missing
from composer.json autoload-dev mappings.
Co-Authored-By: Claude Sonnet 4.6
---
composer.json | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/composer.json b/composer.json
index eec89f2..e84e93a 100644
--- a/composer.json
+++ b/composer.json
@@ -33,7 +33,8 @@
},
"autoload-dev": {
"psr-4": {
- "App\\": "app/"
+ "App\\": "app/",
+ "Tests\\": "tests/"
}
},
"scripts": {