Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ class MyFirstTask
| `getOverallProgress(?int $precision)` | Get average progress of all instances |
| `resetOverallProgress()` | Clear all progress data for the group |
| `removeLocalFromOverall()` | Remove this instance from overall calculation |
| `getEstimatedTimeRemaining()` | Get estimated time remaining in seconds for this instance |
| `getOverallEstimatedTimeRemaining()` | Get estimated time remaining in seconds for the overall progress |

#### Status Checks

Expand Down
38 changes: 38 additions & 0 deletions src/Progressable.php
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,43 @@ public function isOverallComplete(): bool {
return true;
}

/**
* Get the estimated time remaining in seconds for the overall progress.
*/
public function getOverallEstimatedTimeRemaining(): ?int {
$overallProgress = $this->getOverallProgress();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use unrounded progress when computing overall ETA

getOverallEstimatedTimeRemaining() derives its rate from getOverallProgress(), which applies configured precision rounding before returning. That makes ETA depend on display precision: with low precision (or very small/near-complete real progress), the rounded value can become 0 (returning null) or 100 (returning 0) even though work is still in progress, so callers can get incorrect overall ETA. Compute ETA from raw progressData values (without presentation rounding) to avoid precision-induced errors.

Useful? React with 👍 / 👎.


if ($overallProgress >= 100) {
return 0;
}

$progressData = $this->getOverallProgressData();

if (empty($progressData)) {
return null;
}

$startTimes = array_filter(array_column($progressData, 'start_time'), function ($time) {
return $time !== null;
});

if (empty($startTimes) || $overallProgress <= 0) {
return null;
}

$minStartTime = min($startTimes);
$elapsed = Carbon::now()->timestamp - $minStartTime;

if ($elapsed <= 0) {
return null;
}

$rate = $overallProgress / $elapsed;
$remainingProgress = 100 - $overallProgress;

return (int) round($remainingProgress / $rate);
}

/**
* Get the estimated time remaining in seconds.
*/
Expand Down Expand Up @@ -671,6 +708,7 @@ public function toArray(): array {
'is_complete' => $this->isComplete(),
'is_overall_complete' => $hasUniqueName ? $this->isOverallComplete() : null,
'estimated_time_remaining' => $hasUniqueName ? $this->getEstimatedTimeRemaining() : null,
'overall_estimated_time_remaining' => $hasUniqueName ? $this->getOverallEstimatedTimeRemaining() : null,
'message' => $this->getStatusMessage(),
'metadata' => $this->getMetadata(),
'total_steps' => $this->getTotalSteps(),
Expand Down
16 changes: 7 additions & 9 deletions tests/IsOverallCompleteBugTest.php
Original file line number Diff line number Diff line change
@@ -1,24 +1,22 @@
<?php

namespace Verseles\Progressable\Tests;

use Verseles\Progressable\Progressable;
use Orchestra\Testbench\TestCase;
use Verseles\Progressable\Progressable;

class DummyProgressableOverallComplete
{
class DummyProgressableOverallComplete {
use Progressable;
}

class IsOverallCompleteBugTest extends TestCase
{
public function testIsOverallCompleteIsAccurateAndDoesNotReturnTruePrematurely()
{
$a = new DummyProgressableOverallComplete();
class IsOverallCompleteBugTest extends TestCase {
public function test_is_overall_complete_is_accurate_and_does_not_return_true_prematurely() {
$a = new DummyProgressableOverallComplete;
$a->setOverallUniqueName('test-overall-complete-bug');
$a->setLocalKey('a');
$a->setLocalProgress(100);

$b = new DummyProgressableOverallComplete();
$b = new DummyProgressableOverallComplete;
$b->setOverallUniqueName('test-overall-complete-bug');
$b->setLocalKey('b');
$b->setLocalProgress(99.4);
Expand Down
77 changes: 77 additions & 0 deletions tests/OverallEtaTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<?php

namespace Verseles\Progressable\Tests;

use Illuminate\Support\Carbon;
use Orchestra\Testbench\TestCase;
use Verseles\Progressable\Progressable;

class OverallEtaTest extends TestCase {
use Progressable;

private string $testId;

protected function setUp(): void {
parent::setUp();
$this->testId = uniqid('test_', true);

// Reset properties
$this->progress = 0;
unset($this->overallUniqueName);
}

public function test_overall_eta_is_null_initially(): void {
$this->setOverallUniqueName('test_overall_eta_init_'.$this->testId);
$this->assertNull($this->getOverallEstimatedTimeRemaining());
}

public function test_overall_eta_calculation(): void {
Carbon::setTestNow(Carbon::now());

$uniqueName = 'test_overall_eta_calc_'.$this->testId;
$this->setOverallUniqueName($uniqueName);

// First process starts at T0
$this->setLocalKey('process_1');
$this->setLocalProgress(0);

// Second process starts at T+5s
Carbon::setTestNow(Carbon::now()->addSeconds(5));

$obj2 = new class {
use Progressable;
};
$obj2->setOverallUniqueName($uniqueName);
$obj2->setLocalKey('process_2');
$obj2->setLocalProgress(0);

// Advance to T+10s
Carbon::setTestNow(Carbon::now()->addSeconds(5));

// Set process 1 to 20% and process 2 to 0% -> overall = 10%
// Min start time is T0, so elapsed is 10s.
// Rate = 10% / 10s = 1% per second.
// Remaining = 90%. ETA = 90s.
$this->setLocalProgress(20);

$this->assertEquals(90, $this->getOverallEstimatedTimeRemaining());
$this->assertEquals(90, $obj2->getOverallEstimatedTimeRemaining());
}

public function test_overall_eta_is_zero_when_complete(): void {
$uniqueName = 'test_overall_eta_complete_'.$this->testId;

$this->setOverallUniqueName($uniqueName);
$this->setLocalKey('process_1');
$this->setLocalProgress(100);

$obj2 = new class {
use Progressable;
};
$obj2->setOverallUniqueName($uniqueName);
$obj2->setLocalKey('process_2');
$obj2->setLocalProgress(100);

$this->assertEquals(0, $this->getOverallEstimatedTimeRemaining());
}
}
1 change: 1 addition & 0 deletions tests/ProgressableTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -587,6 +587,7 @@ public function test_to_array_without_unique_name(): void {
'is_complete' => false,
'is_overall_complete' => null,
'estimated_time_remaining' => null,
'overall_estimated_time_remaining' => null,
'message' => 'Halfway there',
'metadata' => ['foo' => 'bar'],
'total_steps' => 10,
Expand Down
Loading