From a00ba22d0c5e650b9658d926caa5da81aa3c2b50 Mon Sep 17 00:00:00 2001 From: naingaunglwin-dev Date: Tue, 4 Nov 2025 05:07:09 +0630 Subject: [PATCH 01/14] feat:implement_toString_for_result_class --- src/Result.php | 8 ++++++++ tests/TimeTrackerTest.php | 6 ++++++ 2 files changed, 14 insertions(+) diff --git a/src/Result.php b/src/Result.php index 7aebad3..66b0439 100644 --- a/src/Result.php +++ b/src/Result.php @@ -118,4 +118,12 @@ private function _convert(float|int $from, float|int $to, string $operator, bool default => throw new UnsupportedLogic("Unsupported operator '{$operator}' in unit definition."), }; } + + /** + * @return string + */ + public function __toString(): string + { + return $this->calculated; + } } diff --git a/tests/TimeTrackerTest.php b/tests/TimeTrackerTest.php index ae7caf3..2b4269d 100644 --- a/tests/TimeTrackerTest.php +++ b/tests/TimeTrackerTest.php @@ -250,4 +250,10 @@ public function testGetUnitDefinitions(): void $this->assertSame(['operator' => '*', 'value' => 1000], $definition); } + + public function testResultToString(): void + { + $result = new Result(new Unit(), 10, 's'); + $this->assertSame('10', "$result"); + } } From 356ba1544a33b270a8015641263706d08928a9f3 Mon Sep 17 00:00:00 2001 From: naingaunglwin-dev Date: Tue, 4 Nov 2025 23:25:51 +0630 Subject: [PATCH 02/14] feat:implement_stop()_to_replace_deprecated_end() --- .../NoActiveTimerToStopException.php | 13 +++++ src/TimeTracker.php | 37 ++++++++++++++ tests/TimeTrackerTest.php | 49 ++++++++++++++++--- 3 files changed, 92 insertions(+), 7 deletions(-) create mode 100644 src/Exception/NoActiveTimerToStopException.php diff --git a/src/Exception/NoActiveTimerToStopException.php b/src/Exception/NoActiveTimerToStopException.php new file mode 100644 index 0000000..0dd8200 --- /dev/null +++ b/src/Exception/NoActiveTimerToStopException.php @@ -0,0 +1,13 @@ +end[$id] = microtime(true); } + /** + * Stops the specified timer or, if no ID is provided, the most recently started timer. + * + * @param string|null $id The identifier of the timer to stop, or null to stop the last started timer. + * + * @throws TimerNotStarted If no timer has been started, or the specified timer ID does not exist. + * @throws \RuntimeException If the specified timer has already been stopped. + * + * @return void + */ + public function stop(?string $id = null): void + { + if (empty($this->start)) { + throw new TimerNotStarted($id); + } + + if ($id === null) { + $id = array_key_last($this->start); + } + + if (!isset($this->start[$id])) { + throw new TimerNotStarted($id); + } + + if (isset($this->end[$id])) { + throw new NoActiveTimerToStopException(); + } + + $this->end[$id] = microtime(true); + } + /** * Executes a callback while tracking its execution time. * diff --git a/tests/TimeTrackerTest.php b/tests/TimeTrackerTest.php index 2b4269d..8c9b8d2 100644 --- a/tests/TimeTrackerTest.php +++ b/tests/TimeTrackerTest.php @@ -1,5 +1,6 @@ start($id); usleep(50000); // 50ms delay - $tracker->end($id); + $tracker->stop($id); $result = $tracker->calculate($id); @@ -32,7 +33,7 @@ public function testEndWithInvalidId() $this->expectException(TimerNotStarted::class); $tracker = new TimeTracker(); - $tracker->end('invalid_timer'); + $tracker->stop('invalid_timer'); } public function testCalculateWithInvalidId() @@ -54,7 +55,7 @@ public function testStatus(): void $tracker->start($id); $this->assertSame(TimeTracker::STATUS_IN_PROGRESS, $tracker->status($id)); - $tracker->end($id); + $tracker->stop($id); $this->assertSame(TimeTracker::STATUS_COMPLETED, $tracker->status($id)); } @@ -123,11 +124,11 @@ public function testDurations(): void $tracker->start($id1); usleep(10000); // 10ms delay - $tracker->end($id1); + $tracker->stop($id1); $tracker->start($id2); usleep(20000); // 20ms delay - $tracker->end($id2); + $tracker->stop($id2); $durations = $tracker->durations(); @@ -143,8 +144,8 @@ public function testResetFunctionality(): void // Start multiple timers $timeTracker->start('timer1'); $timeTracker->start('timer2'); - $timeTracker->end('timer1'); - $timeTracker->end('timer2'); + $timeTracker->stop('timer1'); + $timeTracker->stop('timer2'); // Verify timers exist $this->assertTrue($timeTracker->exists('timer1')); @@ -256,4 +257,38 @@ public function testResultToString(): void $result = new Result(new Unit(), 10, 's'); $this->assertSame('10', "$result"); } + + public function testStopWithoutSpecificId() + { + $timetracker = new TimeTracker(); + + $timetracker->start('timer1'); + + usleep(10000); // 10ms delay + + $timetracker->stop(); + + $this->assertTrue($timetracker->exists('timer1')); + } + + public function testStopThrowExceptionOnCallingWithoutActiveStartRecord() + { + $this->expectException(NoActiveTimerToStopException::class); + + $timetracker = new TimeTracker(); + + $timetracker->start('timer1'); + $timetracker->stop(); + $timetracker->stop(); + } + + public function testStopThrowExceptionOnCallingWithoutStartRecord() + { + $this->expectException(TimerNotStarted::class); + + $timetracker = new TimeTracker(); + + $timetracker->start('timer1'); + $timetracker->stop('timer2'); //non-existing timer + } } From d8021bd94b9cf82cabb377d3edd47a661ff0689e Mon Sep 17 00:00:00 2001 From: naingaunglwin-dev Date: Wed, 5 Nov 2025 00:26:50 +0630 Subject: [PATCH 03/14] refactor:replace_ramsey_uuid_with_native_php_functions --- composer.json | 1 - src/TimeTracker.php | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/composer.json b/composer.json index 47c0ae6..5b49516 100644 --- a/composer.json +++ b/composer.json @@ -19,7 +19,6 @@ }, "require": { "php": ">=8.3", - "ramsey/uuid": "^4.7", "illuminate/container": "^11.36" }, "require-dev": { diff --git a/src/TimeTracker.php b/src/TimeTracker.php index a2e1e38..3417f12 100644 --- a/src/TimeTracker.php +++ b/src/TimeTracker.php @@ -8,7 +8,6 @@ use NAL\TimeTracker\Exception\NoActiveTimerToStopException; use NAL\TimeTracker\Exception\TimerNotStarted; use NAL\TimeTracker\Exception\UnsupportedLogic; -use Ramsey\Uuid\Uuid; class TimeTracker { @@ -130,7 +129,7 @@ public static function run(callable $callback, array $params = [], string $unit { $timeTracker = new self(); - $randomId = Uuid::uuid4()->toString(); + $randomId = bin2hex(random_bytes(16)); $container = new Container(); From 2cb648db67b18ffc4e0e2ddbdb9f6b321ccf11c6 Mon Sep 17 00:00:00 2001 From: naingaunglwin-dev Date: Wed, 5 Nov 2025 12:39:34 +0630 Subject: [PATCH 04/14] refactor:rename_static_method_from_run()_to_watch() --- README.md | 2 +- src/TimeTracker.php | 6 +++--- tests/TimeTrackerTest.php | 8 ++++---- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index f8a9b56..52cadb3 100644 --- a/README.md +++ b/README.md @@ -132,7 +132,7 @@ echo $tracker->calculate('test') ### Time tracking with callback function - You can track time for a callback function and get both the execution time and the result: ```php -$result = \NAL\TimeTracker\TimeTracker::run( +$result = \NAL\TimeTracker\TimeTracker::watch( function (Conversation $conv, $time) { sleep(3); return $conv->greet($time) . '
do something at ' . $time; diff --git a/src/TimeTracker.php b/src/TimeTracker.php index 3417f12..03af7a4 100644 --- a/src/TimeTracker.php +++ b/src/TimeTracker.php @@ -125,7 +125,7 @@ public function stop(?string $id = null): void * @param string $unit The unit for measuring execution time. * @return array{result: Result, time: float|int, unit: string, output: mixed} An array containing Result, the execution time, unit, and callback result. */ - public static function run(callable $callback, array $params = [], string $unit = 's'): array + public static function watch(callable $callback, array $params = [], string $unit = 's'): array { $timeTracker = new self(); @@ -140,7 +140,7 @@ public static function run(callable $callback, array $params = [], string $unit $output = $container->call($callback, $params); } catch (\Throwable $e) { - $timeTracker->end($randomId); + $timeTracker->stop($randomId); throw new \RuntimeException( $timeTracker->calculate($randomId)->format('Error occurring during executing callback, end in %s%s')->get() . @@ -149,7 +149,7 @@ public static function run(callable $callback, array $params = [], string $unit $e ); } finally { - $timeTracker->end($randomId); + $timeTracker->stop($randomId); } $result = $timeTracker->calculate($randomId); diff --git a/tests/TimeTrackerTest.php b/tests/TimeTrackerTest.php index 8c9b8d2..04b4a2e 100644 --- a/tests/TimeTrackerTest.php +++ b/tests/TimeTrackerTest.php @@ -59,9 +59,9 @@ public function testStatus(): void $this->assertSame(TimeTracker::STATUS_COMPLETED, $tracker->status($id)); } - public function testRun(): void + public function testWatch(): void { - $result = TimeTracker::run(function () { + $result = TimeTracker::watch(function () { usleep(50000); // 50ms delay }); @@ -72,11 +72,11 @@ public function testRun(): void $this->assertGreaterThan(0, $result['time']); } - public function testRunWithExceptionThrow() + public function testWatchWithExceptionThrow() { $this->expectException(RuntimeException::class); - $result = TimeTracker::run(function () { + $result = TimeTracker::watch(function () { usleep(50000); // 50ms delay invalidFunction(); }); From 8835ec7647a3ed6e70060cfb409075d2d87e8a21 Mon Sep 17 00:00:00 2001 From: Naing Aung Lwin <126607909+naingaunglwin-dev@users.noreply.github.com> Date: Wed, 5 Nov 2025 13:02:15 +0630 Subject: [PATCH 05/14] refactor:move_timer_status_to_dedicated_Enum_class (#5) --- src/TimeTracker.php | 27 +++------------------------ src/TimerStatus.php | 10 ++++++++++ 2 files changed, 13 insertions(+), 24 deletions(-) create mode 100644 src/TimerStatus.php diff --git a/src/TimeTracker.php b/src/TimeTracker.php index 03af7a4..9809225 100644 --- a/src/TimeTracker.php +++ b/src/TimeTracker.php @@ -25,27 +25,6 @@ class TimeTracker */ private array $end = []; - /** - * Indicates a timer that has not been started. - * - * @var string - */ - const string STATUS_NOT_STARTED = 'not started'; - - /** - * Indicates a timer that is in progress. - * - * @var string - */ - const string STATUS_IN_PROGRESS = 'in progress'; - - /** - * Indicates a timer that has been completed. - * - * @var string - */ - const string STATUS_COMPLETED = 'completed'; - private Unit $unit; /** @@ -221,14 +200,14 @@ public function reset(?string $id = null): void public function status(string $id): string { if (!isset($this->start[$id])) { - return self::STATUS_NOT_STARTED; + return TimerStatus::NOT_STARTED->value; } if (!isset($this->end[$id])) { - return self::STATUS_IN_PROGRESS; + return TimerStatus::IN_PROGRESS->value; } - return self::STATUS_COMPLETED; + return TimerStatus::COMPLETED->value; } /** diff --git a/src/TimerStatus.php b/src/TimerStatus.php new file mode 100644 index 0000000..34cc7bb --- /dev/null +++ b/src/TimerStatus.php @@ -0,0 +1,10 @@ + Date: Fri, 7 Nov 2025 02:28:58 +0630 Subject: [PATCH 06/14] fix:update_timer_status_constant_with_enum_in_test_cases (#6) --- tests/TimeTrackerTest.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/TimeTrackerTest.php b/tests/TimeTrackerTest.php index 04b4a2e..a925861 100644 --- a/tests/TimeTrackerTest.php +++ b/tests/TimeTrackerTest.php @@ -2,6 +2,7 @@ use NAL\TimeTracker\Exception\NoActiveTimerToStopException; use NAL\TimeTracker\Exception\TimerNotStarted; +use NAL\TimeTracker\TimerStatus; use PHPUnit\Framework\TestCase; use NAL\TimeTracker\TimeTracker; use NAL\TimeTracker\Result; @@ -50,13 +51,13 @@ public function testStatus(): void $tracker = new TimeTracker(); $id = 'test_timer'; - $this->assertSame(TimeTracker::STATUS_NOT_STARTED, $tracker->status($id)); + $this->assertSame(TimerStatus::NOT_STARTED->value, $tracker->status($id)); $tracker->start($id); - $this->assertSame(TimeTracker::STATUS_IN_PROGRESS, $tracker->status($id)); + $this->assertSame(TimerStatus::IN_PROGRESS->value, $tracker->status($id)); $tracker->stop($id); - $this->assertSame(TimeTracker::STATUS_COMPLETED, $tracker->status($id)); + $this->assertSame(TimerStatus::COMPLETED->value, $tracker->status($id)); } public function testWatch(): void From 354eb66ecc5ac268ee4a93a39e0237634f829bda Mon Sep 17 00:00:00 2001 From: Naing Aung Lwin <126607909+naingaunglwin-dev@users.noreply.github.com> Date: Fri, 7 Nov 2025 03:13:56 +0630 Subject: [PATCH 07/14] feat:add_new_methods_in_timetracker (#7) New methods, 1. isStarted() 2. isStopped() 3. getActiveTimers() --- src/TimeTracker.php | 47 +++++++++++++++++++++++++++++++++++++++ tests/TimeTrackerTest.php | 39 ++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+) diff --git a/src/TimeTracker.php b/src/TimeTracker.php index 9809225..fb55a8d 100644 --- a/src/TimeTracker.php +++ b/src/TimeTracker.php @@ -96,6 +96,53 @@ public function stop(?string $id = null): void $this->end[$id] = microtime(true); } + /** + * Check whether timer with given id is started or not + * + * @param string $id + * + * @return bool + */ + public function isStarted(string $id): bool + { + return array_key_exists($id, $this->start); + } + + /** + * Check whether timer with given id is ended or not + * + * @param string $id + * + * @return bool + */ + public function isStopped(string $id): bool + { + return array_key_exists($id, $this->end); + } + + /** + * Get the current active timers' id + * + * @return array + */ + public function getActiveTimers(): array + { + if ($this->start === []) { + return []; + } + + $ids = array_keys($this->start); + $activeTimers = []; + + foreach ($ids as $id) { + if (!$this->isStopped($id)) { + $activeTimers[] = $id; + } + } + + return $activeTimers; + } + /** * Executes a callback while tracking its execution time. * diff --git a/tests/TimeTrackerTest.php b/tests/TimeTrackerTest.php index a925861..3364b7c 100644 --- a/tests/TimeTrackerTest.php +++ b/tests/TimeTrackerTest.php @@ -292,4 +292,43 @@ public function testStopThrowExceptionOnCallingWithoutStartRecord() $timetracker->start('timer1'); $timetracker->stop('timer2'); //non-existing timer } + + public function testIsStarted() + { + $timetracker = new TimeTracker(); + + $timetracker->start('timer1'); + + $this->assertTrue($timetracker->isStarted('timer1')); + $this->assertFalse($timetracker->isStarted('timer2')); + } + + public function testIsStopped() + { + $timetracker = new TimeTracker(); + + $timetracker->start('timer1'); + usleep(10000); + $timetracker->stop('timer1'); + + $this->assertTrue($timetracker->isStopped('timer1')); + $this->assertFalse($timetracker->isStopped('timer2')); + } + + public function testGetEmptyActiveTimersWhenNoActiveRecord() + { + $timetracker = new TimeTracker(); + + $this->assertEmpty($timetracker->getActiveTimers()); + } + + public function testGetActiveTimers() + { + $timetracker = new TimeTracker(); + + $timetracker->start('timer1'); + $timetracker->start('timer2'); + + $this->assertSame(['timer1', 'timer2'], $timetracker->getActiveTimers()); + } } From f1429b0c03318e392c982e86858afc1a3622b5ce Mon Sep 17 00:00:00 2001 From: Naing Aung Lwin <126607909+naingaunglwin-dev@users.noreply.github.com> Date: Mon, 10 Nov 2025 21:32:12 +0630 Subject: [PATCH 08/14] fix:add_isStopped_check_in_finally_block_to_prevent_duplicate_stop_calls (#8) --- src/TimeTracker.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/TimeTracker.php b/src/TimeTracker.php index fb55a8d..9de9f44 100644 --- a/src/TimeTracker.php +++ b/src/TimeTracker.php @@ -175,7 +175,9 @@ public static function watch(callable $callback, array $params = [], string $uni $e ); } finally { - $timeTracker->stop($randomId); + if (!$timeTracker->isStopped($randomId)) { + $timeTracker->stop($randomId); + } } $result = $timeTracker->calculate($randomId); From 68e482eefb839f75f960471d6a98ecf4a75c1f93 Mon Sep 17 00:00:00 2001 From: naingaunglwin-dev Date: Mon, 10 Nov 2025 23:10:58 +0630 Subject: [PATCH 09/14] docs:update_README_to_include_new_methods --- README.md | 115 ++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 77 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 52cadb3..957e756 100644 --- a/README.md +++ b/README.md @@ -17,13 +17,25 @@ - composer ## Installation via Composer -To install, navigate to your project root directory (where `composer.json` is located) and run the following command: -```shell - composer require naingaunglwin-dev/timetracker +> If Composer is not installed, follow the [official guide](https://getcomposer.org/download/). + +1. Create a `composer.json` file at your project root directory (if you don't have one): +```json +{ + "require": { + "naingaunglwin-dev/timetracker": "^1.0" + } +} +``` + +- Run the following command in your terminal from the project's root directory: +```bash +composer install ``` -- If `composer.json` doesn't exits, run this command first, -```shell - composer init + +If you already have `composer.json` file in your project, just run this command in your terminal, +```bash +composer require naingaunglwin-dev/timetracker ``` ## Usage @@ -40,7 +52,7 @@ $tracker->start('test'); echo 'hello world
'; sleep(3); -$tracker->end('test'); +$tracker->stop('test'); echo $tracker->calculate('test') ->get(); @@ -53,18 +65,12 @@ echo $tracker->calculate('test') ### Convert to different unit - By default, the unit is in seconds (s). You can convert to other predefined units like milliseconds (ms), microseconds (us), and more: ```php -start('test'); echo 'hello world
'; sleep(3); -$tracker->end('test'); +$tracker->stop('test'); echo $tracker->calculate('test') ->convert('ms') @@ -78,18 +84,12 @@ echo $tracker->calculate('test') ### Add custom unit - You can define custom units based on seconds (for example, converting seconds to custom units): ```php -start('test'); echo 'hello world
'; sleep(3); -$tracker->end('test'); +$tracker->stop('test'); // Add a custom unit definition (1 second = 10 custom units) $tracker->addUnitDefinition('testunit', '*', 10); @@ -106,18 +106,12 @@ echo $tracker->calculate('test') ### Format output - You can format the output of the calculated time using placeholders: ```php -start('test'); echo 'hello world
'; sleep(3); -$tracker->end('test'); +$tracker->stop('test'); echo $tracker->calculate('test') ->convert('ms') @@ -132,15 +126,6 @@ echo $tracker->calculate('test') ### Time tracking with callback function - You can track time for a callback function and get both the execution time and the result: ```php -$result = \NAL\TimeTracker\TimeTracker::watch( - function (Conversation $conv, $time) { - sleep(3); - return $conv->greet($time) . '
do something at ' . $time; - }, - ['time' => 'evening'], //parameters variableName => value - 'ms' // time unit, default is `s` -); - class Conversation { public function greet($time){ @@ -148,7 +133,14 @@ class Conversation } } -var_dump($result); +$watch = \NAL\TimeTracker\TimeTracker::watch( + function (Conversation $conv, $time) { + sleep(3); + return $conv->greet($time) . '
do something at ' . $time; + }, + ['time' => 'evening'], //parameters variableName => value + 'ms' // time unit, default is `s` +); ``` - Example output: ```php @@ -160,3 +152,50 @@ array (size=4) 'unit' => string 'ms' (length=2) 'output' => string 'good evening, do something at evening' (length=37) ``` + +### Checking timer states + +The following methods help you check timer states and get currently active timers. + +#### Check if a timer has started +```php +$tracker->start('download'); + +if ($tracker->isStarted('download')) { + echo "Download timer is started."; +} + +// Output: +// Download timer is started. +``` + +#### Check if a timer has stopped +```php +$tracker->start('process'); + +sleep(1); + +$tracker->stop('process'); + +if ($tracker->isStopped('process')) { + echo "Process timer is stopped."; +} + +// Output: +// Process timer is stopped. +``` + +#### Get currently active timers +```php +$tracker->start('task1'); +$tracker->start('task2'); +$tracker->stop('task1'); + +print_r($tracker->getActiveTimers()); + +// Output: +// Array +// ( +// [0] => task2 +// ) +``` From 0efdebcf3a4712b97c0f16621ef1533abce78211 Mon Sep 17 00:00:00 2001 From: Naing Aung Lwin <126607909+naingaunglwin-dev@users.noreply.github.com> Date: Tue, 9 Dec 2025 14:31:43 +0630 Subject: [PATCH 10/14] feat: add new methods in timetracker (#9) new methods: - lap - getLaps - pause - resume - inspect --- src/Exception/NoActivePausedTimerToResume.php | 11 + src/Exception/TimerAlreadyPaused.php | 13 ++ src/Exception/UnmatchedPauseWithoutResume.php | 13 ++ src/TimeTracker.php | 171 ++++++++++++++- tests/TimeTrackerTest.php | 203 ++++++++++++++++++ 5 files changed, 410 insertions(+), 1 deletion(-) create mode 100644 src/Exception/NoActivePausedTimerToResume.php create mode 100644 src/Exception/TimerAlreadyPaused.php create mode 100644 src/Exception/UnmatchedPauseWithoutResume.php diff --git a/src/Exception/NoActivePausedTimerToResume.php b/src/Exception/NoActivePausedTimerToResume.php new file mode 100644 index 0000000..f103257 --- /dev/null +++ b/src/Exception/NoActivePausedTimerToResume.php @@ -0,0 +1,11 @@ +end[$id] = microtime(true); } + /** + * Records a lap time for the specified timer. + * + * A lap represents a checkpoint within an ongoing timer session. Each lap is recorded + * with its timestamp and an optional description for context. + * + * @param string $id The identifier of the timer. + * @param string $description An optional label or note for this lap (default: empty string). + * + * @throws TimerNotStarted If the timer with the given ID has not been started. + * + * @return void + */ + public function lap(string $id, string $description = ''): void + { + if (!isset($this->start[$id])) { + throw new TimerNotStarted($id); + } + + $this->laps[$id][] = [ + 'description' => $description, + 'time' => microtime(true) + ]; + } + + /** + * Retrieves all recorded laps for a given timer. + * + * Each lap contains its timestamp and optional description. + * If no laps have been recorded, an empty array is returned. + * + * @param string $id The identifier of the timer. + * + * @return array A list of laps with their details. + */ + public function getLaps(string $id): array + { + return $this->laps[$id] ?? []; + } + + /** + * Pauses the specified timer. + * + * If the timer is already paused and not yet resumed, this method throws an exception. + * Each pause event is recorded with a timestamp and an optional description. + * + * @param string $id The identifier of the timer. + * @param string $description An optional label or note for this pause (default: empty string). + * + * @throws TimerNotStarted If the timer with the given ID has not been started. + * @throws TimerAlreadyPaused If the timer is already paused. + * + * @return void + */ + public function pause(string $id, string $description = ''): void + { + if (!isset($this->start[$id])) { + throw new TimerNotStarted($id); + } + + if (isset($this->pause[$id]) && count($this->pause[$id]) > count($this->resume[$id] ?? [])) { + throw new TimerAlreadyPaused($id); + } + + $this->pause[$id][] = [ + 'description' => $description, + 'time' => microtime(true) + ]; + } + + /** + * Resumes a previously paused timer. + * + * A timer can only be resumed if it has an active pause entry that has not yet been resumed. + * Each resume event is recorded with a timestamp and an optional description. + * + * @param string $id The identifier of the timer. + * @param string $description An optional label or note for this resume (default: empty string). + * + * @throws TimerNotStarted If the timer with the given ID has not been started. + * @throws NoActivePausedTimerToResume If there is no active pause to resume. + * + * @return void + */ + public function resume(string $id, string $description = ''): void + { + if (!isset($this->start[$id])) { + throw new TimerNotStarted($id); + } + + if (!isset($this->pause[$id]) || count($this->pause[$id]) === count($this->resume[$id] ?? [])) { + throw new NoActivePausedTimerToResume($id); + } + + $this->resume[$id][] = [ + 'description' => $description, + 'time' => microtime(true) + ]; + } + /** * Check whether timer with given id is started or not * @@ -195,6 +319,8 @@ public static function watch(callable $callback, array $params = [], string $uni * * @param string $id The identifier for the timer. * @return Result|null The Result instance or null if the timer does not exist. + * + * @throws UnmatchedPauseWithoutResume */ public function calculate(string $id): ?Result { @@ -202,9 +328,26 @@ public function calculate(string $id): ?Result return null; } - return new Result($this->unit, $this->end[$id] - $this->start[$id], 's'); + $calculate = $this->end[$id] - $this->start[$id]; + + $pausedTime = 0; + + if (!empty($this->pause[$id]) && !empty($this->resume[$id])) { + foreach ($this->pause[$id] as $index => $pauseTime) { + if (isset($this->resume[$id][$index])) { + $pausedTime += $this->resume[$id][$index]['time'] - $pauseTime['time']; + } else { + throw new UnmatchedPauseWithoutResume($id); + } + } + } + + $calculate -= $pausedTime; + + return new Result($this->unit, $calculate, 's'); } + /** * Adds a custom unit definition based on seconds. * @@ -297,4 +440,30 @@ public function getUnit(): Unit { return $this->unit; } + + /** + * Inspects and retrieves detailed timing data for a specific timer. + * + * @param string $id The identifier of the timer to inspect. + * + * @return array{ + * start: float|null, + * end: float|null, + * paused: array, + * resumed: array, + * status: string, + * laps: array + * } A structured array containing all tracked data for the specified timer. + */ + public function inspect(string $id): array + { + return [ + 'start' => $this->start[$id] ?? null, + 'end' => $this->end[$id] ?? null, + 'paused' => $this->pause[$id] ?? [], + 'resumed' => $this->resume[$id] ?? [], + 'status' => $this->status($id), + 'laps' => $this->laps[$id] ?? [] + ]; + } } diff --git a/tests/TimeTrackerTest.php b/tests/TimeTrackerTest.php index 3364b7c..3ca488a 100644 --- a/tests/TimeTrackerTest.php +++ b/tests/TimeTrackerTest.php @@ -1,7 +1,10 @@ assertSame(['timer1', 'timer2'], $timetracker->getActiveTimers()); } + + public function testLap() + { + $timetracker = new TimeTracker(); + + $timetracker->start('timer1'); + + usleep(10000); // 10ms delay + $timetracker->lap('timer1', "After 10ms delay"); + + usleep(20000); // 20ms delay + $timetracker->lap('timer1', "After 20ms delay"); + + $timetracker->stop(); + + $laps = $timetracker->getLaps('timer1'); + + $this->assertNotEmpty($laps); + + $expected = [10, 20]; + foreach ($laps as $index => $lap) { + $this->assertSame("After {$expected[$index]}ms delay", $lap['description']); + } + } + + public function testLapThrowExceptionOnCallingWithoutTimerStarted() + { + $this->expectException(TimerNotStarted::class); + + $timetracker = new TimeTracker(); + + $timetracker->lap('timer1'); + } + + public function testPauseThrowsExceptionIfTimerNotStarted() + { + $tracker = new TimeTracker(); + + $this->expectException(TimerNotStarted::class); + + $tracker->pause('task'); + } + + public function testPauseWorksWhenTimerIsStarted() + { + $tracker = new TimeTracker(); + + $tracker->start('task'); + $tracker->pause('task', 'first pause'); + + $inspectData = $tracker->inspect('task'); + $pauseData = $inspectData['paused'][0]; + + $this->assertSame('first pause', $pauseData['description']); + $this->assertIsFloat($pauseData['time']); + } + + public function testPauseThrowsIfAlreadyPausedAndNotResumed() + { + $tracker = new TimeTracker(); + + $tracker->start('task'); + $tracker->pause('task'); + + $this->expectException(TimerAlreadyPaused::class); + + // second pause should fail + $tracker->pause('task'); + } + + public function testResumeThrowsExceptionIfTimerNotStarted() + { + $tracker = new TimeTracker(); + + $this->expectException(TimerNotStarted::class); + + $tracker->resume('task'); + } + + public function testResumeThrowsIfNoActivePause() + { + $tracker = new TimeTracker(); + $tracker->start('task'); + + $this->expectException(NoActivePausedTimerToResume::class); + + $tracker->resume('task'); + } + + public function testResumeWorksAfterPause() + { + $tracker = new TimeTracker(); + + $tracker->start('task'); + $tracker->pause('task', 'pause point'); + usleep(10_000); + $tracker->resume('task', 'resume point'); + + $inspectData = $tracker->inspect('task'); + $resumeData = $inspectData['resumed'][0]; + + $this->assertSame('resume point', $resumeData['description']); + $this->assertIsFloat($resumeData['time']); + } + + public function testMultiplePauseResumeCycles() + { + $tracker = new TimeTracker(); + + $tracker->start('task'); + + $tracker->pause('task'); + usleep(5000); + $tracker->resume('task'); + + $tracker->pause('task'); + usleep(5000); + $tracker->resume('task'); + + $tracker->stop(); + + $tracker->calculate('task'); + + $this->assertTrue(true); + } + + public function testCalculateThrowsExceptionForUnmatchedPause() + { + $tracker = new TimeTracker(); + + $id = 'task'; + + // Start timer and create pause/resume cycle with unmatched pause + $tracker->start($id); + usleep(5000); + + $tracker->pause($id, 'first pause'); + $tracker->resume($id, 'first resume'); + $tracker->pause($id, 'second pause'); + + $tracker->stop($id); + + $this->expectException(UnmatchedPauseWithoutResume::class); + $this->expectExceptionMessage("Unmatched pause without resume for timer with ID '{$id}'"); + + $tracker->calculate($id); + } + + public function testInspectMethod() + { + $tracker = new TimeTracker(); + $id = 'inspect_timer'; + + // Test inspect for non-started timer + $inspectData = $tracker->inspect($id); + $this->assertNull($inspectData['start']); + $this->assertNull($inspectData['end']); + $this->assertEmpty($inspectData['paused']); + $this->assertEmpty($inspectData['resumed']); + $this->assertSame(TimerStatus::NOT_STARTED->value, $inspectData['status']); + $this->assertEmpty($inspectData['laps']); + + // Start timer and add comprehensive data + $tracker->start($id); + usleep(5000); + + $tracker->lap($id, 'First checkpoint'); + usleep(3000); + + $tracker->pause($id, 'Taking a break'); + usleep(2000); + $tracker->resume($id, 'Back to work'); + usleep(4000); + + $tracker->lap($id, 'Second checkpoint'); + + $tracker->stop($id); + + $inspectData = $tracker->inspect($id); + + $this->assertIsFloat($inspectData['start']); + $this->assertIsFloat($inspectData['end']); + $this->assertGreaterThan($inspectData['start'], $inspectData['end']); + + $this->assertCount(1, $inspectData['paused']); + $this->assertSame('Taking a break', $inspectData['paused'][0]['description']); + $this->assertIsFloat($inspectData['paused'][0]['time']); + + $this->assertCount(1, $inspectData['resumed']); + $this->assertSame('Back to work', $inspectData['resumed'][0]['description']); + $this->assertIsFloat($inspectData['resumed'][0]['time']); + + $this->assertSame(TimerStatus::COMPLETED->value, $inspectData['status']); + + $this->assertCount(2, $inspectData['laps']); + $this->assertSame('First checkpoint', $inspectData['laps'][0]['description']); + $this->assertSame('Second checkpoint', $inspectData['laps'][1]['description']); + $this->assertIsFloat($inspectData['laps'][0]['time']); + $this->assertIsFloat($inspectData['laps'][1]['time']); + } } From 98178b27543b332e4f21bc9a95b4231524204571 Mon Sep 17 00:00:00 2001 From: naingaunglwin-dev Date: Tue, 9 Dec 2025 14:35:27 +0630 Subject: [PATCH 11/14] chore: add phpunit cache and coverage folders to .gitignore --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 3fca1a9..4c607e4 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,10 @@ vendor/ # Ignore PHPUnit configuration file (if you're using phpunit.xml for local customizations) phpunit.xml +# Ignore PHPUnit test result output files +.phpunit.cache +coverage-report + # Ignore Composer lock file if you don't want to share it (usually, you want to commit composer.lock) # composer.lock From 91c84d94a9dbdb9c7f877d16272047ac04cdbaa6 Mon Sep 17 00:00:00 2001 From: Naing Aung Lwin <126607909+naingaunglwin-dev@users.noreply.github.com> Date: Tue, 9 Dec 2025 15:05:52 +0630 Subject: [PATCH 12/14] chore: add test workflow for master and dev branches (#10) * chore: add test workflow for master and dev branches * remove version field from composer.json --- .github/workflows/tests.yml | 41 +++++++++++++++++++++++++++++++++++++ composer.json | 4 +++- 2 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/tests.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..24e1791 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,41 @@ +name: Tests + +on: + push: + branches: [ "master", "dev" ] + pull_request: + branches: [ "master", "dev" ] + +permissions: + contents: read + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.3 + + - name: Validate composer.json and composer.lock + run: composer validate --strict + + - name: Cache Composer packages + id: composer-cache + uses: actions/cache@v3 + with: + path: vendor + key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-php- + + - name: Install dependencies + run: composer install --prefer-dist --no-progress --no-suggest + + - name: Run test suite + run: composer test \ No newline at end of file diff --git a/composer.json b/composer.json index 5b49516..1a6e8df 100644 --- a/composer.json +++ b/composer.json @@ -2,7 +2,6 @@ "name": "naingaunglwin-dev/timetracker", "description": "A lightweight time tracker for php", "minimum-stability": "stable", - "version": "1.0.0", "type": "library", "prefer-stable": true, "license": "MIT", @@ -23,5 +22,8 @@ }, "require-dev": { "phpunit/phpunit": "^11.5" + }, + "scripts": { + "test": "vendor/bin/phpunit tests" } } From b7d3e0d30bdcff31cf922138336998c4fe73b94e Mon Sep 17 00:00:00 2001 From: Naing Aung Lwin <126607909+naingaunglwin-dev@users.noreply.github.com> Date: Tue, 9 Dec 2025 15:54:23 +0630 Subject: [PATCH 13/14] refactor: restore run() as deprecated alias for backward compatibility (#11) - Reintroduce run() which was previously removed when adding watch() - Mark run() as deprecated but keep it fully functional --- src/TimeTracker.php | 51 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/src/TimeTracker.php b/src/TimeTracker.php index ab6c248..a5999ac 100644 --- a/src/TimeTracker.php +++ b/src/TimeTracker.php @@ -267,6 +267,57 @@ public function getActiveTimers(): array return $activeTimers; } + /** + * @codeCoverageIgnore + * + * Executes a callback while tracking its execution time. + * + * @deprecated Use `watch` instead + * + * @param callable $callback The callback function to execute. + * @param array $params Parameters to pass to the callback. + * @param string $unit The unit for measuring execution time. + * @return array{result: Result, time: float|int, unit: string, output: mixed} An array containing Result, the execution time, unit, and callback result. + */ + public static function run(callable $callback, array $params = [], string $unit = 's'): array + { + $timeTracker = new self(); + + $randomId = bin2hex(random_bytes(16)); + + $container = new Container(); + + $timeTracker->start($randomId); + + try { + + $output = $container->call($callback, $params); + + } catch (\Throwable $e) { + $timeTracker->stop($randomId); + + throw new \RuntimeException( + $timeTracker->calculate($randomId)->format('Error occurring during executing callback, end in %s%s')->get() . + "\n{$e->getMessage()}", + $e->getCode(), + $e + ); + } finally { + if (!$timeTracker->isStopped($randomId)) { + $timeTracker->stop($randomId); + } + } + + $result = $timeTracker->calculate($randomId); + + return [ + 'result' => $result, + 'time' => $result->convert($unit)->get(), + 'unit' => $unit, + 'output' => $output ?? null + ]; + } + /** * Executes a callback while tracking its execution time. * From e7d046eebedbfd00d70eb67247f97928e761b3ca Mon Sep 17 00:00:00 2001 From: Naing Aung Lwin <126607909+naingaunglwin-dev@users.noreply.github.com> Date: Tue, 9 Dec 2025 18:32:30 +0630 Subject: [PATCH 14/14] docs: add changelog for recent changes (#12) --- CHANGELOG.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..86ee8fb --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,23 @@ +# ChangeLog + +## v1.1.0 - 2025-12-09 +### Added +- Implemented `__toString()` method for the `Result` class, allowing instances to be converted to strings (e.g. `"$result"` now returns the calculated value). (PR #1) +- Added new `stop()` method to the timer, supporting stopping: (PR #2) + - a specific timer by ID + - **or** the **most recently started timer** when no ID is provided. +- Added new `watch()` static method to replace `run()` for executing and timing callbacks. (PR #4) +- Added new timer utility methods `isStarted()`, `isStopped()`, and `getActiveTimers()` in `TimeTracker` to inspect active and completed timers. (PR #7) +- Added new timer utility methods `lap()`, `getLaps()`, `pause()`, `resume()`, and `inspect()` in `TimeTracker`. (PR #9) + +### Changed +- Replaced `ramsey/uuid` with native PHP functions (`bin2hex(random_bytes(16))`) for generating random IDs. (PR #3) +- Removed the `ramsey/uuid` dependency as it is no longer required. (PR #3) +- Replaced `STATUS_*` string constants with a dedicated `TimerStatus` enum. (PR #5) + +### Fixed +- Prevent duplicate `stop()` calls in `watch()` by adding an `isStopped()` check in the `finally` block. (RP #8) + +### Deprecated +- Marked `end()` as deprecated. It still works for backward compatibility but will be removed in a future major release. (PR #2) +- Marked `run()` as deprecated. It still works for backward compatibility but will be removed in a future major release. (PR #4) \ No newline at end of file