Compare commits

...

2 Commits

Author SHA1 Message Date
Constantin Graf
47f6131d88 Add test to TimeEntryEndpointTest 2026-01-16 00:34:29 +01:00
Gregor Vostrak
496ccbc45c change rounding up on boundaries so it does not round up but keeps the value, fixes #994 2026-01-14 18:25:48 +01:00
3 changed files with 151 additions and 3 deletions

View File

@@ -31,12 +31,17 @@ class TimeEntryService
throw new LogicException('Rounding minutes must be greater than 0');
}
$end = 'coalesce("end", \''.Carbon::now()->toDateTimeString().'\')';
$start = $this->getStartSelectRawForRounding($roundingType, $roundingMinutes);
if ($roundingType === TimeEntryRoundingType::Down) {
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.', '.$this->getStartSelectRawForRounding($roundingType, $roundingMinutes).')';
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.', '.$start.')';
} elseif ($roundingType === TimeEntryRoundingType::Up) {
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.$roundingMinutes.' minutes\', '.$this->getStartSelectRawForRounding($roundingType, $roundingMinutes).')';
// If end is already on a boundary, keep it; otherwise round up to next boundary
return 'CASE WHEN '.$end.' = date_bin(\''.$roundingMinutes.' minutes\', '.$end.', '.$start.') '.
'THEN '.$end.' '.
'ELSE date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.$roundingMinutes.' minutes\', '.$start.') '.
'END';
} elseif ($roundingType === TimeEntryRoundingType::Nearest) {
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.($roundingMinutes / 2).' minutes\', '.$this->getStartSelectRawForRounding($roundingType, $roundingMinutes).')';
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.($roundingMinutes / 2).' minutes\', '.$start.')';
}
}
}

View File

@@ -436,6 +436,52 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
);
}
public function test_index_endpoint_can_round_up_but_does_not_round_up_if_already_on_border(): void
{
// Arrange
$this->travelTo(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:15:04'));
$data = $this->createUserWithPermission([
'time-entries:view:own',
]);
$timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)
->forMember($data->member)
->create([
'start' => Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:08'),
'end' => Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:06:00'),
]);
$timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)
->forMember($data->member)
->create([
'start' => Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:07'),
'end' => null,
]);
$this->actAsOrganizationWithSubscription();
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.time-entries.index', [
$data->organization->getKey(),
'member_id' => $data->member->getKey(),
'rounding_type' => TimeEntryRoundingType::Up,
'rounding_minutes' => 6,
]));
// Assert
$this->assertResponseCode($response, 200);
$response->assertJson(fn (AssertableJson $json) => $json
->has('data')
->has('meta')
->where('meta.total', 2)
->count('data', 2)
->where('data.0.id', $timeEntry1->getKey())
->where('data.0.start', '2020-01-01T00:00:00Z')
->where('data.0.end', '2020-01-01T00:06:00Z')
->where('data.1.id', $timeEntry2->getKey())
->where('data.1.start', '2020-01-01T00:00:00Z')
->where('data.1.end', '2020-01-01T00:18:00Z')
);
}
public function test_index_endpoint_ignores_rounding_if_organization_has_no_premium_features(): void
{
// Arrange

View File

@@ -1205,4 +1205,101 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
];
$this->assertEqualsCanonicalizing($expected, $result);
}
/**
* Test that rounding up does NOT add extra time when the entry is already on a 15-minute boundary.
* f.e. 13:00 - 14:30 (90 minutes) should stay at 90 minutes when rounding up with 15-minute interval.
*/
public function test_aggregate_time_round_up_does_not_add_time_when_already_on_boundary(): void
{
// Arrange
// Create a time entry with duration exactly on a 15-minute boundary (90 minutes = 5400 seconds)
// This simulates 13:00 - 14:30 (or any 90-minute entry)
$project = Project::factory()->create();
TimeEntry::factory()->startWithDuration(
Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 13:00:00'),
5400 // 90 minutes = 1 hour 30 minutes, exactly on 15-minute boundary
)->forProject($project)->create();
$query = TimeEntry::query();
// Act
$result = $this->service->getAggregatedTimeEntries(
$query,
TimeEntryAggregationType::Project,
null,
'Europe/Vienna',
Weekday::Monday,
false,
null,
null,
true,
TimeEntryRoundingType::Up,
15
);
// Assert
// The entry is already on a 15-minute boundary (90 minutes), so it should stay at 90 minutes (5400 seconds)
$this->assertEqualsCanonicalizing([
'seconds' => 5400, // 90 minutes - should NOT be rounded to 105 minutes (6300 seconds)
'cost' => 0,
'grouped_type' => 'project',
'grouped_data' => [
[
'key' => $project->getKey(),
'seconds' => 5400, // 90 minutes
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
],
],
], $result);
}
/**
* Test that rounding up works correctly for entries NOT on a boundary.
* Example: 13:00 - 13:48 (48 minutes) should round up to 13:00 - 14:00 (60 minutes).
*/
public function test_aggregate_time_round_up_works_when_not_on_boundary(): void
{
// Arrange
// Create a time entry with duration NOT on a 15-minute boundary (48 minutes = 2880 seconds)
$project = Project::factory()->create();
TimeEntry::factory()->startWithDuration(
Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 13:00:00'),
2880 // 48 minutes, not on 15-minute boundary
)->forProject($project)->create();
$query = TimeEntry::query();
// Act
$result = $this->service->getAggregatedTimeEntries(
$query,
TimeEntryAggregationType::Project,
null,
'Europe/Vienna',
Weekday::Monday,
false,
null,
null,
true,
TimeEntryRoundingType::Up,
15
);
// Assert
// 48 minutes rounded up to 15-minute interval = 60 minutes (3600 seconds)
$this->assertEqualsCanonicalizing([
'seconds' => 3600, // 60 minutes
'cost' => 0,
'grouped_type' => 'project',
'grouped_data' => [
[
'key' => $project->getKey(),
'seconds' => 3600, // 60 minutes
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
],
],
], $result);
}
}