From f02636074585a82bee370a1acaf28ffa68023e11 Mon Sep 17 00:00:00 2001 From: Nikola Bucic Date: Fri, 10 Apr 2026 16:20:29 +0200 Subject: [PATCH 1/4] Main * fix: resolve timer not starting and display bugs in TimerScreen * fix: use PHP 8.5 clone() syntax in resumeAs() * fix: Minor Timer Runner and Screen fixes * fix: timer iteration * feat: history log, ring UI, PREPARE state, and long-press pause --- .env.example | 2 + .github/workflows/bump-version.yml | 34 ++ .gitignore | 4 + app/Console/Commands/MigrateFromFiles.php | 168 +++++++ app/Enum/BeepLeadIn.php | 10 - app/Enum/StateMachine.php | 1 + app/Events/PhaseChanged.php | 2 +- app/Events/ProgramCompleted.php | 3 +- app/Http/Controllers/Controller.php | 8 - app/Jobs/WriteHistoryEntry.php | 37 ++ app/Livewire/Library.php | 32 +- app/Livewire/ProgramEditor.php | 216 ++++---- app/Livewire/Settings.php | 53 +- app/Livewire/TimerScreen.php | 250 ++++++---- app/Models/HistoryEntry.php | 26 + app/Models/Phase.php | 48 ++ app/Models/Program.php | 93 ++++ app/Models/Setting.php | 41 ++ app/Models/User.php | 32 -- app/Timer/AppSettings.php | 74 --- app/Timer/Phase.php | 53 -- app/Timer/TimerCursor.php | 230 ++++----- app/Timer/TimerProgram.php | 205 -------- app/Timer/TimerRunner.php | 338 +++++++------ composer.json | 12 +- composer.lock | 5 +- config/app.php | 15 +- config/auth.php | 117 ----- config/database.php | 184 ------- config/mail.php | 118 ----- config/nativephp.php | 7 +- config/queue.php | 93 +--- config/services.php | 38 -- database/.gitignore | 1 - database/factories/UserFactory.php | 45 -- .../0001_01_01_000001_create_cache_table.php | 35 -- .../0001_01_01_000002_create_jobs_table.php | 57 --- .../2026_04_09_071339_create_jobs_table.php | 32 ++ ...026_04_09_080000_create_programs_table.php | 25 + .../2026_04_09_080001_create_phases_table.php | 28 ++ ...2026_04_09_080002_create_history_table.php | 24 + ...026_04_09_080003_create_settings_table.php | 25 + ...26_04_09_122314_create_sessions_table.php} | 18 - database/seeders/DatabaseSeeder.php | 25 - nativephp.json | 6 + package-lock.json | 2 +- phpunit.xml | 3 - resources/js/app.js | 49 +- resources/js/audio.js | 23 +- resources/views/livewire/library.blade.php | 28 +- .../views/livewire/program-editor.blade.php | 337 +++++++------ resources/views/livewire/settings.blade.php | 7 +- .../views/livewire/timer-screen.blade.php | 460 ++++++++++++------ resources/views/welcome.blade.php | 225 --------- routes/console.php | 7 - tests/Feature/ExampleTest.php | 19 - tests/Pest.php | 3 +- tests/TestCase.php | 3 +- tests/Unit/ExampleTest.php | 16 - tests/Unit/Timer/BeepLogicTest.php | 331 ++++++++----- tests/Unit/Timer/DurationCalcTest.php | 30 +- tests/Unit/Timer/EndSoundTest.php | 52 +- tests/Unit/Timer/LifecycleTest.php | 55 +-- tests/Unit/Timer/SettingsTest.php | 93 +--- tests/Unit/Timer/TimerProgramTest.php | 125 ++--- tests/Unit/Timer/TimerRunnerTest.php | 113 +++-- version.json | 4 + 67 files changed, 2284 insertions(+), 2571 deletions(-) create mode 100644 .github/workflows/bump-version.yml create mode 100644 app/Console/Commands/MigrateFromFiles.php delete mode 100644 app/Http/Controllers/Controller.php create mode 100644 app/Jobs/WriteHistoryEntry.php create mode 100644 app/Models/HistoryEntry.php create mode 100644 app/Models/Phase.php create mode 100644 app/Models/Program.php create mode 100644 app/Models/Setting.php delete mode 100644 app/Models/User.php delete mode 100644 app/Timer/AppSettings.php delete mode 100644 app/Timer/Phase.php delete mode 100644 app/Timer/TimerProgram.php delete mode 100644 config/auth.php delete mode 100644 config/database.php delete mode 100644 config/mail.php delete mode 100644 config/services.php delete mode 100644 database/.gitignore delete mode 100644 database/factories/UserFactory.php delete mode 100644 database/migrations/0001_01_01_000001_create_cache_table.php delete mode 100644 database/migrations/0001_01_01_000002_create_jobs_table.php create mode 100644 database/migrations/2026_04_09_071339_create_jobs_table.php create mode 100644 database/migrations/2026_04_09_080000_create_programs_table.php create mode 100644 database/migrations/2026_04_09_080001_create_phases_table.php create mode 100644 database/migrations/2026_04_09_080002_create_history_table.php create mode 100644 database/migrations/2026_04_09_080003_create_settings_table.php rename database/migrations/{0001_01_01_000000_create_users_table.php => 2026_04_09_122314_create_sessions_table.php} (53%) delete mode 100644 database/seeders/DatabaseSeeder.php create mode 100644 nativephp.json delete mode 100644 resources/views/welcome.blade.php delete mode 100644 tests/Feature/ExampleTest.php delete mode 100644 tests/Unit/ExampleTest.php create mode 100644 version.json diff --git a/.env.example b/.env.example index c0660ea..229a426 100644 --- a/.env.example +++ b/.env.example @@ -63,3 +63,5 @@ AWS_BUCKET= AWS_USE_PATH_STYLE_ENDPOINT=false VITE_APP_NAME="${APP_NAME}" + +NATIVEPHP_APP_ID=com.nikolabucic.intervaltimer diff --git a/.github/workflows/bump-version.yml b/.github/workflows/bump-version.yml new file mode 100644 index 0000000..6ccda15 --- /dev/null +++ b/.github/workflows/bump-version.yml @@ -0,0 +1,34 @@ +name: Bump Version + +on: + push: + branches: [main] + +jobs: + bump: + if: "!contains(github.event.head_commit.message, '[skip ci]')" + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + + - name: Bump patch version and version_code + run: | + VERSION=$(jq -r '.version' version.json) + CODE=$(jq -r '.version_code' version.json) + IFS='.' read -r MAJOR MINOR PATCH <<< "$VERSION" + NEW_PATCH=$((PATCH + 1)) + NEW_VERSION="$MAJOR.$MINOR.$NEW_PATCH" + NEW_CODE=$((CODE + 1)) + jq --arg v "$NEW_VERSION" --argjson c "$NEW_CODE" \ + '.version = $v | .version_code = $c' version.json > tmp.json && mv tmp.json version.json + echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_ENV + + - name: Commit bumped version + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add version.json + git commit -m "chore: bump version to $NEW_VERSION [skip ci]" + git push diff --git a/.gitignore b/.gitignore index 0eeb578..944856f 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,7 @@ _ide_helper.php Homestead.json Homestead.yaml Thumbs.db +/nativephp +/database/database.sqlite +/app-release-signed.apk* +/my-release-key* diff --git a/app/Console/Commands/MigrateFromFiles.php b/app/Console/Commands/MigrateFromFiles.php new file mode 100644 index 0000000..e07d7ee --- /dev/null +++ b/app/Console/Commands/MigrateFromFiles.php @@ -0,0 +1,168 @@ +option('force') && !$this->confirm('This will import JSON file data into the database. Continue?')) { + return self::FAILURE; + } + + $this->migrateSettings(); + $this->migratePrograms(); + $this->migrateHistory(); + + $this->info('Done.'); + return self::SUCCESS; + } + + private function migrateSettings(): void + { + $path = 'settings.json'; + + if (!Storage::exists($path)) { + $this->line(' settings.json not found — skipping (defaults will be used on first load).'); + return; + } + + try { + $data = json_decode(Storage::get($path), true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + $this->warn(" Could not parse settings.json: {$e->getMessage()}"); + return; + } + + $settings = Setting::first() ?? new Setting(); + $settings->default_beep_lead_in = BeepLeadIn::from((int) ($data['default_beep_lead_in'] ?? 3)); + $settings->default_end_sound = $data['default_end_sound'] ?? 'triple'; + $settings->sound_mode = $data['sound_mode'] ?? 'beep'; + $settings->volume = (float) ($data['volume'] ?? 0.8); + $settings->keep_screen_on = (bool) ($data['keep_screen_on'] ?? true); + $settings->save(); + + $this->info(' Settings imported.'); + } + + private function migratePrograms(): void + { + $files = collect(Storage::files('programs')) + ->filter(fn(string $p) => str_ends_with($p, '.json')); + + if ($files->isEmpty()) { + $this->line(' No program files found — skipping.'); + return; + } + + $imported = 0; + $skipped = 0; + + foreach ($files as $file) { + try { + $data = json_decode(Storage::get($file), true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + $this->warn(" Skipping $file: {$e->getMessage()}"); + $skipped++; + continue; + } + + $id = $data['id'] ?? null; + if (!$id) { + $this->warn(" Skipping $file: missing id."); + $skipped++; + continue; + } + + if (Program::where('id', $id)->exists()) { + $this->line(" Skipping $id — already in database."); + $skipped++; + continue; + } + + $program = Program::create([ + 'id' => $id, + 'name' => $data['name'], + 'beep_lead_in' => BeepLeadIn::from((int) ($data['beep_lead_in'] ?? 3)), + 'end_sound' => $data['end_sound'] ?? 'triple', + 'last_used_at' => $data['last_used_at'] ?? null, +// 'created_at' => $data['created_at'] ?? now(), +// 'updated_at' => $data['created_at'] ?? now(), + ]); + + foreach (($data['phases'] ?? []) as $index => $phaseData) { + $program->phases()->create([ + 'sort_order' => $index, + 'label' => $phaseData['label'], + 'duration' => (int) $phaseData['duration'], + 'repetitions' => (int) ($phaseData['repetitions'] ?? 1), + 'pause' => (int) ($phaseData['pause'] ?? 0), + 'cooldown' => (int) ($phaseData['cooldown'] ?? 0), + 'color' => $phaseData['color'] ?? '#3b82f6', + ]); + } + + $imported++; + } + + $this->info(" Programs: $imported imported, $skipped skipped."); + } + + private function migrateHistory(): void + { + $path = 'history.json'; + + if (!Storage::exists($path)) { + $this->line(' history.json not found — skipping.'); + return; + } + + try { + $entries = json_decode(Storage::get($path), true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + $this->warn(" Could not parse history.json: {$e->getMessage()}"); + return; + } + + if (HistoryEntry::exists()) { + $this->line(' History table already has rows — skipping.'); + return; + } + + $imported = 0; + foreach ($entries as $entry) { + HistoryEntry::create([ + 'program_id' => $entry['program_id'] ?? null, + 'program_name' => $entry['program_name'], + 'completed_at' => $entry['completed_at'], + 'total_duration' => (int) $entry['total_duration'], + ]); + $imported++; + } + + $this->info(" History: $imported entries imported."); + } +} diff --git a/app/Enum/BeepLeadIn.php b/app/Enum/BeepLeadIn.php index a5a9236..71124ed 100644 --- a/app/Enum/BeepLeadIn.php +++ b/app/Enum/BeepLeadIn.php @@ -2,18 +2,8 @@ namespace App\Enum; -use Override; - enum BeepLeadIn: int { case Five = 5; case Three = 3; - - public static function fromNumberToEnum(int $value): BeepLeadIn - { - return match ($value) { - 3 => self::Three, - default => self::Five, - }; - } } diff --git a/app/Enum/StateMachine.php b/app/Enum/StateMachine.php index 1bd9ba1..d9f9bb8 100644 --- a/app/Enum/StateMachine.php +++ b/app/Enum/StateMachine.php @@ -5,6 +5,7 @@ enum StateMachine: string { case idle = 'IDLE'; +case prepare = 'PREPARE'; case running = 'RUNNING'; case paused = 'PAUSED'; case pause = 'PAUSE'; diff --git a/app/Events/PhaseChanged.php b/app/Events/PhaseChanged.php index 319a8ae..11581a1 100644 --- a/app/Events/PhaseChanged.php +++ b/app/Events/PhaseChanged.php @@ -4,7 +4,7 @@ namespace App\Events; -use App\Timer\Phase; +use App\Models\Phase; use Attribute; use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Foundation\Events\Dispatchable; diff --git a/app/Events/ProgramCompleted.php b/app/Events/ProgramCompleted.php index b6c31a2..ea3e2e2 100644 --- a/app/Events/ProgramCompleted.php +++ b/app/Events/ProgramCompleted.php @@ -4,11 +4,12 @@ namespace App\Events; +use Attribute; use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; -#[\Attribute] +#[Attribute] class ProgramCompleted { use Dispatchable, InteractsWithSockets, SerializesModels; diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php deleted file mode 100644 index 8677cd5..0000000 --- a/app/Http/Controllers/Controller.php +++ /dev/null @@ -1,8 +0,0 @@ - $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 82e6544..620f5f2 100644 --- a/app/Livewire/Library.php +++ b/app/Livewire/Library.php @@ -4,7 +4,9 @@ namespace App\Livewire; -use App\Timer\TimerProgram; +use App\Models\Program; +use App\Models\Setting; +use Illuminate\View\View; use Livewire\Attributes\Layout; use Livewire\Attributes\Title; use Livewire\Component; @@ -16,7 +18,6 @@ class Library extends Component public string $newName = ''; public bool $showCreate = false; - /** @var TimerProgram[] */ public array $programs = []; public function mount(): void @@ -26,12 +27,15 @@ public function mount(): void public function loadPrograms(): void { - $this->programs = 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; } @@ -45,27 +49,27 @@ public function createProgram(): void { $this->validate(['newName' => 'required|string|max:60']); - $program = TimerProgram::create(trim($this->newName)); - $program->save(); + $settings = Setting::current(); + + $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 = ''; - $this->redirect("/programs/{$program->id}/edit", navigate: true); + $this->redirect("/programs/$program->id/edit"); } public function deleteProgram(string $id): void { - try { - $program = TimerProgram::load($id); - $program->delete(); - } catch (\RuntimeException) { - // Already gone — ignore. - } + Program::find($id)?->delete(); $this->loadPrograms(); } - public function render(): \Illuminate\View\View + public function render(): View { return view('livewire.library'); } diff --git a/app/Livewire/ProgramEditor.php b/app/Livewire/ProgramEditor.php index 816a3f8..76559d8 100644 --- a/app/Livewire/ProgramEditor.php +++ b/app/Livewire/ProgramEditor.php @@ -4,9 +4,11 @@ namespace App\Livewire; -use App\Timer\AppSettings; -use App\Timer\Phase; -use App\Timer\TimerProgram; +use App\Enum\BeepLeadIn; +use App\Models\Program; +use App\Models\Setting; +use Illuminate\Validation\Rules\Enum; +use Illuminate\View\View; use Livewire\Attributes\Layout; use Livewire\Attributes\Title; use Livewire\Component; @@ -16,26 +18,26 @@ class ProgramEditor extends Component { // ── Program fields ──────────────────────────────────────────────────── - public string $programId = ''; - public string $name = ''; - public int $beepLeadIn = 3; - public string $endSound = 'triple'; + public string $programId = ''; + public string $name = ''; + public BeepLeadIn $beepLeadIn = BeepLeadIn::Three; + public string $endSound = 'triple'; // ── Phase form fields (for the active add/edit panel) ───────────────── - public ?int $editingPhaseIndex = null; // null = adding new - public string $phaseLabel = ''; - public int $phaseDuration = 30; - public int $phaseReps = 1; - public int $phasePause = 0; - public int $phaseCooldown = 0; - public string $phaseColor = '#3b82f6'; + public ?int $editingPhaseIndex = null; // null = adding new + public string $phaseLabel = ''; + public int $phaseDuration = 30; + public int $phaseReps = 1; + public int $phasePause = 0; + public int $phaseCooldown = 0; + public string $phaseColor = '#3b82f6'; - public bool $showPhaseForm = false; + 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 = []; - // Colour palette for quick-pick + // Color palette for quick-pick public array $palette = [ '#3b82f6', // blue '#22c55e', // green @@ -50,46 +52,76 @@ class ProgramEditor extends Component 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; + $settings = Setting::current(); + $this->beepLeadIn = $settings->default_beep_lead_in; + $this->endSound = $settings->default_end_sound; } 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, - ); + $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 openAddPhase(): void + public function cancelPhaseForm(): void { - if (count($this->phases) >= 10) { - return; - } + $this->showPhaseForm = false; $this->resetPhaseForm(); - $this->editingPhaseIndex = null; - $this->showPhaseForm = true; + } + + public function deletePhase(int $index): void + { + array_splice($this->phases, $index, 1); } public function editPhase(int $index): void { $p = $this->phases[$index]; - $this->phaseLabel = $p['label']; - $this->phaseDuration = $p['duration']; - $this->phaseReps = $p['repetitions']; - $this->phasePause = $p['pause']; - $this->phaseCooldown = $p['cooldown']; - $this->phaseColor = $p['color']; + $this->phaseLabel = $p['label']; + $this->phaseDuration = $p['duration']; + $this->phaseReps = $p['repetitions']; + $this->phasePause = $p['pause']; + $this->phaseCooldown = $p['cooldown']; + $this->phaseColor = $p['color']; $this->editingPhaseIndex = $index; - $this->showPhaseForm = true; + $this->showPhaseForm = true; + } + + public function movePhaseDown(int $index): void + { + if ($index >= count($this->phases) - 1) return; + [$this->phases[$index], $this->phases[$index + 1]] = + [$this->phases[$index + 1], $this->phases[$index]]; + } + + public function movePhaseUp(int $index): void + { + if ($index <= 0) return; + [$this->phases[$index - 1], $this->phases[$index]] = + [$this->phases[$index], $this->phases[$index - 1]]; + } + + public function openAddPhase(): void + { + if (count($this->phases) >= 10) { + return; + } + $this->resetPhaseForm(); + $this->editingPhaseIndex = null; + $this->showPhaseForm = true; } public function savePhase(): void @@ -102,6 +134,10 @@ public function savePhase(): void 'phaseCooldown' => 'required|integer|min:0|max:3600', ]); + if ($this->phaseReps === 1) { + $this->phasePause = 0; + } + $phaseArray = [ 'label' => trim($this->phaseLabel), 'duration' => $this->phaseDuration, @@ -121,63 +157,64 @@ public function savePhase(): void $this->resetPhaseForm(); } - public function deletePhase(int $index): void - { - array_splice($this->phases, $index, 1); - } - - public function movePhaseUp(int $index): void - { - if ($index <= 0) return; - [$this->phases[$index - 1], $this->phases[$index]] = - [$this->phases[$index], $this->phases[$index - 1]]; - } - - public function movePhaseDown(int $index): void - { - if ($index >= count($this->phases) - 1) return; - [$this->phases[$index], $this->phases[$index + 1]] = - [$this->phases[$index + 1], $this->phases[$index]]; - } - - public function cancelPhaseForm(): void + public function savePhaseAndAddNew(): void { - $this->showPhaseForm = false; - $this->resetPhaseForm(); + $this->savePhase(); + $this->editingPhaseIndex = null; + $this->showPhaseForm = true; } - // ── Program save/delete ─────────────────────────────────────────────── + // ── Program save ────────────────────────────────────────────────────── public function saveProgram(): void { $this->validate([ 'name' => 'required|string|max:60', - 'beepLeadIn' => 'required|in:3,5', + 'beepLeadIn' => ['required', new Enum(BeepLeadIn::class)], 'endSound' => 'required|in:triple,chime', ]); if ($this->programId === '') { - $program = TimerProgram::create($this->name); - $this->programId = $program->id; + $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->name = $this->name; + $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), - $this->phases, - ); - + $program->beep_lead_in = $this->beepLeadIn; + $program->end_sound = $this->endSound; $program->save(); - $this->redirect("/timer/{$program->id}", navigate: true); + $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( @@ -191,13 +228,24 @@ static function (int $carry, array $p): int { ); } - public function formattedDuration(): string + /** + * 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 { - $total = $this->totalDuration(); - return sprintf('%d:%02d', intdiv($total, 60), $total % 60); + $count = count($this->phases); + if ($count === 0) { + return true; + } + if ($this->editingPhaseIndex === null) { + return true; + } + return $this->editingPhaseIndex === $count - 1; } - public function render(): \Illuminate\View\View + public function render(): View { return view('livewire.program-editor'); } @@ -208,7 +256,7 @@ private function resetPhaseForm(): void { $this->phaseLabel = ''; $this->phaseDuration = 30; - $this->phaseReps = 1; + $this->phaseReps = 3; $this->phasePause = 0; $this->phaseCooldown = 0; $this->phaseColor = '#3b82f6'; diff --git a/app/Livewire/Settings.php b/app/Livewire/Settings.php index a74f800..305b63c 100644 --- a/app/Livewire/Settings.php +++ b/app/Livewire/Settings.php @@ -4,7 +4,10 @@ namespace App\Livewire; -use App\Timer\AppSettings; +use App\Enum\BeepLeadIn; +use App\Models\Setting; +use Illuminate\Validation\Rules\Enum; +use Illuminate\View\View; use Livewire\Attributes\Layout; use Livewire\Attributes\Title; use Livewire\Component; @@ -13,50 +16,52 @@ #[Title('Settings — Interval Timer')] class Settings extends Component { - public int $defaultBeepLeadIn = 3; - public string $defaultEndSound = 'triple'; - public string $soundMode = 'beep'; - public float $volume = 0.8; - public bool $keepScreenOn = true; + public BeepLeadIn $defaultBeepLeadIn = BeepLeadIn::Three; + public string $defaultEndSound = 'triple'; + public string $soundMode = 'beep'; + public float $volume = 0.8; + public bool $keepScreenOn = true; - public bool $saved = false; + public bool $saved = false; public function mount(): void { - $settings = AppSettings::load(); + $settings = Setting::current(); - $this->defaultBeepLeadIn = $settings->defaultBeepLeadIn; - $this->defaultEndSound = $settings->defaultEndSound; - $this->soundMode = $settings->soundMode; + $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->keepScreenOn; + $this->keepScreenOn = $settings->keep_screen_on; + } + + public function render(): View + { + return view('livewire.settings'); } public function save(): void { $this->validate([ - 'defaultBeepLeadIn' => 'required|in:3,5', + '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', ]); - $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(); - $this->saved = true; - } + $this->dispatch('settingsLoaded', soundMode: $this->soundMode, volume: $this->volume, program: null); - public function render(): \Illuminate\View\View - { - return view('livewire.settings'); + $this->saved = true; } } diff --git a/app/Livewire/TimerScreen.php b/app/Livewire/TimerScreen.php index abe170a..abb7958 100644 --- a/app/Livewire/TimerScreen.php +++ b/app/Livewire/TimerScreen.php @@ -4,12 +4,16 @@ namespace App\Livewire; -use App\Timer\AppSettings; +use App\Enum\StateMachine; +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\View\View; use Livewire\Attributes\Layout; -use Livewire\Attributes\On; use Livewire\Attributes\Title; use Livewire\Component; @@ -19,74 +23,114 @@ class TimerScreen extends Component { // ── Identifiers ─────────────────────────────────────────────────────── public ?string $programId = null; - public string $programName = ''; + public string $programName = ''; - // ── Cursor snapshot (serialisable scalars for Livewire) ────────────── - public string $state = 'idle'; // mirrors TimerCursor->state - public int $remaining = 0; - public int $totalRemaining = 0; - public int $phaseIndex = 0; - public int $repIndex = 0; + // ── Cursor snapshot (serializable scalars for Livewire) ────────────── + public StateMachine $state = StateMachine::idle; + public int $remaining = 0; + public int $totalRemaining = 0; + public int $phaseIndex = 0; + public int $repIndex = 0; // ── Phase display ───────────────────────────────────────────────────── public string $phaseLabel = ''; public string $phaseColor = '#3b82f6'; - public int $phaseReps = 1; + 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'; + 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 = AppSettings::load(); - $this->soundMode = $settings->soundMode; + $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 = TimerProgram::load($id); - - $runner->load($program); + $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); - // Push settings to JS audio layer - $this->dispatch('settingsLoaded', soundMode: $this->soundMode, volume: $this->volume); + $this->dispatch('topbar-title', title: $program->name); + $this->dispatch('settingsLoaded', soundMode: $this->soundMode, volume: $this->volume, program: $program); } - // ── Timer controls ──────────────────────────────────────────────────── + 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 start(): void + public function pause(): void + { + $runner = app(TimerRunner::class); + $this->rehydrateRunner($runner); + $runner->pause(); + $this->syncCursor($runner->cursor(), Program::with('phases')->findOrFail($this->programId)); + } + + public function resume(): void + { + $runner = app(TimerRunner::class); + $this->rehydrateRunner($runner); + $runner->resume(); + $this->syncCursor($runner->cursor(), Program::with('phases')->findOrFail($this->programId)); + } + + public function restart(): void { $runner = app(TimerRunner::class); - $program = TimerProgram::load($this->programId); + $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')); + } - $runner->onBeep(function (string $reason) use ($program): void { - $this->handleBeep($reason, $program); - }); - $runner->onPauseBeep(function (): void { - $this->dispatch('playPauseBeep'); - }); + public function start(): void + { + $runner = app(TimerRunner::class); + $program = Program::with('phases')->findOrFail($this->programId); + $this->programTotalDuration = $program->totalDuration(); + $runner->load($program); $runner->start(); $this->syncCursor($runner->cursor(), $program); - // EDGE top bar → program name $this->dispatch('topbar-title', title: $this->programName); } @@ -94,56 +138,37 @@ public function start(): void public function tick(): void { $runner = app(TimerRunner::class); + $this->rehydrateRunner($runner); - if (! $runner->cursor()->isActive()) { + if (!$runner->cursor()->isActive()) { return; } $runner->tick(); $cursor = $runner->cursor(); - $program = TimerProgram::load($this->programId); + $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')); } } - public function pause(): void + public function requestSettings(): void { - $runner = app(TimerRunner::class); - $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 resume(): void + public function render(): View { - $runner = app(TimerRunner::class); - $runner->resume(); - $this->syncCursor($runner->cursor(), TimerProgram::load($this->programId)); - } - - public function discard(): void - { - app(TimerRunner::class)->discard(); - $this->state = 'idle'; - $this->remaining = 0; - $this->totalRemaining = 0; - $this->dispatch('topbar-title', title: config('app.name')); - } - - 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')); + return view('livewire.timer-screen'); } - // ── Computed display helpers ────────────────────────────────────────── + // ── Display helpers ─────────────────────────────────────────────────── public function formattedRemaining(): string { @@ -157,33 +182,67 @@ public function formattedTotal(): string return sprintf('%d:%02d', intdiv($s, 60), $s % 60); } + 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); + } + public function segmentLabel(): string { return match ($this->state) { - 'pause' => 'Pause', - 'cooldown' => 'Cooldown', - 'paused' => 'Paused', - 'completed' => 'Complete!', - default => $this->phaseLabel, + StateMachine::prepare => 'Get Ready', + StateMachine::pause => 'Pause', + StateMachine::cooldown => 'Cooldown', + StateMachine::paused => 'Paused', + StateMachine::completed => 'Complete!', + default => $this->phaseLabel, }; } - public function repLabel(): string + // ── Internals ───────────────────────────────────────────────────────── + + private function rehydrateRunner(TimerRunner $runner): void { - if (in_array($this->state, ['pause', 'cooldown', 'paused', 'completed', 'idle'], true)) { - return ''; + if (!$this->programId) { + return; } - return sprintf('%d / %d', $this->repIndex + 1, $this->phaseReps); + + $program = Program::with('phases')->findOrFail($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'); + }); } - public function render(): \Illuminate\View\View + private function handleBeep(string $reason): void { - return view('livewire.timer-screen'); + $this->countdownLabel = match ($reason) { + 'prepare', 'countdown' => (string) $this->remaining, + 'rep_end' => 'Done', + 'pause_end' => 'Go', + 'cooldown_end' => 'Next', + default => '', + }; + $this->dispatch('playBeep', reason: $reason); } - // ── Internals ───────────────────────────────────────────────────────── - - private function syncCursor(TimerCursor $cursor, TimerProgram $program): void + private function syncCursor(TimerCursor $cursor, Program $program): void { $this->state = $cursor->state; $this->remaining = $cursor->remaining; @@ -191,24 +250,41 @@ private function syncCursor(TimerCursor $cursor, TimerProgram $program): void $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 (isset($program->phases[$cursor->phaseIndex])) { - $phase = $program->phases[$cursor->phaseIndex]; + $phase = $program->phases[$cursor->phaseIndex]; $this->phaseLabel = $phase->label; $this->phaseColor = $phase->color; $this->phaseReps = $phase->repetitions; } } - private function handleBeep(string $reason, TimerProgram $program): void + private function loadHistory(): void { - // Determine the countdown label for voice mode - $this->countdownLabel = match ($reason) { - 'countdown' => $this->remaining . ' seconds', - 'rep_end' => 'Done', - 'pause_end' => 'Go', - 'cooldown_end' => 'Next', - default => '', - }; - $this->dispatch('playBeep', reason: $reason); + $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/Models/HistoryEntry.php b/app/Models/HistoryEntry.php new file mode 100644 index 0000000..3afd6c3 --- /dev/null +++ b/app/Models/HistoryEntry.php @@ -0,0 +1,26 @@ + 'datetime', + 'total_duration' => 'integer', + ]; +} diff --git a/app/Models/Phase.php b/app/Models/Phase.php new file mode 100644 index 0000000..2ef7c85 --- /dev/null +++ b/app/Models/Phase.php @@ -0,0 +1,48 @@ + 'integer', + 'duration' => 'integer', + 'repetitions' => 'integer', + 'pause' => 'integer', + 'cooldown' => 'integer', + ]; + + protected static function boot(): void + { + parent::boot(); + + static::saving(static function (Phase $phase): void { + if ($phase->repetitions < 1 || $phase->repetitions > 50) { + throw new \RangeException('Repetitions must be between 1 and 50.'); + } + }); + } + + public function program(): BelongsTo + { + return $this->belongsTo(Program::class); + } +} diff --git a/app/Models/Program.php b/app/Models/Program.php new file mode 100644 index 0000000..50e4253 --- /dev/null +++ b/app/Models/Program.php @@ -0,0 +1,93 @@ + BeepLeadIn::class, + 'last_used_at' => 'datetime', + ]; + + protected $appends = ['total_duration']; + + protected $attributes = [ + 'beep_lead_in' => BeepLeadIn::Three, + ]; + + public function __construct(array $attributes = []) + { + $this->attributes['end_sound'] = Setting::current()->default_end_sound; + parent::__construct($attributes); + } + + public function phases(): HasMany + { + return $this->hasMany(Phase::class)->orderBy('sort_order'); + } + + public function addPhase(array $attributes): Phase + { + if ($this->phases()->count() >= 10) { + throw new \OverflowException('A program can have at most 10 phases.'); + } + + $attributes['sort_order'] ??= $this->phases()->count(); + + return $this->phases()->create($attributes); + } + + /** Also stamps last_used_at alongside updated_at. */ + public function touch($attribute = null): bool + { + $this->last_used_at = now(); + return parent::touch($attribute); + } + + #[NoDiscard] + public function totalDuration(): int + { + $phases = $this->phases->all(); + + if (empty($phases)) { + return 0; + } + + $total = array_reduce( + $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 $total - (array_last($phases)?->cooldown ?? 0); + } + + public function getTotalDurationAttribute(): int + { + return $this->totalDuration(); + } + + public function formattedDuration(): string + { + $total = $this->totalDuration(); + return sprintf('%d:%02d', intdiv($total, 60), $total % 60); + } +} diff --git a/app/Models/Setting.php b/app/Models/Setting.php new file mode 100644 index 0000000..f62fee6 --- /dev/null +++ b/app/Models/Setting.php @@ -0,0 +1,41 @@ + BeepLeadIn::class, + 'keep_screen_on' => 'boolean', + 'volume' => 'float', + ]; + + /** Returns the single settings row, creating it with defaults on first run. */ + public static function current(): self + { + return self::first() ?? self::create([ + 'default_beep_lead_in' => BeepLeadIn::Three->value, + 'default_end_sound' => 'triple', + 'sound_mode' => 'beep', + 'volume' => 0.8, + 'keep_screen_on' => true, + ]); + } +} diff --git a/app/Models/User.php b/app/Models/User.php deleted file mode 100644 index f6ba1d2..0000000 --- a/app/Models/User.php +++ /dev/null @@ -1,32 +0,0 @@ - */ - use HasFactory, Notifiable; - - /** - * Get the attributes that should be cast. - * - * @return array - */ - protected function casts(): array - { - return [ - 'email_verified_at' => 'datetime', - 'password' => 'hashed', - ]; - } -} diff --git a/app/Timer/AppSettings.php b/app/Timer/AppSettings.php deleted file mode 100644 index 75f118b..0000000 --- a/app/Timer/AppSettings.php +++ /dev/null @@ -1,74 +0,0 @@ -defaultBeepLeadIn = BeepLeadIn::fromNumberToEnum((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/Phase.php b/app/Timer/Phase.php deleted file mode 100644 index 75403d9..0000000 --- a/app/Timer/Phase.php +++ /dev/null @@ -1,53 +0,0 @@ - 50) { - throw new RangeException('Repetitions must be between 1 and 50.'); - } - if ($duration < 1) { - throw new RangeException('Phase duration must be at least 1 second.'); - } - } - - public function toArray(): array - { - return [ - 'label' => $this->label, - 'duration' => $this->duration, - 'repetitions' => $this->repetitions, - 'pause' => $this->pause, - 'cooldown' => $this->cooldown, - 'color' => $this->color, - ]; - } - - public static function fromArray(array $data): self - { - return new self( - label: $data['label'], - duration: (int) $data['duration'], - repetitions: (int) ($data['repetitions'] ?? 1), - pause: (int) ($data['pause'] ?? 0), - cooldown: (int) ($data['cooldown'] ?? 0), - color: $data['color'] ?? '#3b82f6', - ); - } -} diff --git a/app/Timer/TimerCursor.php b/app/Timer/TimerCursor.php index fb6106a..0a08451 100644 --- a/app/Timer/TimerCursor.php +++ b/app/Timer/TimerCursor.php @@ -10,15 +10,11 @@ * Immutable snapshot of where the timer is at any moment. * * PHP 8.5 FEATURES: - * • readonly class — all properties set once at construction. - * • clone with syntax — each advance method shows the PHP 8.5 form in a + * • readonly class — all properties set once at construction. + * • clone with syntax — each advance method shows the PHP 8.5 form in a * comment; the working fallback uses new self() which * is 100% equivalent for readonly classes. - * • StateMachine enum — string-backed enum replaces the string literal. - * - * NOTE: `clone $obj with { prop: value }` requires the PHP 8.5 runtime bundled - * by NativePHP. Host tooling may run PHP 8.4; these new self() calls are a - * drop-in equivalent that work everywhere. + * • StateMachine enum — string-backed enum replaces the string literal. */ readonly class TimerCursor { @@ -28,152 +24,166 @@ public function __construct( public StateMachine $state, // idle | running | paused | pause | cooldown | completed public int $remaining, // seconds left in current segment public int $totalRemaining, // seconds left in the entire program - ) {} - - // ── Static factory ──────────────────────────────────────────────────────── + ) + { + } public static function idle(): self { return new self( - phaseIndex: 0, - repIndex: 0, - state: StateMachine::idle, - remaining: 0, + phaseIndex: 0, + repIndex: 0, + state: StateMachine::idle, + remaining: 0, totalRemaining: 0, ); } - // ── Segment-level advances ──────────────────────────────────────────────── + /** 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, + ] + ); + } - /** Tick one second off the current segment and total remaining. */ - public function tick(): self + /** 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 { 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' => 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, + ] ); } - /** Move into the cooldown state after the final rep of a phase. */ - public function enterCooldown(int $cooldownDuration, int $totalRemaining): self + /** Enter the pre-start countdown before the first rep. */ + public function enterPrepare(int $seconds): 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' => 0, + 'repIndex' => 0, + 'state' => StateMachine::prepare, + 'remaining' => $seconds, + 'totalRemaining' => $this->totalRemaining, + ] ); } - /** Advance to the next repetition of the same phase. */ - public function nextRep(int $repDuration, int $totalRemaining): self + /** True whenever the timer is actively counting down (not user-paused, not idle). */ + public function isActive(): bool { - /* 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 in_array($this->state, [ + StateMachine::prepare, + StateMachine::running, + StateMachine::pause, + StateMachine::cooldown, + ], true); + } + + public function isCompleted(): bool + { + return $this->state === StateMachine::completed; + } + + public function isIdle(): bool + { + return $this->state === StateMachine::idle; + } + + public function isInCooldown(): bool + { + return $this->state === StateMachine::cooldown; + } + + public function isInPause(): bool + { + return $this->state === StateMachine::pause; + } + + // ── Predicates ──────────────────────────────────────────────────────────── + + public function isPaused(): bool + { + return $this->state === StateMachine::paused; } /** 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, + ] ); } - /** User pressed pause (or phone call received). */ - public function pause(): self + /** Advance to the next repetition of the same phase. */ + public function nextRep(int $repDuration, int $totalRemaining): 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 + 1, + 'state' => StateMachine::running, + 'remaining' => $repDuration, + 'totalRemaining' => $totalRemaining, + ] ); } - /** Resume into a named sub-state (the state that was active pre-pause). */ - public function resumeAs(StateMachine $state): self + /** User pressed pause (or phone call received). */ + public function pause(): 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, [ + 'phaseIndex' => $this->phaseIndex, + 'repIndex' => $this->repIndex, + 'state' => StateMachine::paused, + 'remaining' => $this->remaining, + 'totalRemaining' => $this->totalRemaining, + ] ); } - /** Mark the program as completed. */ - public function complete(): 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: 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, ['state' => $state]); } - // ── Predicates ──────────────────────────────────────────────────────────── - - /** True whenever the timer is actively counting down (not user-paused, not idle). */ - public function isActive(): bool + /** Tick one second off the current segment and the total remaining. */ + public function tick(): self { - return in_array($this->state, [StateMachine::running, StateMachine::pause, StateMachine::cooldown], true); + return clone($this, [ + 'phaseIndex' => $this->phaseIndex, + 'repIndex' => $this->repIndex, + 'state' => $this->state, + 'remaining' => max(0, $this->remaining - 1), + 'totalRemaining' => max(0, $this->totalRemaining - 1), + ] + ); } - - public function isIdle(): bool { return $this->state === StateMachine::idle; } - public function isRunning(): bool { return $this->state === StateMachine::running; } - public function isPaused(): bool { return $this->state === StateMachine::paused; } - public function isInPause(): bool { return $this->state === StateMachine::pause; } - public function isInCooldown(): bool { return $this->state === StateMachine::cooldown; } - public function isCompleted(): bool { return $this->state === StateMachine::completed; } } diff --git a/app/Timer/TimerProgram.php b/app/Timer/TimerProgram.php deleted file mode 100644 index 0f6dd22..0000000 --- a/app/Timer/TimerProgram.php +++ /dev/null @@ -1,205 +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, 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::fromNumberToEnum($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); - } - - /** - * @throws JsonException - */ - public function touch(): void - { - $this->lastUsedAt = now()->toISOString(); - $this->save(); - } - - /** Save the program to its JSON file. - * @throws JsonException - */ - public function save(): void - { - Storage::put( - "programs/$this->id.json", - json_encode($this->toArray(), JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR), - ); - } - - 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 4ba913d..97506f2 100644 --- a/app/Timer/TimerRunner.php +++ b/app/Timer/TimerRunner.php @@ -7,8 +7,10 @@ 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 JsonException; use RuntimeException; /** @@ -18,9 +20,9 @@ * $this->app->singleton(TimerRunner::class); * * State machine transitions: - * idle → running → pause → running (between reps) + * idle → running → pause → running (between reps) * ↓ - * cooldown → running (after final rep, before next phase) + * cooldown → running (after final rep, before next phase) * ↓ * completed * @@ -28,31 +30,34 @@ * 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 segment < lead-in, countdown starts from second 1. + * If a segment < lead-in, the countdown starts from second 1. */ class TimerRunner { - private ?TimerProgram $program = null; - private TimerCursor $cursor; + private const PREPARE_SECONDS = 5; + + private ?Program $program = null; + 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,54 +65,143 @@ 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(); + } + + /** 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; + } // ── 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). 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)(); + } + } - /** Override the clock for tests. fn(): int (seconds since epoch) */ - public function setClock(\Closure $fn): void { $this->clockFn = $fn; } + public function program(): ?Program + { + 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; - $this->cursor = TimerCursor::idle(); + if (!$this->cursor->isPaused()) { + return; + } + + $this->cursor = $this->cursor->resumeAs($this->stateBeforePause); + $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(); - if (! $this->isIdle()) { + if (!$this->isIdle()) { throw new RuntimeException( "Cannot start: expected state 'idle', got '{$this->cursor->state->value}'.", ); } - $phase = $this->currentPhase(); + $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(); $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(); } /** @@ -116,12 +210,24 @@ public function start(): void */ 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'); } @@ -135,38 +241,51 @@ 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 beep_lead_in->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->beep_lead_in->value; + $segmentTotal = $this->segmentDurationForCursor($cursor); + $effectiveLead = ($segmentTotal < $leadIn) ? max(1, $segmentTotal - 1) : $leadIn; + + return $cursor->remaining <= $effectiveLead && $cursor->remaining > 0; } - /** Resume from user-paused state, restoring the pre-pause sub-state. */ - 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::prepare => self::PREPARE_SECONDS, + 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. @@ -174,8 +293,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 ────────────────────────────── @@ -195,7 +314,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), @@ -204,13 +323,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), @@ -223,6 +342,8 @@ private function advance(TimerCursor $cursor): void $this->advanceAfterCooldown($cursor, $isLastPhase); } + // ── Helpers ─────────────────────────────────────────────────────────────── + private function advanceToNextRep(TimerCursor $cursor, Phase $phase): void { $this->cursor = $cursor->nextRep( @@ -240,7 +361,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, @@ -252,95 +373,26 @@ private function advanceAfterCooldown(TimerCursor $cursor, bool $isLastPhase): v $this->notifyTick(); } - /** @throws JsonException */ private function complete(): void { $this->cursor = $this->cursor->complete(); + $totalDuration = $this->program->totalDuration(); + ProgramCompleted::dispatch( $this->program->id, - $this->program->endSound, - $this->program->totalDuration(), + $this->program->end_sound, + $totalDuration, + ); + + WriteHistoryEntry::dispatch( + $this->program->id, + $this->program->name, + now()->toISOString(), + $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/composer.json b/composer.json index bfb0baa..eec89f2 100644 --- a/composer.json +++ b/composer.json @@ -3,10 +3,14 @@ "name": "laravel/laravel", "type": "project", "description": "The skeleton application for the Laravel framework.", - "keywords": ["laravel", "framework"], + "keywords": [ + "laravel", + "framework" + ], "license": "MIT", "require": { "php": "^8.5", + "ext-zip": "*", "laravel/framework": "^13.0", "laravel/tinker": "^3.0", "livewire/livewire": "^3.0", @@ -24,14 +28,12 @@ }, "autoload": { "psr-4": { - "App\\": "app/", - "Database\\Factories\\": "database/factories/", - "Database\\Seeders\\": "database/seeders/" + "App\\": "app/" } }, "autoload-dev": { "psr-4": { - "Tests\\": "tests/" + "App\\": "app/" } }, "scripts": { diff --git a/composer.lock b/composer.lock index b46beeb..def5b74 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "2a7a1e13912c323b179e77028988f7a2", + "content-hash": "ed46c3d9d7188821f6ff9a70722847be", "packages": [ { "name": "brick/math", @@ -9791,7 +9791,8 @@ "prefer-stable": true, "prefer-lowest": false, "platform": { - "php": "^8.5" + "php": "^8.5", + "ext-zip": "*" }, "platform-dev": {}, "plugin-api-version": "2.9.0" diff --git a/config/app.php b/config/app.php index 423eed5..135d3f3 100644 --- a/config/app.php +++ b/config/app.php @@ -13,7 +13,20 @@ | */ - 'name' => env('APP_NAME', 'Laravel'), + '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'), /* |-------------------------------------------------------------------------- diff --git a/config/auth.php b/config/auth.php deleted file mode 100644 index d7568ff..0000000 --- a/config/auth.php +++ /dev/null @@ -1,117 +0,0 @@ - [ - 'guard' => env('AUTH_GUARD', 'web'), - 'passwords' => env('AUTH_PASSWORD_BROKER', 'users'), - ], - - /* - |-------------------------------------------------------------------------- - | Authentication Guards - |-------------------------------------------------------------------------- - | - | Next, you may define every authentication guard for your application. - | Of course, a great default configuration has been defined for you - | which utilizes session storage plus the Eloquent user provider. - | - | All authentication guards have a user provider, which defines how the - | users are actually retrieved out of your database or other storage - | system used by the application. Typically, Eloquent is utilized. - | - | Supported: "session" - | - */ - - 'guards' => [ - 'web' => [ - 'driver' => 'session', - 'provider' => 'users', - ], - ], - - /* - |-------------------------------------------------------------------------- - | User Providers - |-------------------------------------------------------------------------- - | - | All authentication guards have a user provider, which defines how the - | users are actually retrieved out of your database or other storage - | system used by the application. Typically, Eloquent is utilized. - | - | If you have multiple user tables or models you may configure multiple - | providers to represent the model / table. These providers may then - | be assigned to any extra authentication guards you have defined. - | - | Supported: "database", "eloquent" - | - */ - - 'providers' => [ - 'users' => [ - 'driver' => 'eloquent', - 'model' => env('AUTH_MODEL', User::class), - ], - - // 'users' => [ - // 'driver' => 'database', - // 'table' => 'users', - // ], - ], - - /* - |-------------------------------------------------------------------------- - | Resetting Passwords - |-------------------------------------------------------------------------- - | - | These configuration options specify the behavior of Laravel's password - | reset functionality, including the table utilized for token storage - | and the user provider that is invoked to actually retrieve users. - | - | The expiry time is the number of minutes that each reset token will be - | considered valid. This security feature keeps tokens short-lived so - | they have less time to be guessed. You may change this as needed. - | - | The throttle setting is the number of seconds a user must wait before - | generating more password reset tokens. This prevents the user from - | quickly generating a very large amount of password reset tokens. - | - */ - - 'passwords' => [ - 'users' => [ - 'provider' => 'users', - 'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'), - 'expire' => 60, - 'throttle' => 60, - ], - ], - - /* - |-------------------------------------------------------------------------- - | Password Confirmation Timeout - |-------------------------------------------------------------------------- - | - | Here you may define the number of seconds before a password confirmation - | window expires and users are asked to re-enter their password via the - | confirmation screen. By default, the timeout lasts for three hours. - | - */ - - 'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800), - -]; diff --git a/config/database.php b/config/database.php deleted file mode 100644 index 64709ce..0000000 --- a/config/database.php +++ /dev/null @@ -1,184 +0,0 @@ - env('DB_CONNECTION', 'sqlite'), - - /* - |-------------------------------------------------------------------------- - | Database Connections - |-------------------------------------------------------------------------- - | - | Below are all of the database connections defined for your application. - | An example configuration is provided for each database system which - | is supported by Laravel. You're free to add / remove connections. - | - */ - - 'connections' => [ - - 'sqlite' => [ - 'driver' => 'sqlite', - 'url' => env('DB_URL'), - 'database' => env('DB_DATABASE', database_path('database.sqlite')), - 'prefix' => '', - 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), - 'busy_timeout' => null, - 'journal_mode' => null, - 'synchronous' => null, - 'transaction_mode' => 'DEFERRED', - ], - - 'mysql' => [ - 'driver' => 'mysql', - 'url' => env('DB_URL'), - 'host' => env('DB_HOST', '127.0.0.1'), - 'port' => env('DB_PORT', '3306'), - 'database' => env('DB_DATABASE', 'laravel'), - 'username' => env('DB_USERNAME', 'root'), - 'password' => env('DB_PASSWORD', ''), - 'unix_socket' => env('DB_SOCKET', ''), - 'charset' => env('DB_CHARSET', 'utf8mb4'), - 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'), - 'prefix' => '', - 'prefix_indexes' => true, - 'strict' => true, - 'engine' => null, - 'options' => extension_loaded('pdo_mysql') ? array_filter([ - (PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), - ]) : [], - ], - - 'mariadb' => [ - 'driver' => 'mariadb', - 'url' => env('DB_URL'), - 'host' => env('DB_HOST', '127.0.0.1'), - 'port' => env('DB_PORT', '3306'), - 'database' => env('DB_DATABASE', 'laravel'), - 'username' => env('DB_USERNAME', 'root'), - 'password' => env('DB_PASSWORD', ''), - 'unix_socket' => env('DB_SOCKET', ''), - 'charset' => env('DB_CHARSET', 'utf8mb4'), - 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'), - 'prefix' => '', - 'prefix_indexes' => true, - 'strict' => true, - 'engine' => null, - 'options' => extension_loaded('pdo_mysql') ? array_filter([ - (PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), - ]) : [], - ], - - 'pgsql' => [ - 'driver' => 'pgsql', - 'url' => env('DB_URL'), - 'host' => env('DB_HOST', '127.0.0.1'), - 'port' => env('DB_PORT', '5432'), - 'database' => env('DB_DATABASE', 'laravel'), - 'username' => env('DB_USERNAME', 'root'), - 'password' => env('DB_PASSWORD', ''), - 'charset' => env('DB_CHARSET', 'utf8'), - 'prefix' => '', - 'prefix_indexes' => true, - 'search_path' => 'public', - 'sslmode' => env('DB_SSLMODE', 'prefer'), - ], - - 'sqlsrv' => [ - 'driver' => 'sqlsrv', - 'url' => env('DB_URL'), - 'host' => env('DB_HOST', 'localhost'), - 'port' => env('DB_PORT', '1433'), - 'database' => env('DB_DATABASE', 'laravel'), - 'username' => env('DB_USERNAME', 'root'), - 'password' => env('DB_PASSWORD', ''), - 'charset' => env('DB_CHARSET', 'utf8'), - 'prefix' => '', - 'prefix_indexes' => true, - // 'encrypt' => env('DB_ENCRYPT', 'yes'), - // 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'), - ], - - ], - - /* - |-------------------------------------------------------------------------- - | Migration Repository Table - |-------------------------------------------------------------------------- - | - | This table keeps track of all the migrations that have already run for - | your application. Using this information, we can determine which of - | the migrations on disk haven't actually been run on the database. - | - */ - - 'migrations' => [ - 'table' => 'migrations', - 'update_date_on_publish' => true, - ], - - /* - |-------------------------------------------------------------------------- - | Redis Databases - |-------------------------------------------------------------------------- - | - | Redis is an open source, fast, and advanced key-value store that also - | provides a richer body of commands than a typical key-value system - | such as Memcached. You may define your connection settings here. - | - */ - - 'redis' => [ - - 'client' => env('REDIS_CLIENT', 'phpredis'), - - 'options' => [ - 'cluster' => env('REDIS_CLUSTER', 'redis'), - 'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'), - 'persistent' => env('REDIS_PERSISTENT', false), - ], - - 'default' => [ - 'url' => env('REDIS_URL'), - 'host' => env('REDIS_HOST', '127.0.0.1'), - 'username' => env('REDIS_USERNAME'), - 'password' => env('REDIS_PASSWORD'), - 'port' => env('REDIS_PORT', '6379'), - 'database' => env('REDIS_DB', '0'), - 'max_retries' => env('REDIS_MAX_RETRIES', 3), - 'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'), - 'backoff_base' => env('REDIS_BACKOFF_BASE', 100), - 'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000), - ], - - 'cache' => [ - 'url' => env('REDIS_URL'), - 'host' => env('REDIS_HOST', '127.0.0.1'), - 'username' => env('REDIS_USERNAME'), - 'password' => env('REDIS_PASSWORD'), - 'port' => env('REDIS_PORT', '6379'), - 'database' => env('REDIS_CACHE_DB', '1'), - 'max_retries' => env('REDIS_MAX_RETRIES', 3), - 'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'), - 'backoff_base' => env('REDIS_BACKOFF_BASE', 100), - 'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000), - ], - - ], - -]; diff --git a/config/mail.php b/config/mail.php deleted file mode 100644 index e32e88d..0000000 --- a/config/mail.php +++ /dev/null @@ -1,118 +0,0 @@ - env('MAIL_MAILER', 'log'), - - /* - |-------------------------------------------------------------------------- - | Mailer Configurations - |-------------------------------------------------------------------------- - | - | Here you may configure all of the mailers used by your application plus - | their respective settings. Several examples have been configured for - | you and you are free to add your own as your application requires. - | - | Laravel supports a variety of mail "transport" drivers that can be used - | when delivering an email. You may specify which one you're using for - | your mailers below. You may also add additional mailers if needed. - | - | Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2", - | "postmark", "resend", "log", "array", - | "failover", "roundrobin" - | - */ - - 'mailers' => [ - - 'smtp' => [ - 'transport' => 'smtp', - 'scheme' => env('MAIL_SCHEME'), - 'url' => env('MAIL_URL'), - 'host' => env('MAIL_HOST', '127.0.0.1'), - 'port' => env('MAIL_PORT', 2525), - 'username' => env('MAIL_USERNAME'), - 'password' => env('MAIL_PASSWORD'), - 'timeout' => null, - 'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)), - ], - - 'ses' => [ - 'transport' => 'ses', - ], - - 'postmark' => [ - 'transport' => 'postmark', - // 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'), - // 'client' => [ - // 'timeout' => 5, - // ], - ], - - 'resend' => [ - 'transport' => 'resend', - ], - - 'sendmail' => [ - 'transport' => 'sendmail', - 'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'), - ], - - 'log' => [ - 'transport' => 'log', - 'channel' => env('MAIL_LOG_CHANNEL'), - ], - - 'array' => [ - 'transport' => 'array', - ], - - 'failover' => [ - 'transport' => 'failover', - 'mailers' => [ - 'smtp', - 'log', - ], - 'retry_after' => 60, - ], - - 'roundrobin' => [ - 'transport' => 'roundrobin', - 'mailers' => [ - 'ses', - 'postmark', - ], - 'retry_after' => 60, - ], - - ], - - /* - |-------------------------------------------------------------------------- - | Global "From" Address - |-------------------------------------------------------------------------- - | - | You may wish for all emails sent by your application to be sent from - | the same address. Here you may specify a name and address that is - | used globally for all emails that are sent by your application. - | - */ - - 'from' => [ - 'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'), - 'name' => env('MAIL_FROM_NAME', env('APP_NAME', 'Laravel')), - ], - -]; diff --git a/config/nativephp.php b/config/nativephp.php index 10cd500..d12250d 100644 --- a/config/nativephp.php +++ b/config/nativephp.php @@ -1,5 +1,7 @@ env('NATIVEPHP_APP_VERSION', '1.0.0'), + 'version' => $versionData['version'] ?? env('NATIVEPHP_APP_VERSION', '1.0.0'), /* |-------------------------------------------------------------------------- @@ -26,7 +28,7 @@ | */ - 'version_code' => env('NATIVEPHP_APP_VERSION_CODE', 1), + 'version_code' => $versionData['version_code'] ?? env('NATIVEPHP_APP_VERSION_CODE', 1), /* |-------------------------------------------------------------------------- @@ -129,6 +131,7 @@ 'storage/framework/cache', 'storage/framework/testing', 'storage/logs/laravel.log', + 'public/hot', ], /* diff --git a/config/queue.php b/config/queue.php index 79c2c0a..8126210 100644 --- a/config/queue.php +++ b/config/queue.php @@ -7,28 +7,13 @@ | Default Queue Connection Name |-------------------------------------------------------------------------- | - | Laravel's queue supports a variety of backends via a single, unified - | API, giving you convenient access to each backend using identical - | syntax for each. The default queue connection is defined below. + | 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', 'database'), - /* - |-------------------------------------------------------------------------- - | Queue Connections - |-------------------------------------------------------------------------- - | - | Here you may configure the connection options for every queue backend - | used by your application. An example configuration is provided for - | each backend supported by Laravel. You're also free to add more. - | - | Drivers: "sync", "database", "beanstalkd", "sqs", "redis", - | "deferred", "background", "failover", "null" - | - */ - 'connections' => [ 'sync' => [ @@ -44,86 +29,26 @@ 'after_commit' => false, ], - 'beanstalkd' => [ - 'driver' => 'beanstalkd', - 'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'), - 'queue' => env('BEANSTALKD_QUEUE', 'default'), - 'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90), - 'block_for' => 0, - 'after_commit' => false, - ], - - 'sqs' => [ - 'driver' => 'sqs', - 'key' => env('AWS_ACCESS_KEY_ID'), - 'secret' => env('AWS_SECRET_ACCESS_KEY'), - 'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'), - 'queue' => env('SQS_QUEUE', 'default'), - 'suffix' => env('SQS_SUFFIX'), - 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), - 'after_commit' => false, - ], - - 'redis' => [ - 'driver' => 'redis', - 'connection' => env('REDIS_QUEUE_CONNECTION', 'default'), - 'queue' => env('REDIS_QUEUE', 'default'), - 'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90), - 'block_for' => null, - 'after_commit' => false, - ], - - 'deferred' => [ - 'driver' => 'deferred', - ], - - 'background' => [ - 'driver' => 'background', - ], - - 'failover' => [ - 'driver' => 'failover', - 'connections' => [ - 'database', - 'deferred', - ], + 'null' => [ + 'driver' => 'null', ], ], - /* - |-------------------------------------------------------------------------- - | Job Batching - |-------------------------------------------------------------------------- - | - | The following options configure the database and table that store job - | batching information. These options can be updated to any database - | connection and table which has been defined by your application. - | - */ - - 'batching' => [ - 'database' => env('DB_CONNECTION', 'sqlite'), - 'table' => 'job_batches', - ], - /* |-------------------------------------------------------------------------- | Failed Queue Jobs |-------------------------------------------------------------------------- | - | These options configure the behavior of failed queue job logging so you - | can control how and where failed jobs are stored. Laravel ships with - | support for storing failed jobs in a simple file or in a database. - | - | Supported drivers: "database-uuids", "dynamodb", "file", "null" + | 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', 'database-uuids'), - 'database' => env('DB_CONNECTION', 'sqlite'), - 'table' => 'failed_jobs', + 'driver' => env('QUEUE_FAILED_DRIVER', 'null'), + 'database' => null, + 'table' => null, ], ]; diff --git a/config/services.php b/config/services.php deleted file mode 100644 index 6a90eb8..0000000 --- a/config/services.php +++ /dev/null @@ -1,38 +0,0 @@ - [ - 'key' => env('POSTMARK_API_KEY'), - ], - - 'resend' => [ - 'key' => env('RESEND_API_KEY'), - ], - - 'ses' => [ - 'key' => env('AWS_ACCESS_KEY_ID'), - 'secret' => env('AWS_SECRET_ACCESS_KEY'), - 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), - ], - - 'slack' => [ - 'notifications' => [ - 'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'), - 'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'), - ], - ], - -]; diff --git a/database/.gitignore b/database/.gitignore deleted file mode 100644 index 9b19b93..0000000 --- a/database/.gitignore +++ /dev/null @@ -1 +0,0 @@ -*.sqlite* diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php deleted file mode 100644 index c4ceb07..0000000 --- a/database/factories/UserFactory.php +++ /dev/null @@ -1,45 +0,0 @@ - - */ -class UserFactory extends Factory -{ - /** - * The current password being used by the factory. - */ - protected static ?string $password; - - /** - * Define the model's default state. - * - * @return array - */ - public function definition(): array - { - return [ - 'name' => fake()->name(), - 'email' => fake()->unique()->safeEmail(), - 'email_verified_at' => now(), - 'password' => static::$password ??= Hash::make('password'), - 'remember_token' => Str::random(10), - ]; - } - - /** - * Indicate that the model's email address should be unverified. - */ - public function unverified(): static - { - return $this->state(fn (array $attributes) => [ - 'email_verified_at' => null, - ]); - } -} diff --git a/database/migrations/0001_01_01_000001_create_cache_table.php b/database/migrations/0001_01_01_000001_create_cache_table.php deleted file mode 100644 index 06dc7a5..0000000 --- a/database/migrations/0001_01_01_000001_create_cache_table.php +++ /dev/null @@ -1,35 +0,0 @@ -string('key')->primary(); - $table->mediumText('value'); - $table->bigInteger('expiration')->index(); - }); - - Schema::create('cache_locks', function (Blueprint $table) { - $table->string('key')->primary(); - $table->string('owner'); - $table->bigInteger('expiration')->index(); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('cache'); - Schema::dropIfExists('cache_locks'); - } -}; diff --git a/database/migrations/0001_01_01_000002_create_jobs_table.php b/database/migrations/0001_01_01_000002_create_jobs_table.php deleted file mode 100644 index 425e705..0000000 --- a/database/migrations/0001_01_01_000002_create_jobs_table.php +++ /dev/null @@ -1,57 +0,0 @@ -id(); - $table->string('queue')->index(); - $table->longText('payload'); - $table->unsignedTinyInteger('attempts'); - $table->unsignedInteger('reserved_at')->nullable(); - $table->unsignedInteger('available_at'); - $table->unsignedInteger('created_at'); - }); - - Schema::create('job_batches', function (Blueprint $table) { - $table->string('id')->primary(); - $table->string('name'); - $table->integer('total_jobs'); - $table->integer('pending_jobs'); - $table->integer('failed_jobs'); - $table->longText('failed_job_ids'); - $table->mediumText('options')->nullable(); - $table->integer('cancelled_at')->nullable(); - $table->integer('created_at'); - $table->integer('finished_at')->nullable(); - }); - - Schema::create('failed_jobs', function (Blueprint $table) { - $table->id(); - $table->string('uuid')->unique(); - $table->text('connection'); - $table->text('queue'); - $table->longText('payload'); - $table->longText('exception'); - $table->timestamp('failed_at')->useCurrent(); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('jobs'); - Schema::dropIfExists('job_batches'); - Schema::dropIfExists('failed_jobs'); - } -}; diff --git a/database/migrations/2026_04_09_071339_create_jobs_table.php b/database/migrations/2026_04_09_071339_create_jobs_table.php new file mode 100644 index 0000000..6098d9b --- /dev/null +++ b/database/migrations/2026_04_09_071339_create_jobs_table.php @@ -0,0 +1,32 @@ +bigIncrements('id'); + $table->string('queue')->index(); + $table->longText('payload'); + $table->unsignedTinyInteger('attempts'); + $table->unsignedInteger('reserved_at')->nullable(); + $table->unsignedInteger('available_at'); + $table->unsignedInteger('created_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('jobs'); + } +}; diff --git a/database/migrations/2026_04_09_080000_create_programs_table.php b/database/migrations/2026_04_09_080000_create_programs_table.php new file mode 100644 index 0000000..d42202f --- /dev/null +++ b/database/migrations/2026_04_09_080000_create_programs_table.php @@ -0,0 +1,25 @@ +uuid('id')->primary(); + $table->string('name', 60); + $table->tinyInteger('beep_lead_in')->default(3); + $table->string('end_sound', 20)->default('triple'); + $table->timestamp('last_used_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('programs'); + } +}; diff --git a/database/migrations/2026_04_09_080001_create_phases_table.php b/database/migrations/2026_04_09_080001_create_phases_table.php new file mode 100644 index 0000000..6669ed8 --- /dev/null +++ b/database/migrations/2026_04_09_080001_create_phases_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignUuid('program_id')->constrained()->cascadeOnDelete(); + $table->unsignedTinyInteger('sort_order'); + $table->string('label', 40); + $table->unsignedSmallInteger('duration'); + $table->unsignedSmallInteger('repetitions')->default(1); + $table->unsignedSmallInteger('pause')->default(0); + $table->unsignedSmallInteger('cooldown')->default(0); + $table->string('color', 20)->default('#3b82f6'); + }); + } + + public function down(): void + { + Schema::dropIfExists('phases'); + } +}; diff --git a/database/migrations/2026_04_09_080002_create_history_table.php b/database/migrations/2026_04_09_080002_create_history_table.php new file mode 100644 index 0000000..9dd60d5 --- /dev/null +++ b/database/migrations/2026_04_09_080002_create_history_table.php @@ -0,0 +1,24 @@ +id(); + $table->uuid('program_id')->nullable()->index(); + $table->string('program_name', 60); + $table->unsignedInteger('total_duration'); + $table->timestamp('completed_at'); + }); + } + + public function down(): void + { + Schema::dropIfExists('history'); + } +}; diff --git a/database/migrations/2026_04_09_080003_create_settings_table.php b/database/migrations/2026_04_09_080003_create_settings_table.php new file mode 100644 index 0000000..6c670d9 --- /dev/null +++ b/database/migrations/2026_04_09_080003_create_settings_table.php @@ -0,0 +1,25 @@ +id(); + $table->tinyInteger('default_beep_lead_in')->default(3); + $table->string('default_end_sound', 20)->default('triple'); + $table->string('sound_mode', 10)->default('beep'); + $table->float('volume')->default(0.8); + $table->boolean('keep_screen_on')->default(true); + }); + } + + public function down(): void + { + Schema::dropIfExists('settings'); + } +}; diff --git a/database/migrations/0001_01_01_000000_create_users_table.php b/database/migrations/2026_04_09_122314_create_sessions_table.php similarity index 53% rename from database/migrations/0001_01_01_000000_create_users_table.php rename to database/migrations/2026_04_09_122314_create_sessions_table.php index 05fb5d9..f60625b 100644 --- a/database/migrations/0001_01_01_000000_create_users_table.php +++ b/database/migrations/2026_04_09_122314_create_sessions_table.php @@ -11,22 +11,6 @@ */ public function up(): void { - Schema::create('users', function (Blueprint $table) { - $table->id(); - $table->string('name'); - $table->string('email')->unique(); - $table->timestamp('email_verified_at')->nullable(); - $table->string('password'); - $table->rememberToken(); - $table->timestamps(); - }); - - Schema::create('password_reset_tokens', function (Blueprint $table) { - $table->string('email')->primary(); - $table->string('token'); - $table->timestamp('created_at')->nullable(); - }); - Schema::create('sessions', function (Blueprint $table) { $table->string('id')->primary(); $table->foreignId('user_id')->nullable()->index(); @@ -42,8 +26,6 @@ public function up(): void */ public function down(): void { - Schema::dropIfExists('users'); - Schema::dropIfExists('password_reset_tokens'); Schema::dropIfExists('sessions'); } }; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php deleted file mode 100644 index 6b901f8..0000000 --- a/database/seeders/DatabaseSeeder.php +++ /dev/null @@ -1,25 +0,0 @@ -create(); - - User::factory()->create([ - 'name' => 'Test User', - 'email' => 'test@example.com', - ]); - } -} diff --git a/nativephp.json b/nativephp.json new file mode 100644 index 0000000..3daf54e --- /dev/null +++ b/nativephp.json @@ -0,0 +1,6 @@ +{ + "php": { + "version": "8.5", + "icu": false + } +} diff --git a/package-lock.json b/package-lock.json index fcb2f91..6dbefb1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "Interval-timer-nativephp", + "name": "interval-timer-nativephp", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/phpunit.xml b/phpunit.xml index e7f0a48..8270081 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -8,9 +8,6 @@ tests/Unit - - tests/Feature - diff --git a/resources/js/app.js b/resources/js/app.js index 97a4426..8724cee 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -1,46 +1,59 @@ import './bootstrap'; -import Alpine from 'alpinejs'; import { initAudio } from './audio'; -window.Alpine = Alpine; - // 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.audio = initAudio(); - // Receive settings from Livewire - this.$wire.on('settingsLoaded', ({ soundMode, volume }) => { - this.soundMode = soundMode; - this.audio.setVolume(volume); + 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 (['PREPARE', 'RUNNING', 'PAUSE', 'COOLDOWN'].includes(stateName)) { + this.interval = setInterval(() => this.$wire.tick(), 1000); + } }); - // Beep trigger from Livewire - this.$wire.on('playBeep', ({ reason }) => { + + 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(); } }); - // Pause beep - this.$wire.on('playPauseBeep', () => { - this.audio.pauseBeep(); - }); - // End sound - this.$wire.on('playEndSound', ({ sound }) => { + + this.$wire.on('playEndSound', ({sound}) => { if (sound === 'triple') { this.audio.tripleBeep(); } else { this.audio.chime(); } }); + + this.$wire.on('playPauseBeep', () => { + this.audio.pauseBeep(); + }); }, voiceText(reason) { const map = { - countdown: this.$wire.countdownLabel ?? 'Get ready', + prepare: this.$wire?.countdownLabel ?? '', + countdown: this.$wire?.countdownLabel ?? 'Get ready', rep_end: 'Done', pause_end: 'Go', cooldown_end: 'Next', @@ -50,4 +63,4 @@ document.addEventListener('alpine:init', () => { })); }); -Alpine.start(); +// Alpine is automatically started by Livewire v3. diff --git a/resources/js/audio.js b/resources/js/audio.js index 122fbbc..5e7a7cc 100644 --- a/resources/js/audio.js +++ b/resources/js/audio.js @@ -1,15 +1,17 @@ +// noinspection JSUnresolvedReference + /** * Audio engine for the interval timer. * - * soundMode = 'beep' → Web Audio API (synthesised, no network) + * soundMode = 'beep' → Web Audio API (synthesized, no network) * soundMode = 'voice' → Android TTS via NativePHP JS bridge (feminine, calm) * * All methods are no-ops until the user has interacted with the page, * satisfying the browser AudioContext autoplay policy. */ -export function initAudio() { +export function initAudio(volume = 0.8) { + volume = Math.max(0, Math.min(1, volume)); let ctx = null; - let volume = 0.8; function getCtx() { if (!ctx) { @@ -43,12 +45,19 @@ export function initAudio() { /** Single countdown beep (800 Hz, 100 ms). */ function beep() { tone(800, 100); } + /** Prepare-phase beep — three rapid beeps at the same tone (800 Hz, 100 ms × 3). */ + function prepareBeep() { + tone(800, 100, 0); + tone(800, 100, 150); + tone(800, 100, 300); + } + /** Gentle single beep on user pause (600 Hz, 80 ms). */ function pauseBeep() { tone(600, 80); } /** * End sound: triple beep (three 880 Hz tones 150 ms apart). - * finish-triple.mp3 semantics, synthesised. + * finish-triple.mp3 semantics, synthesized. */ function tripleBeep() { tone(880, 120, 0); @@ -58,7 +67,7 @@ export function initAudio() { /** * End sound: chime (descending 3-tone chord, warm). - * finish-chime.mp3 semantics, synthesised. + * finish-chime.mp3 semantics, synthesized. */ function chime() { tone(1046.5, 300, 0); // C6 @@ -68,7 +77,7 @@ export function initAudio() { /** * Android TTS via NativePHP bridge. - * Falls back to Web Speech API on web browser. + * Falls back to Web Speech API on a web browser. */ function speak(text) { if (window.NativePhp?.tts?.speak) { @@ -97,10 +106,10 @@ export function initAudio() { return { beep, + prepareBeep, pauseBeep, tripleBeep, chime, speak, - setVolume(v) { volume = Math.max(0, Math.min(1, v)); }, }; } diff --git a/resources/views/livewire/library.blade.php b/resources/views/livewire/library.blade.php index 2902361..e567e7e 100644 --- a/resources/views/livewire/library.blade.php +++ b/resources/views/livewire/library.blade.php @@ -60,30 +60,30 @@ class="flex items-center gap-3 bg-gray-900 rounded-2xl px-4 py-4 > {{-- Tap → go to timer --}} @@ -61,23 +65,25 @@ class="px-3 py-1.5 rounded-lg text-sm font-semibold transition-colors wire:click="$set('endSound', 'triple')" class="px-3 py-1.5 rounded-lg text-sm font-semibold transition-colors {{ $endSound === 'triple' ? 'bg-blue-600 text-white' : 'bg-gray-800 text-gray-400' }}" - >Triple + >Triple + + >Chime + {{-- Total duration badge --}} @if($this->totalDuration() > 0) -
+
Total: {{ $this->formattedDuration() }} -
+
@endif {{-- Phases --}} @@ -87,69 +93,80 @@ class="px-3 py-1.5 rounded-lg text-sm font-semibold transition-colors Phases ({{ count($phases) }}/10) @if(count($phases) < 10) - + @endif
@forelse($phases as $i => $phase) -
+
- {{-- Colour swatch --}} -
+ {{-- Colour swatch --}} +
- {{-- Info --}} -
-

{{ $phase['label'] }}

-

- {{ $phase['duration'] }}s - × {{ $phase['repetitions'] }} {{ Str::plural('rep', $phase['repetitions']) }} - @if($phase['pause'] > 0)· {{ $phase['pause'] }}s pause @endif - @if($phase['cooldown'] > 0)· {{ $phase['cooldown'] }}s cooldown @endif -

-
+ {{-- Info --}} +
+

{{ $phase['label'] }}

+

+ {{ $phase['duration'] }}s + × {{ $phase['repetitions'] }} {{ Str::plural('rep', $phase['repetitions']) }} + @if($phase['pause'] > 0) + · {{ $phase['pause'] }}s pause + @endif + @if($phase['cooldown'] > 0 && !$loop->last) + · {{ $phase['cooldown'] }}s cooldown + @endif +

+
+ + {{-- Reorder --}} +
+ + +
- {{-- Reorder --}} -
-
- - {{-- Edit --}} - - - {{-- Delete --}} - -
@empty -
-

No phases yet — add one above

-
+
+

No phases yet — add one above

+
@endforelse
@@ -157,113 +174,139 @@ class="p-1 text-gray-600 hover:text-gray-400 {{ $i === count($phases)-1 ? 'invis {{-- ── Phase form sheet ─────────────────────────────────────────────── --}} @if($showPhaseForm) -
-
+
+
-
-

- {{ $editingPhaseIndex !== null ? 'Edit Phase' : 'Add Phase' }} -

- -
+
+

+ {{ $editingPhaseIndex !== null ? 'Edit Phase' : 'Add Phase' }} +

+ +
-
+
- {{-- Label --}} -
- - Phase + Name + - @error('phaseLabel')

{{ $message }}

@enderror -
+ > + @error('phaseLabel')

{{ $message }}

@enderror +
- {{-- Duration × Reps --}} -
-
- - +
+ + - @error('phaseDuration')

{{ $message }}

@enderror -
-
- - {{ $message }}

@enderror +
+
+ + - @error('phaseReps')

{{ $message }}

@enderror + @error('phaseReps')

{{ $message }}

@enderror +
-
- {{-- Pause × Cooldown --}} -
-
- -

Between reps

- + {{-- Pause × Cooldown --}} +
+ @if($phaseReps > 1) +
+ +

Between reps

+ +
+ @endif +
+ +

+ @if($this->editingIsLastPhase()) + not counted — add another phase + @else + After final rep + @endif +

+ 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' }}"> +
+ + {{-- Colour --}}
- -

After final rep

- + +
+ @foreach($palette as $color) + + @endforeach +
-
- {{-- Colour --}} -
- -
- @foreach($palette as $color) + {{-- Actions --}} +
- @endforeach + wire:click="cancelPhaseForm" + class="flex-1 bg-gray-800 hover:bg-gray-700 text-gray-300 font-semibold py-3 rounded-xl" + >Cancel + + + @if( $editingPhaseIndex === null && count($phases) < 10) + + @endif +
-
- {{-- Actions --}} -
- -
-
-
@endif
diff --git a/resources/views/livewire/settings.blade.php b/resources/views/livewire/settings.blade.php index 5dc2fa4..3398139 100644 --- a/resources/views/livewire/settings.blade.php +++ b/resources/views/livewire/settings.blade.php @@ -1,3 +1,4 @@ +@php use App\Enum\BeepLeadIn; @endphp
@@ -91,12 +92,12 @@ class="w-full accent-blue-500 h-1.5"
@@ -151,7 +152,7 @@ class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform

- Interval Timer · Built with NativePHP Mobile {{ app('nativephp.version', '3.x') }} + Interval Timer · Built with NativePHP Mobile {{ config('nativephp.version', '3.1') }} + Laravel {{ app()->version() }} + PHP 8.5

diff --git a/resources/views/livewire/timer-screen.blade.php b/resources/views/livewire/timer-screen.blade.php index 3e164f8..b405b1f 100644 --- a/resources/views/livewire/timer-screen.blade.php +++ b/resources/views/livewire/timer-screen.blade.php @@ -1,3 +1,4 @@ +@php use App\Enum\StateMachine; @endphp {{-- TimerScreen — full-screen timer UI. @@ -8,194 +9,329 @@ --}}
+ {{-- ── No program loaded: history list ───────────────────────────── --}} + @if(! $programId) +
- {{-- ── Ticker: fires every 1 s when timer is running ────────────────── --}} - + {{-- Header --}} + + + {{-- 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 loaded ───────────────────────────────────────────── --}} - @if(! $programId) -
-
- - -
-

No program selected

-

Go to Library and tap a program to start

- - Open Library - -
@else - {{-- ── Active timer ────────────────────────────────────────────────── --}} - - {{-- Phase progress bar --}} -
- @if($phaseIndex < collect($phases ?? [])->count() || true) -
- @endif -
- - {{-- Phase label strip --}} -
-
- - - {{ $this->segmentLabel() }} - - @if($this->repLabel()) - — Rep {{ $this->repLabel() }} - @endif + {{-- ── 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 +
+ @endforeach
-
- {{-- ── BIG countdown ───────────────────────────────────────────────── --}} -
+ @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 - {{-- Main digit --}} + {{-- ── Ring + inner content ────────────────────────────────────────── --}}
- {{ $state === '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 + +
+
+ + {{-- State messages below ring --}} + @if($state->value === 'COMPLETED') +

+ Program Complete! +

+ @elseif($state->value === 'COOLDOWN') +

+ Take deep breaths +

+ @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 +
- {{-- Completed message --}} - @if($state === 'completed') -

- Program Complete! -

- @endif - - {{-- Cooldown breathing prompt --}} - @if($state === 'cooldown') -

- Take deep breaths -

- @endif - - {{-- Paused label --}} - @if($state === 'paused') -

Paused

- @endif - - {{-- Total remaining --}} - @if(in_array($state, ['running', 'pause', 'cooldown', 'paused']) && $totalRemaining > 0) -

- {{ $this->formattedTotal() }} total remaining -

- @endif - -
- - {{-- ── Controls ───────────────────────────────────────────────────── --}} -
- - {{-- Primary action --}} - @if($state === 'idle') - + > + Start + - @elseif($state === 'paused') - + > + Resume + - @elseif($state === 'completed') - + > + Run Again + - @else - - @endif - - {{-- Secondary actions row --}} -
- + - - - - Edit - - - @if(! in_array($state, ['idle', 'completed'])) - - @endif -
+ > + + + + Discard + + @endif +
-
+
@endif
diff --git a/resources/views/welcome.blade.php b/resources/views/welcome.blade.php deleted file mode 100644 index 5f83753..0000000 --- a/resources/views/welcome.blade.php +++ /dev/null @@ -1,225 +0,0 @@ - - - - - - - {{ config('app.name', 'Laravel') }} - - - - - - - @if (file_exists(public_path('build/manifest.json')) || file_exists(public_path('hot'))) - @vite(['resources/css/app.css', 'resources/js/app.js']) - @else - - @endif - - -
- @if (Route::has('login')) - - @endif -
-
-
-
-

Let's get started

-

With so many options available to you,
we suggest you start with the following:

- - - -

- v{{ app()->version() }} - - View changelog - - - - -

-
-
- {{-- Laravel Logo --}} - - - - - - - - - - - {{-- 13 --}} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-
-
- - @if (Route::has('login')) - - @endif - - diff --git a/routes/console.php b/routes/console.php index 3c9adf1..b3d9bbc 100644 --- a/routes/console.php +++ b/routes/console.php @@ -1,8 +1 @@ comment(Inspiring::quote()); -})->purpose('Display an inspiring quote'); diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php deleted file mode 100644 index 8364a84..0000000 --- a/tests/Feature/ExampleTest.php +++ /dev/null @@ -1,19 +0,0 @@ -get('/'); - - $response->assertStatus(200); - } -} diff --git a/tests/Pest.php b/tests/Pest.php index 48d27ea..3839087 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -9,8 +9,7 @@ | | All tests under Unit/Timer/ use the full Laravel TestCase so they get a | real app container with Storage::fake(), Event::fake(), etc. -| Feature tests always use the Laravel TestCase. | */ -uses(Tests\TestCase::class)->in('Unit/Timer', 'Feature'); +uses(Tests\TestCase::class)->in('Unit/Timer'); diff --git a/tests/TestCase.php b/tests/TestCase.php index fe1ffc2..d7c5d5f 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,9 +2,10 @@ namespace Tests; +use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\TestCase as BaseTestCase; abstract class TestCase extends BaseTestCase { - // + use RefreshDatabase; } diff --git a/tests/Unit/ExampleTest.php b/tests/Unit/ExampleTest.php deleted file mode 100644 index 5773b0c..0000000 --- a/tests/Unit/ExampleTest.php +++ /dev/null @@ -1,16 +0,0 @@ -assertTrue(true); - } -} diff --git a/tests/Unit/Timer/BeepLogicTest.php b/tests/Unit/Timer/BeepLogicTest.php index d59847d..d252dd3 100644 --- a/tests/Unit/Timer/BeepLogicTest.php +++ b/tests/Unit/Timer/BeepLogicTest.php @@ -2,30 +2,36 @@ declare(strict_types=1); -use App\Timer\Phase; use App\Enum\BeepLeadIn; -use App\Enum\StateMachine; -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.) -function beepRunner(int $duration, int $reps = 1, int $pause = 0, int $cooldown = 0, 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::fromNumberToEnum($leadIn); - $prog->addPhase(new Phase('Work', $duration, $reps, $pause, $cooldown, '#3b82f6')); - $prog->save(); + foreach ($phases as $index => $phase) { + $prog->phases()->create(array_merge($phase, ['sort_order' => $index])); + } - $ctx = new \stdClass(); - $ctx->beeps = []; + 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; @@ -33,141 +39,244 @@ 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; } -// ── Lead-in 3s ──────────────────────────────────────────────────────────────── - -test('beep fires during last 3 seconds of a 10s rep (3s lead-in)', function (): void { - $ctx = beepRunner(duration: 10, leadIn: 3); +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, + ]; +} - // 7 ticks brings remaining from 10 → 3 (the lead-in window) - for ($i = 0; $i < 7; $i++) $ctx->runner->tick(); +// ── Prepare beep ────────────────────────────────────────────────────────────── - expect($ctx->beeps)->toContain('countdown'); -}); +test('prepare beep fires once per tick during the 5s prepare countdown', function (): void { + $prog = createProgram('Prepare beep test', [ + createPhase('Work', duration: 10), + ]); -test('beep fires exactly 3 times during last 3 seconds of 10s rep', function (): void { - $ctx = beepRunner(duration: 10, leadIn: 3); + $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 < 10; $i++) $ctx->runner->tick(); + for ($i = 0; $i < 5; $i++) $runner->tick(); - $countdownCount = count(array_filter($ctx->beeps, fn ($r) => $r === 'countdown')); - expect($countdownCount)->toBe(3); + $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 5s ──────────────────────────────────────────────────────────────── +// ── Lead-in 3s ──────────────────────────────────────────────────────────────── -test('beep fires 5 times during last 5 seconds with 5s lead-in', function (): void { - $ctx = beepRunner(duration: 15, leadIn: 5); +test('beep fires during last 3 seconds of a 10s rep (3s lead-in)', + /** + * @throws JsonException + */ + function (): void { + $ctx = createBeepRunner([ + createPhase(name: 'Work', duration: 10), + ], + ); - for ($i = 0; $i < 15; $i++) $ctx->runner->tick(); + // 7 ticks bring remaining from 10 → 3 (the lead-in window) + for ($i = 0; $i < 7; $i++) $ctx->runner->tick(); - $countdownCount = count(array_filter($ctx->beeps, fn ($r) => $r === 'countdown')); - expect($countdownCount)->toBe(5); -}); + expect($ctx->beeps)->toContain('countdown'); + }); -// ── Short segment fallback ──────────────────────────────────────────────────── +test('beep fires exactly 3 times during last 3 seconds of 10s rep', + function (): void { + $ctx = createBeepRunner([ + createPhase(name: 'Work', duration: 10), + ], + ); -test('segment shorter than lead-in fires beep from second 1 (2s segment, 3s lead-in)', function (): void { - $ctx = beepRunner(duration: 2, leadIn: 3); + for ($i = 0; $i < 10; $i++) $ctx->runner->tick(); - for ($i = 0; $i < 2; $i++) $ctx->runner->tick(); + $countdownCount = count(array_filter($ctx->beeps, fn($r) => $r === 'countdown')); + expect($countdownCount)->toBe(3); + }); - $countdownCount = count(array_filter($ctx->beeps, fn ($r) => $r === 'countdown')); - expect($countdownCount)->toBeGreaterThanOrEqual(1); -}); +// ── Lead-in 5s ──────────────────────────────────────────────────────────────── +test('beep fires 5 times during last 5 seconds with 5s lead-in', + /** + * @throws JsonException + */ + function (): void { + $ctx = createBeepRunner([ + createPhase(name: 'Work', duration: 15), + ], leadIn: 5, + ); + + for ($i = 0; $i < 15; $i++) $ctx->runner->tick(); + + $countdownCount = count(array_filter($ctx->beeps, fn($r) => $r === 'countdown')); + expect($countdownCount)->toBe(5); + }); -// ── Fires on rep end ────────────────────────────────────────────────────────── +// ── Short segment fallback ──────────────────────────────────────────────────── -test('rep_end beep fires when rep expires', function (): void { - $ctx = beepRunner(duration: 5); +test('segment shorter than lead-in fires beep from second 1 (2s segment, 3s lead-in)', + /** + * @throws JsonException + */ + function (): void { + $ctx = createBeepRunner( + [ + createPhase(name: 'Work', duration: 2), + ], + ); + + for ($i = 0; $i < 2; $i++) $ctx->runner->tick(); + + $countdownCount = count(array_filter($ctx->beeps, fn($r) => $r === 'countdown')); + expect($countdownCount)->toBeGreaterThanOrEqual(1); + }); - for ($i = 0; $i < 5; $i++) $ctx->runner->tick(); +// ── Fires on rep end ────────────────────────────────────────────────────────── - expect($ctx->beeps)->toContain('rep_end'); -}); +test('rep_end beep fires when rep expires', + /** + * @throws JsonException + */ + function (): void { + $ctx = createBeepRunner( + [ + createPhase(name: 'Work', duration: 5), + ], + ); -// ── Fires on pause end ──────────────────────────────────────────────────────── + for ($i = 0; $i < 5; $i++) $ctx->runner->tick(); -test('pause_end beep fires when inter-rep pause expires', function (): void { - $ctx = beepRunner(duration: 5, reps: 2, pause: 3); + expect($ctx->beeps)->toContain('rep_end'); + }); - // Rep 1 (5 ticks) then pause (3 ticks) - for ($i = 0; $i < 8; $i++) $ctx->runner->tick(); +// ── Fires on pause end ──────────────────────────────────────────────────────── - expect($ctx->beeps)->toContain('pause_end'); -}); +test('pause_end beep fires when inter-rep pause expires', + /** + * @throws JsonException + */ + function (): void { + $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(); + + expect($ctx->beeps)->toContain('pause_end'); + }); // ── Fires on cooldown end ───────────────────────────────────────────────────── -test('cooldown_end beep fires when cooldown expires', function (): void { - $ctx = beepRunner(duration: 5, cooldown: 3); - - // Rep (5 ticks) + cooldown (3 ticks) - for ($i = 0; $i < 8; $i++) $ctx->runner->tick(); - - expect($ctx->beeps)->toContain('cooldown_end'); -}); +test('cooldown_end beep fires when cooldown expires', + /** + * @throws JsonException + */ + function (): void { + $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(); + + expect($ctx->beeps)->toContain('cooldown_end'); + }); // ── rep_end does NOT fire during pause or cooldown segments ────────────────── -test('rep_end does not fire when pause segment expires', function (): void { - $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(); - - // rep_end fires once (end of rep 1). pause_end fires once (end of pause). - // rep_end must NOT fire a second time at end of pause. - $repEnds = count(array_filter($ctx->beeps, fn ($r) => $r === 'rep_end')); - $pauseEnds = count(array_filter($ctx->beeps, fn ($r) => $r === 'pause_end')); - - expect($repEnds)->toBe(1) - ->and($pauseEnds)->toBe(1); -}); +test('rep_end does not fire when pause segment expires', + /** + * @throws JsonException + */ + function (): void { + $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(); + + // rep_end fires once (end of rep 1). pause_end fires once (end of pause). + // rep_end must NOT fire a second time at the end of the pause. + $repEnds = count(array_filter($ctx->beeps, fn($r) => $r === 'rep_end')); + $pauseEnds = count(array_filter($ctx->beeps, fn($r) => $r === 'pause_end')); + + expect($repEnds)->toBe(1) + ->and($pauseEnds)->toBe(1); + }); // ── Pause beep ──────────────────────────────────────────────────────────────── -test('onPauseBeep fires exactly once when user pauses', function (): void { - Storage::fake(); - $prog = TimerProgram::create('Pause beep test'); - $prog->addPhase(new Phase('Work', 20, 1, 0, 0, '#3b82f6')); - $prog->save(); +test('onPauseBeep fires exactly once when user pauses', + /** + * @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]); - $runner = new TimerRunner(); - $runner->load(TimerProgram::load($prog->id)); + $runner = new TimerRunner(); + $runner->load($prog->load('phases')); - $pauseBeepCount = 0; - $runner->onPauseBeep(function () use (&$pauseBeepCount): void { - $pauseBeepCount++; - }); - - $runner->start(); - $runner->tick(); - $runner->pause(); + $pauseBeepCount = 0; + $runner->onPauseBeep(function () use (&$pauseBeepCount): void { + $pauseBeepCount++; + }); - expect($pauseBeepCount)->toBe(1); -}); + $runner->start(); + for ($i = 0; $i < 5; $i++) $runner->tick(); // skip prepare + $runner->tick(); + $runner->pause(); -test('onPauseBeep does not fire on resume', function (): void { - Storage::fake(); - $prog = TimerProgram::create('Resume test'); - $prog->addPhase(new Phase('Work', 20, 1, 0, 0, '#3b82f6')); - $prog->save(); - - $runner = new TimerRunner(); - $runner->load(TimerProgram::load($prog->id)); - - $pauseBeepCount = 0; - $runner->onPauseBeep(function () use (&$pauseBeepCount): void { - $pauseBeepCount++; + expect($pauseBeepCount)->toBe(1); }); - $runner->start(); - $runner->tick(); - $runner->pause(); - $runner->resume(); // must not fire another pause beep - - expect($pauseBeepCount)->toBe(1); -}); +test('onPauseBeep does not fire on resume', + /** + * @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]); + + $runner = new TimerRunner(); + $runner->load($prog->load('phases')); + + $pauseBeepCount = 0; + $runner->onPauseBeep(function () use (&$pauseBeepCount): void { + $pauseBeepCount++; + }); + + $runner->start(); + for ($i = 0; $i < 5; $i++) $runner->tick(); // skip prepare + $runner->tick(); + $runner->pause(); + $runner->resume(); // must not fire another pause beep + + expect($pauseBeepCount)->toBe(1); + }); diff --git a/tests/Unit/Timer/DurationCalcTest.php b/tests/Unit/Timer/DurationCalcTest.php index cbf699d..3bffc02 100644 --- a/tests/Unit/Timer/DurationCalcTest.php +++ b/tests/Unit/Timer/DurationCalcTest.php @@ -2,29 +2,24 @@ declare(strict_types=1); -use App\Timer\Phase; -use App\Timer\TimerProgram; -use Illuminate\Support\Facades\Storage; +use App\Models\Program; // Helper: build a program with given phases without touching Storage -function makeProgram(array $phaseDefs): TimerProgram +function makeProgram(array $phaseDefs): Program { - Storage::fake(); - - $prog = TimerProgram::create('Test'); - foreach ($phaseDefs as $def) { - $prog->addPhase(new Phase(...$def)); + $prog = Program::create(['name' => 'Test']); + foreach ($phaseDefs as $i => $def) { + $prog->phases()->create(array_merge(['sort_order' => $i], $def)); } - $prog->save(); - return TimerProgram::load($prog->id); + return $prog->load('phases'); } // ── Formula: (duration×reps) + (pause×(reps-1)) + cooldown ────────────────── test('single phase no pause no cooldown', function (): void { $prog = makeProgram([ - ['Work', 30, 1, 0, 0, '#3b82f6'], + ['label' => 'Work', 'duration' => 30, 'repetitions' => 1, 'pause' => 0, 'cooldown' => 0, 'color' => '#3b82f6'], ]); expect($prog->totalDuration())->toBe(30); }); @@ -50,7 +45,7 @@ function makeProgram(array $phaseDefs): TimerProgram $prog = makeProgram([ ['label' => 'Work', 'duration' => 10, 'repetitions' => 2, 'pause' => 5, 'cooldown' => 8, 'color' => '#3b82f6'], ]); - expect($prog->totalDuration())->toBe(33); + expect($prog->totalDuration())->toBe(25); }); test('multiple phases summed correctly', function (): void { @@ -63,15 +58,12 @@ function makeProgram(array $phaseDefs): TimerProgram ['label' => 'Work', 'duration' => 10, 'repetitions' => 3, 'pause' => 5, 'cooldown' => 0, 'color' => '#3b82f6'], ['label' => 'Cool', 'duration' => 20, 'repetitions' => 1, 'pause' => 0, 'cooldown' => 10, 'color' => '#6b7280'], ]); - expect($prog->totalDuration())->toBe(100); + expect($prog->totalDuration())->toBe(90); }); test('zero-duration program returns zero', function (): void { - Storage::fake(); - $prog = TimerProgram::create('Empty'); - $prog->save(); - $loaded = TimerProgram::load($prog->id); - expect($loaded->totalDuration())->toBe(0); + $prog = Program::create(['name' => 'Empty']); + expect($prog->totalDuration())->toBe(0); }); // ── formattedDuration ───────────────────────────────────────────────────────── diff --git a/tests/Unit/Timer/EndSoundTest.php b/tests/Unit/Timer/EndSoundTest.php index d13b277..4ce4a45 100644 --- a/tests/Unit/Timer/EndSoundTest.php +++ b/tests/Unit/Timer/EndSoundTest.php @@ -3,45 +3,37 @@ declare(strict_types=1); use App\Events\ProgramCompleted; -use App\Timer\Phase; -use App\Timer\TimerProgram; +use App\Models\Program; use App\Timer\TimerRunner; use Illuminate\Support\Facades\Event; -use Illuminate\Support\Facades\Storage; // ── ProgramCompleted event ──────────────────────────────────────────────────── test('ProgramCompleted dispatched exactly once on program finish', function (): void { - Storage::fake(); Event::fake([ProgramCompleted::class]); - $prog = TimerProgram::create('End sound test'); - $prog->endSound = 'triple'; - $prog->addPhase(new Phase('Work', 3, 1, 0, 0, '#3b82f6')); - $prog->save(); + $prog = Program::create(['name' => 'End sound test', 'end_sound' => 'triple']); + $prog->phases()->create(['label' => 'Work', 'duration' => 3, 'repetitions' => 1, 'pause' => 0, 'cooldown' => 0, 'color' => '#3b82f6', 'sort_order' => 0]); $runner = new TimerRunner(); - $runner->load(TimerProgram::load($prog->id)); + $runner->load($prog); $runner->start(); - + for ($i = 0; $i < 5; $i++) $runner->tick(); // skip prepare for ($i = 0; $i < 3; $i++) $runner->tick(); Event::assertDispatchedTimes(ProgramCompleted::class, 1); }); test('ProgramCompleted carries correct endSound', function (): void { - Storage::fake(); Event::fake([ProgramCompleted::class]); - $prog = TimerProgram::create('Chime test'); - $prog->endSound = 'chime'; - $prog->addPhase(new Phase('Work', 2, 1, 0, 0, '#3b82f6')); - $prog->save(); + $prog = Program::create(['name' => 'Chime test', 'end_sound' => 'chime']); + $prog->phases()->create(['label' => 'Work', 'duration' => 2, 'repetitions' => 1, 'pause' => 0, 'cooldown' => 0, 'color' => '#3b82f6', 'sort_order' => 0]); $runner = new TimerRunner(); - $runner->load(TimerProgram::load($prog->id)); + $runner->load($prog); $runner->start(); - + for ($i = 0; $i < 5; $i++) $runner->tick(); // skip prepare for ($i = 0; $i < 2; $i++) $runner->tick(); Event::assertDispatched(ProgramCompleted::class, function (ProgramCompleted $e): bool { @@ -50,15 +42,13 @@ }); test('ProgramCompleted not dispatched while timer is still running', function (): void { - Storage::fake(); Event::fake([ProgramCompleted::class]); - $prog = TimerProgram::create('Not done yet'); - $prog->addPhase(new Phase('Work', 10, 1, 0, 0, '#3b82f6')); - $prog->save(); + $prog = Program::create(['name' => 'Not done yet']); + $prog->phases()->create(['label' => 'Work', 'duration' => 10, 'repetitions' => 1, 'pause' => 0, 'cooldown' => 0, 'color' => '#3b82f6', 'sort_order' => 0]); $runner = new TimerRunner(); - $runner->load(TimerProgram::load($prog->id)); + $runner->load($prog); $runner->start(); // Only tick 5 of 10 seconds @@ -68,15 +58,13 @@ }); test('ProgramCompleted not dispatched on discard', function (): void { - Storage::fake(); Event::fake([ProgramCompleted::class]); - $prog = TimerProgram::create('Discard test'); - $prog->addPhase(new Phase('Work', 5, 1, 0, 0, '#3b82f6')); - $prog->save(); + $prog = Program::create(['name' => 'Discard test']); + $prog->phases()->create(['label' => 'Work', 'duration' => 5, 'repetitions' => 1, 'pause' => 0, 'cooldown' => 0, 'color' => '#3b82f6', 'sort_order' => 0]); $runner = new TimerRunner(); - $runner->load(TimerProgram::load($prog->id)); + $runner->load($prog); $runner->start(); $runner->tick(); $runner->discard(); @@ -85,17 +73,15 @@ }); test('ProgramCompleted carries total duration', function (): void { - Storage::fake(); Event::fake([ProgramCompleted::class]); - $prog = TimerProgram::create('Duration test'); - $prog->addPhase(new Phase('Work', 5, 2, 3, 0, '#3b82f6')); // 5+3+5 = 13 - $prog->save(); + $prog = Program::create(['name' => 'Duration test']); + $prog->phases()->create(['label' => 'Work', 'duration' => 5, 'repetitions' => 2, 'pause' => 3, 'cooldown' => 0, 'color' => '#3b82f6', 'sort_order' => 0]); $runner = new TimerRunner(); - $runner->load(TimerProgram::load($prog->id)); + $runner->load($prog); $runner->start(); - + for ($i = 0; $i < 5; $i++) $runner->tick(); // skip prepare // Rep 1 (5) + pause (3) + rep 2 (5) = 13 ticks for ($i = 0; $i < 13; $i++) $runner->tick(); diff --git a/tests/Unit/Timer/LifecycleTest.php b/tests/Unit/Timer/LifecycleTest.php index d1a59b5..15ff78d 100644 --- a/tests/Unit/Timer/LifecycleTest.php +++ b/tests/Unit/Timer/LifecycleTest.php @@ -4,24 +4,20 @@ use App\Enum\StateMachine; use App\Events\ProgramCompleted; -use App\Timer\Phase; -use App\Timer\TimerProgram; +use App\Models\Program; use App\Timer\TimerRunner; use Illuminate\Support\Facades\Event; -use Illuminate\Support\Facades\Storage; // ── Pause resumes from exact position ──────────────────────────────────────── test('pause preserves exact remaining seconds and resumes correctly', function (): void { - Storage::fake(); - - $prog = TimerProgram::create('Lifecycle'); - $prog->addPhase(new Phase('Work', 20, 1, 0, 0, '#3b82f6')); - $prog->save(); + $prog = Program::create(['name' => 'Lifecycle']); + $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); $runner->start(); + for ($i = 0; $i < 5; $i++) $runner->tick(); // skip prepare // Run 7 seconds → 13 remaining for ($i = 0; $i < 7; $i++) $runner->tick(); @@ -42,15 +38,13 @@ // ── Pause mid-pause-segment ──────────────────────────────────────────────────── test('user can pause during inter-rep pause and resume to pause state', function (): void { - Storage::fake(); - - $prog = TimerProgram::create('Mid-pause test'); - $prog->addPhase(new Phase('Work', 5, 2, 10, 0, '#3b82f6')); - $prog->save(); + $prog = Program::create(['name' => 'Mid-pause test']); + $prog->phases()->create(['label' => 'Work', 'duration' => 5, 'repetitions' => 2, 'pause' => 10, 'cooldown' => 0, 'color' => '#3b82f6', 'sort_order' => 0]); $runner = new TimerRunner(); - $runner->load(TimerProgram::load($prog->id)); + $runner->load($prog); $runner->start(); + for ($i = 0; $i < 5; $i++) $runner->tick(); // skip prepare // Finish rep 1 (5 ticks) → enter pause for ($i = 0; $i < 5; $i++) $runner->tick(); @@ -73,15 +67,13 @@ // ── Discard → no history / no event ────────────────────────────────────────── test('discard does not dispatch ProgramCompleted', function (): void { - Storage::fake(); Event::fake([ProgramCompleted::class]); - $prog = TimerProgram::create('Kill test'); - $prog->addPhase(new Phase('Work', 30, 1, 0, 0, '#3b82f6')); - $prog->save(); + $prog = Program::create(['name' => 'Kill test']); + $prog->phases()->create(['label' => 'Work', 'duration' => 30, 'repetitions' => 1, 'pause' => 0, 'cooldown' => 0, 'color' => '#3b82f6', 'sort_order' => 0]); $runner = new TimerRunner(); - $runner->load(TimerProgram::load($prog->id)); + $runner->load($prog); $runner->start(); for ($i = 0; $i < 15; $i++) $runner->tick(); // half-way $runner->discard(); @@ -94,32 +86,27 @@ // ── last_used_at updated on completion only ──────────────────────────────────── test('last_used_at is null before program completes', function (): void { - Storage::fake(); - - $prog = TimerProgram::create('No touch yet'); - $prog->addPhase(new Phase('Work', 5, 1, 0, 0, '#3b82f6')); - $prog->save(); + $prog = Program::create(['name' => 'No touch yet']); + $prog->phases()->create(['label' => 'Work', 'duration' => 5, 'repetitions' => 1, 'pause' => 0, 'cooldown' => 0, 'color' => '#3b82f6', 'sort_order' => 0]); $runner = new TimerRunner(); - $runner->load(TimerProgram::load($prog->id)); + $runner->load($prog); $runner->start(); for ($i = 0; $i < 3; $i++) $runner->tick(); // not done yet - expect(TimerProgram::load($prog->id)->lastUsedAt)->toBeNull(); + expect(Program::find($prog->id)->last_used_at)->toBeNull(); }); test('last_used_at is set after program completes', function (): void { - Storage::fake(); - - $prog = TimerProgram::create('Touch on complete'); - $prog->addPhase(new Phase('Work', 3, 1, 0, 0, '#3b82f6')); - $prog->save(); + $prog = Program::create(['name' => 'Touch on complete']); + $prog->phases()->create(['label' => 'Work', 'duration' => 3, 'repetitions' => 1, 'pause' => 0, 'cooldown' => 0, 'color' => '#3b82f6', 'sort_order' => 0]); $runner = new TimerRunner(); - $runner->load(TimerProgram::load($prog->id)); + $runner->load($prog); $runner->start(); + for ($i = 0; $i < 5; $i++) $runner->tick(); // skip prepare for ($i = 0; $i < 3; $i++) $runner->tick(); expect($runner->cursor()->isCompleted())->toBeTrue(); - expect(TimerProgram::load($prog->id)->lastUsedAt)->not->toBeNull(); + expect(Program::find($prog->id)->last_used_at)->not->toBeNull(); }); diff --git a/tests/Unit/Timer/SettingsTest.php b/tests/Unit/Timer/SettingsTest.php index 51dc4b8..46bfe99 100644 --- a/tests/Unit/Timer/SettingsTest.php +++ b/tests/Unit/Timer/SettingsTest.php @@ -3,81 +3,38 @@ declare(strict_types=1); use App\Enum\BeepLeadIn; -use App\Timer\AppSettings; -use App\Timer\TimerProgram; -use Illuminate\Support\Facades\Storage; +use App\Models\Setting; -beforeEach(function (): void { - Storage::fake(); -}); - -// ── Default values ──────────────────────────────────────────────────────────── +test('settings load with defaults when no row exists', function (): void { + $s = Setting::current(); -test('settings load with defaults when no file exists', function (): void { - $s = AppSettings::load(); - - expect($s->defaultBeepLeadIn)->toBe(BeepLeadIn::Three) - ->and($s->defaultEndSound)->toBe('triple') - ->and($s->soundMode)->toBe('beep') + expect($s->default_beep_lead_in)->toBe(BeepLeadIn::Three) + ->and($s->default_end_sound)->toBe('triple') + ->and($s->sound_mode)->toBe('beep') ->and($s->volume)->toBe(0.8) - ->and($s->keepScreenOn)->toBeTrue(); + ->and($s->keep_screen_on)->toBeTrue(); }); -// ── Save / load round-trip ──────────────────────────────────────────────────── - test('settings save and reload correctly', function (): void { - $s = AppSettings::load(); - $s->defaultBeepLeadIn = BeepLeadIn::Five; - $s->defaultEndSound = 'chime'; - $s->soundMode = 'voice'; - $s->volume = 0.5; - $s->keepScreenOn = false; - $s->save(); - - $loaded = AppSettings::load(); - expect($loaded->defaultBeepLeadIn)->toBe(BeepLeadIn::Five) - ->and($loaded->defaultEndSound)->toBe('chime') - ->and($loaded->soundMode)->toBe('voice') + $s = Setting::current(); + $s->update([ + 'default_beep_lead_in' => BeepLeadIn::Five, + 'default_end_sound' => 'chime', + 'sound_mode' => 'voice', + 'volume' => 0.5, + 'keep_screen_on' => false, + ]); + + $loaded = Setting::current(); + expect($loaded->default_beep_lead_in)->toBe(BeepLeadIn::Five) + ->and($loaded->default_end_sound)->toBe('chime') + ->and($loaded->sound_mode)->toBe('voice') ->and($loaded->volume)->toBe(0.5) - ->and($loaded->keepScreenOn)->toBeFalse(); -}); - -test('settings writes to storage/app/settings.json', function (): void { - $s = AppSettings::load(); - $s->save(); - expect(Storage::exists('settings.json'))->toBeTrue(); -}); - -// ── Per-program seeding ─────────────────────────────────────────────────────── - -test('new program inherits beepLeadIn from settings', function (): void { - $settings = AppSettings::load(); - $settings->defaultBeepLeadIn = BeepLeadIn::Five; - $settings->save(); - - $prog = TimerProgram::create('Seeded'); - expect($prog->beepLeadIn)->toBe(BeepLeadIn::Five); -}); - -test('new program inherits endSound from settings', function (): void { - $settings = AppSettings::load(); - $settings->defaultEndSound = 'chime'; - $settings->save(); - - $prog = TimerProgram::create('Seeded chime'); - expect($prog->endSound)->toBe('chime'); + ->and($loaded->keep_screen_on)->toBeFalse(); }); -test('per-program beepLeadIn can be overridden independently of global', function (): void { - $settings = AppSettings::load(); - $settings->defaultBeepLeadIn = BeepLeadIn::Three; - $settings->save(); - - $prog = TimerProgram::create('Override'); - $prog->beepLeadIn = BeepLeadIn::Five; - $prog->save(); - - $loaded = TimerProgram::load($prog->id); - expect($loaded->beepLeadIn)->toBe(BeepLeadIn::Five) - ->and(AppSettings::load()->defaultBeepLeadIn)->toBe(BeepLeadIn::Three); +test('settings.current() creates a DB row', function (): void { + expect(Setting::count())->toBe(0); + Setting::current(); + expect(Setting::count())->toBe(1); }); diff --git a/tests/Unit/Timer/TimerProgramTest.php b/tests/Unit/Timer/TimerProgramTest.php index 2490a72..744762e 100644 --- a/tests/Unit/Timer/TimerProgramTest.php +++ b/tests/Unit/Timer/TimerProgramTest.php @@ -3,54 +3,43 @@ declare(strict_types=1); use App\Enum\BeepLeadIn; -use App\Timer\AppSettings; -use App\Timer\Phase; -use App\Timer\TimerProgram; -use Illuminate\Support\Facades\Storage; - -beforeEach(function (): void { - Storage::fake(); - - $s = AppSettings::load(); - $s->defaultBeepLeadIn = BeepLeadIn::Three; - $s->defaultEndSound = 'triple'; - $s->save(); -}); - -// ── create() ────────────────────────────────────────────────────────────────── - -test('create seeds beepLeadIn and endSound from settings', function (): void { - $prog = TimerProgram::create('My Workout'); - - expect($prog->beepLeadIn)->toBe(BeepLeadIn::Three) - ->and($prog->endSound)->toBe('triple') - ->and($prog->name)->toBe('My Workout') - ->and($prog->phases)->toBeArray()->toBeEmpty(); -}); +use App\Models\Program; +use Illuminate\Database\Eloquent\ModelNotFoundException; test('create assigns a uuid id', function (): void { - $prog = TimerProgram::create('Test'); - expect($prog->id)->toBeString()->not->toBeEmpty(); + $prog = Program::create(['name' => 'Test']); + expect($prog->id)->toBeString()->not->toBeEmpty()->toHaveLength(36); }); -// ── save() / load() round-trip (pipe-operator chain) ───────────────────────── +test('program is persisted and retrievable by id', function (): void { + $prog = Program::create(['name' => 'Persist Test']); -test('save writes json file under programs directory', function (): void { - $prog = TimerProgram::create('Test'); - $prog->save(); - expect(Storage::exists("programs/{$prog->id}.json"))->toBeTrue(); + $found = Program::find($prog->id); + expect($found)->not->toBeNull() + ->and($found->name)->toBe('Persist Test'); }); -test('load returns same data that was saved', function (): void { - $prog = TimerProgram::create('Round-trip'); - $prog->addPhase(new Phase('Work', 30, 3, 5, 10, '#3b82f6')); - $prog->save(); - - $loaded = TimerProgram::load($prog->id); +test('findOrFail returns same data that was saved', function (): void { + $prog = Program::create([ + 'name' => 'Round-trip', + 'beep_lead_in' => BeepLeadIn::Three, + 'end_sound' => 'triple', + ]); + $prog->phases()->create([ + 'label' => 'Work', + 'duration' => 30, + 'repetitions' => 3, + 'pause' => 5, + 'cooldown' => 10, + 'color' => '#3b82f6', + 'sort_order' => 0, + ]); + + $loaded = Program::findOrFail($prog->id); expect($loaded->name)->toBe('Round-trip') - ->and($loaded->beepLeadIn)->toBe(BeepLeadIn::Three) - ->and($loaded->endSound)->toBe('triple') + ->and($loaded->beep_lead_in)->toBe(BeepLeadIn::Three) + ->and($loaded->end_sound)->toBe('triple') ->and($loaded->phases)->toHaveCount(1) ->and($loaded->phases[0]->label)->toBe('Work') ->and($loaded->phases[0]->duration)->toBe(30) @@ -59,69 +48,63 @@ ->and($loaded->phases[0]->cooldown)->toBe(10); }); -test('load throws RuntimeException for unknown id', function (): void { - expect(fn () => TimerProgram::load('does-not-exist'))->toThrow(\RuntimeException::class); +test('findOrFail throws ModelNotFoundException for unknown id', function (): void { + expect(fn () => Program::findOrFail('00000000-0000-0000-0000-000000000000'))->toThrow(ModelNotFoundException::class); }); -test('loaded program is an instance of TimerProgram', function (): void { - $prog = TimerProgram::create('Pipe test'); - $prog->save(); - expect(TimerProgram::load($prog->id))->toBeInstanceOf(TimerProgram::class); +test('loaded program is an instance of Program', function (): void { + $prog = Program::create(['name' => 'Type test']); + expect(Program::find($prog->id))->toBeInstanceOf(Program::class); }); -// ── addPhase / 10-phase cap ─────────────────────────────────────────────────── - test('can add up to 10 phases', function (): void { - $prog = TimerProgram::create('10-phases'); + $prog = Program::create(['name' => '10-phases']); for ($i = 0; $i < 10; $i++) { - $prog->addPhase(new Phase("Phase {$i}", 10, 1, 0, 0, '#3b82f6')); + $prog->addPhase(['label' => "Phase {$i}", 'duration' => 10, 'repetitions' => 1, 'pause' => 0, 'cooldown' => 0, 'color' => '#3b82f6']); } - expect($prog->phases)->toHaveCount(10); + expect($prog->phases()->count())->toBe(10); }); test('adding 11th phase throws OverflowException', function (): void { - $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('Too many', 5, 1, 0, 0, '#3b82f6'))) + expect(fn () => $prog->addPhase(['label' => 'Too many', 'duration' => 5, 'repetitions' => 1, 'pause' => 0, 'cooldown' => 0, 'color' => '#3b82f6'])) ->toThrow(\OverflowException::class); }); -// ── Phase 50-rep cap ────────────────────────────────────────────────────────── - test('phase with 50 repetitions is valid', function (): void { - $phase = new Phase('Max reps', 10, 50, 5, 0, '#3b82f6'); + $prog = Program::create(['name' => 'Reps test']); + $phase = $prog->phases()->create(['label' => 'Max reps', 'duration' => 10, 'repetitions' => 50, 'pause' => 5, 'cooldown' => 0, 'color' => '#3b82f6', 'sort_order' => 0]); expect($phase->repetitions)->toBe(50); }); test('phase with 51 repetitions throws RangeException', function (): void { - expect(fn () => new Phase('Too many reps', 10, 51, 0, 0, '#3b82f6')) + $prog = Program::create(['name' => 'Reps test']); + expect(fn () => $prog->phases()->create(['label' => 'Too many reps', 'duration' => 10, 'repetitions' => 51, 'pause' => 0, 'cooldown' => 0, 'color' => '#3b82f6', 'sort_order' => 0])) ->toThrow(\RangeException::class); }); test('phase with 0 repetitions throws RangeException', function (): void { - expect(fn () => new Phase('Zero reps', 10, 0, 0, 0, '#3b82f6')) + $prog = Program::create(['name' => 'Reps test']); + expect(fn () => $prog->phases()->create(['label' => 'Zero reps', 'duration' => 10, 'repetitions' => 0, 'pause' => 0, 'cooldown' => 0, 'color' => '#3b82f6', 'sort_order' => 0])) ->toThrow(\RangeException::class); }); -// ── all() ───────────────────────────────────────────────────────────────────── - test('all returns all saved programs', function (): void { - $a = TimerProgram::create('A'); $a->save(); - $b = TimerProgram::create('B'); $b->save(); - expect(TimerProgram::all())->toHaveCount(2); + Program::create(['name' => 'A']); + Program::create(['name' => 'B']); + expect(Program::all())->toHaveCount(2); }); -test('all returns empty array when no programs exist', function (): void { - expect(TimerProgram::all())->toBeArray()->toBeEmpty(); +test('all returns empty collection when no programs exist', function (): void { + expect(Program::all())->toBeEmpty(); }); -// ── delete() ───────────────────────────────────────────────────────────────── - -test('delete removes the json file', function (): void { - $prog = TimerProgram::create('Delete me'); - $prog->save(); +test('delete removes the program from database', function (): void { + $prog = Program::create(['name' => 'Delete me']); + $id = $prog->id; $prog->delete(); - expect(Storage::exists("programs/{$prog->id}.json"))->toBeFalse(); + expect(Program::find($id))->toBeNull(); }); diff --git a/tests/Unit/Timer/TimerRunnerTest.php b/tests/Unit/Timer/TimerRunnerTest.php index d8f59f8..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 ─────────────────────────────────────────────────────────────────── @@ -16,23 +14,49 @@ function freshRunner(): TimerRunner return new TimerRunner(); } -function onePhaseProgram(int $duration = 10, int $reps = 1, int $pause = 0, int $cooldown = 0): TimerProgram +/** Tick through the 5-second PREPARE countdown so the runner enters RUNNING. */ +function skipPrepare(TimerRunner $runner): void { - Storage::fake(); - $prog = TimerProgram::create('Single Phase'); - $prog->addPhase(new Phase('Work', $duration, $reps, $pause, $cooldown, '#3b82f6')); - $prog->save(); - return TimerProgram::load($prog->id); + for ($i = 0; $i < 5; $i++) $runner->tick(); } -function twoPhaseProgram(): TimerProgram +function onePhaseProgram(int $duration = 10, int $reps = 1, int $pause = 0, int $cooldown = 0): 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' => '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(): Program +{ + $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 ──────────────────────────────────────────────────────────────── @@ -49,34 +73,53 @@ 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 → running ──────────────────────────────────────────────────────────── +// ── idle → prepare ──────────────────────────────────────────────────────────── + +test('start transitions to prepare state with 5s remaining', function (): void { + $runner = freshRunner(); + $runner->load(onePhaseProgram(10)); + $runner->start(); + + expect($runner->cursor()->state)->toBe(StateMachine::prepare) + ->and($runner->cursor()->remaining)->toBe(5); +}); -test('start transitions to running state', function (): void { +test('after 5 prepare ticks runner enters running state', function (): void { $runner = freshRunner(); $runner->load(onePhaseProgram(10)); $runner->start(); + skipPrepare($runner); expect($runner->cursor()->state)->toBe(StateMachine::running) ->and($runner->cursor()->remaining)->toBe(10); }); +test('pause is a no-op during prepare', function (): void { + $runner = freshRunner(); + $runner->load(onePhaseProgram(10)); + $runner->start(); + $runner->pause(); + + expect($runner->cursor()->state)->toBe(StateMachine::prepare); +}); + // ── running → pause (inter-rep) ─────────────────────────────────────────────── test('tick at rep end with pause configured enters pause state', function (): void { $runner = freshRunner(); $runner->load(onePhaseProgram(duration: 5, reps: 2, pause: 3)); $runner->start(); + skipPrepare($runner); for ($i = 0; $i < 5; $i++) $runner->tick(); @@ -90,6 +133,7 @@ function twoPhaseProgram(): TimerProgram $runner = freshRunner(); $runner->load(onePhaseProgram(duration: 5, reps: 2, pause: 3)); $runner->start(); + skipPrepare($runner); for ($i = 0; $i < 5; $i++) $runner->tick(); for ($i = 0; $i < 3; $i++) $runner->tick(); @@ -100,15 +144,16 @@ function twoPhaseProgram(): TimerProgram // ── running → cooldown ──────────────────────────────────────────────────────── -test('last rep with cooldown enters cooldown state', function (): void { +test('last rep with cooldown does not enter cooldown state', function (): void { $runner = freshRunner(); - $runner->load(onePhaseProgram(duration: 5, reps: 1, cooldown: 4)); + $runner->load(onePhaseProgram(duration: 5, cooldown: 4)); $runner->start(); + skipPrepare($runner); for ($i = 0; $i < 5; $i++) $runner->tick(); - expect($runner->cursor()->state)->toBe(StateMachine::cooldown) - ->and($runner->cursor()->remaining)->toBe(4); + expect($runner->cursor()->state)->toBe(StateMachine::completed) + ->and($runner->cursor()->remaining)->toBe(0); }); // ── cooldown → completed (single phase) ─────────────────────────────────────── @@ -117,9 +162,10 @@ 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); $runner->onTick(function (TimerCursor $c) use (&$completed): void { if ($c->isCompleted()) $completed = true; @@ -137,6 +183,7 @@ function twoPhaseProgram(): TimerProgram $runner = freshRunner(); $runner->load(twoPhaseProgram()); $runner->start(); + skipPrepare($runner); for ($i = 0; $i < 5; $i++) $runner->tick(); // rep 0 for ($i = 0; $i < 2; $i++) $runner->tick(); // pause @@ -153,6 +200,7 @@ function twoPhaseProgram(): TimerProgram $runner = freshRunner(); $runner->load(onePhaseProgram(10)); $runner->start(); + skipPrepare($runner); $runner->tick(); $runner->pause(); @@ -164,6 +212,7 @@ function twoPhaseProgram(): TimerProgram $runner = freshRunner(); $runner->load(onePhaseProgram(10)); $runner->start(); + skipPrepare($runner); $runner->tick(); $runner->pause(); $runner->resume(); @@ -176,6 +225,7 @@ function twoPhaseProgram(): TimerProgram $runner = freshRunner(); $runner->load(onePhaseProgram(duration: 5, reps: 2, pause: 5)); $runner->start(); + skipPrepare($runner); for ($i = 0; $i < 5; $i++) $runner->tick(); // into pause state $runner->pause(); @@ -187,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); }); diff --git a/version.json b/version.json new file mode 100644 index 0000000..a25a174 --- /dev/null +++ b/version.json @@ -0,0 +1,4 @@ +{ + "version": "1.0.0", + "version_code": 1 +} 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 2/4] 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 3/4] 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 cf981a43de19a9208d7e59607dc1637fd3c1bed1 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 4/4] 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": {