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
-
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 --}} - - - {{-- 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)
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 --}} +
+

Recent Runs

+ + Open Library +
-

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 --}} + + + {{-- 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)