mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-21 06:32:14 +01:00
Add "Week" grouping option to reporting
This commit is contained in:
@@ -17,6 +17,7 @@ use App\Jobs\RecalculateSpentTimeForProject;
|
||||
use App\Jobs\RecalculateSpentTimeForTask;
|
||||
use App\Models\Client;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tag;
|
||||
use App\Models\Task;
|
||||
@@ -1742,6 +1743,112 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
||||
$this->assertStringNotContainsString('2024-03-14', $csv);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: Organization, 1: Member, 2: User}
|
||||
*/
|
||||
private function createWeeksSpanningNewYear(): array
|
||||
{
|
||||
$data = $this->createUserWithPermission([
|
||||
'time-entries:view:all',
|
||||
]);
|
||||
// Note: the organization factory randomizes the date format, so pin it
|
||||
$data->organization->update(['date_format' => DateFormat::SlashSeparatedDDMMYYYY]);
|
||||
$project = Project::factory()->forOrganization($data->organization)->create();
|
||||
// Note: the user factory pins the week start to Monday, so these land in predictable buckets
|
||||
foreach (['2025-12-22', '2025-12-29', '2026-01-05'] as $day) {
|
||||
TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)
|
||||
->startWithDuration(Carbon::parse($day.' 09:00:00', 'UTC'), 3600)->create();
|
||||
}
|
||||
|
||||
return [$data->organization, $data->member, $data->user];
|
||||
}
|
||||
|
||||
public function test_aggregate_export_csv_labels_a_week_group_with_its_date_range(): void
|
||||
{
|
||||
// Arrange
|
||||
$this->travelTo(Carbon::create(2026, 1, 15, 12, 0, 0, 'UTC'));
|
||||
[$organization, , $user] = $this->createWeeksSpanningNewYear();
|
||||
Passport::actingAs($user);
|
||||
|
||||
// Act
|
||||
$response = $this->getJson(route('api.v1.time-entries.aggregate-export', [
|
||||
$organization->getKey(),
|
||||
'format' => ExportFormat::CSV,
|
||||
'group' => TimeEntryAggregationType::Week,
|
||||
'sub_group' => TimeEntryAggregationType::Project,
|
||||
'history_group' => TimeEntryAggregationTypeInterval::Week,
|
||||
'start' => Carbon::parse('2025-12-15 00:00:00', 'UTC')->toIso8601ZuluString(),
|
||||
'end' => Carbon::parse('2026-01-15 23:59:59', 'UTC')->toIso8601ZuluString(),
|
||||
]));
|
||||
|
||||
// Assert
|
||||
$this->assertResponseCode($response, 200);
|
||||
$disk = Storage::disk(config('filesystems.private'));
|
||||
$files = $disk->files('exports');
|
||||
$this->assertCount(1, $files);
|
||||
$csv = $disk->get($files[0]);
|
||||
$this->assertIsString($csv);
|
||||
$this->assertStringContainsString('22/12/2025 - 28/12/2025', $csv);
|
||||
$this->assertStringContainsString('29/12/2025 - 04/01/2026', $csv);
|
||||
$this->assertStringContainsString('05/01/2026 - 11/01/2026', $csv);
|
||||
$this->assertStringNotContainsString('2025-12-29', $csv);
|
||||
$this->assertStringNotContainsString('2025-12-22', $csv);
|
||||
}
|
||||
|
||||
public function test_aggregate_export_endpoints_can_create_a_pdf_report_grouped_by_week_spanning_new_year(): void
|
||||
{
|
||||
// Arrange
|
||||
$this->travelTo(Carbon::create(2026, 1, 15, 12, 0, 0, 'UTC'));
|
||||
[$organization, , $user] = $this->createWeeksSpanningNewYear();
|
||||
Passport::actingAs($user);
|
||||
$this->actAsOrganizationWithSubscription();
|
||||
|
||||
// Act
|
||||
// Note: a week 1 label carries a comma and reaches the echarts series in a <script> tag
|
||||
$response = $this->getJson(route('api.v1.time-entries.aggregate-export', [
|
||||
$organization->getKey(),
|
||||
'format' => ExportFormat::PDF,
|
||||
'group' => TimeEntryAggregationType::Week,
|
||||
'sub_group' => TimeEntryAggregationType::Project,
|
||||
'history_group' => TimeEntryAggregationTypeInterval::Week,
|
||||
'start' => Carbon::parse('2025-12-15 00:00:00', 'UTC')->toIso8601ZuluString(),
|
||||
'end' => Carbon::parse('2026-01-15 23:59:59', 'UTC')->toIso8601ZuluString(),
|
||||
]));
|
||||
|
||||
// Assert
|
||||
$this->assertResponseCode($response, 200);
|
||||
}
|
||||
|
||||
public function test_aggregate_export_pdf_labels_a_week_group_with_its_date_range(): void
|
||||
{
|
||||
// Arrange
|
||||
$this->travelTo(Carbon::create(2026, 1, 15, 12, 0, 0, 'UTC'));
|
||||
[$organization, , $user] = $this->createWeeksSpanningNewYear();
|
||||
Passport::actingAs($user);
|
||||
$this->actAsOrganizationWithSubscription();
|
||||
|
||||
// Act
|
||||
// Note: debug=true returns the rendered HTML instead of handing it to the PDF renderer.
|
||||
$response = $this->getJson(route('api.v1.time-entries.aggregate-export', [
|
||||
$organization->getKey(),
|
||||
'format' => ExportFormat::PDF,
|
||||
'group' => TimeEntryAggregationType::Week,
|
||||
'sub_group' => TimeEntryAggregationType::Project,
|
||||
'history_group' => TimeEntryAggregationTypeInterval::Week,
|
||||
'start' => Carbon::parse('2025-12-15 00:00:00', 'UTC')->toIso8601ZuluString(),
|
||||
'end' => Carbon::parse('2026-01-15 23:59:59', 'UTC')->toIso8601ZuluString(),
|
||||
'debug' => 'true',
|
||||
]));
|
||||
|
||||
// Assert
|
||||
$this->assertResponseCode($response, 200);
|
||||
$html = $response->json('html');
|
||||
$this->assertIsString($html);
|
||||
$this->assertStringContainsString('22/12/2025 - 28/12/2025', $html);
|
||||
$this->assertStringContainsString('29/12/2025 - 04/01/2026', $html);
|
||||
$this->assertStringNotContainsString('2025-12-29', $html);
|
||||
}
|
||||
|
||||
public function test_index_export_endpoint_with_client_ids_filter_returns_filtered_entries(): void
|
||||
{
|
||||
// Arrange
|
||||
|
||||
@@ -326,15 +326,50 @@ class LocalizationServiceTest extends TestCaseWithDatabase
|
||||
$this->assertNull($formatted);
|
||||
}
|
||||
|
||||
public function test_format_time_group_key_returns_the_key_unchanged_for_non_day_group_types(): void
|
||||
public function test_format_time_group_key_formats_a_week_key_as_the_range_it_covers(): void
|
||||
{
|
||||
// Arrange
|
||||
$this->localizationService->setDateFormat(DateFormat::SlashSeparatedDDMMYYYY);
|
||||
|
||||
// Act
|
||||
$formatted = $this->localizationService->formatTimeGroupKey('2026-07-27', TimeEntryAggregationType::Week);
|
||||
|
||||
// Assert
|
||||
$this->assertSame('27/07/2026 - 02/08/2026', $formatted);
|
||||
}
|
||||
|
||||
public function test_format_time_group_key_formats_a_week_range_from_the_weekday_of_its_own_key(): void
|
||||
{
|
||||
// Arrange
|
||||
$this->localizationService->setDateFormat(DateFormat::SlashSeparatedDDMMYYYY);
|
||||
|
||||
// Act
|
||||
$sundayStart = $this->localizationService->formatTimeGroupKey('2026-07-26', TimeEntryAggregationType::Week);
|
||||
$mondayStart = $this->localizationService->formatTimeGroupKey('2026-07-20', TimeEntryAggregationType::Week);
|
||||
|
||||
// Assert
|
||||
$this->assertSame('26/07/2026 - 01/08/2026', $sundayStart);
|
||||
$this->assertSame('20/07/2026 - 26/07/2026', $mondayStart);
|
||||
}
|
||||
|
||||
public function test_format_time_group_key_formats_a_week_range_spanning_new_year(): void
|
||||
{
|
||||
// Arrange
|
||||
$this->localizationService->setDateFormat(DateFormat::SlashSeparatedDDMMYYYY);
|
||||
|
||||
// Act
|
||||
$formatted = $this->localizationService->formatTimeGroupKey('2025-12-29', TimeEntryAggregationType::Week);
|
||||
|
||||
// Assert
|
||||
$this->assertSame('29/12/2025 - 04/01/2026', $formatted);
|
||||
}
|
||||
|
||||
public function test_format_time_group_key_does_not_change_month_year_and_entity_group_types(): void
|
||||
{
|
||||
// Arrange
|
||||
$this->localizationService->setDateFormat(DateFormat::SlashSeparatedDDMMYYYY);
|
||||
|
||||
// Act & Assert
|
||||
// Week/month/year are not offered as a grouping in the reporting UI and their keys have
|
||||
// different shapes, so they are passed through rather than formatted as a date.
|
||||
$this->assertSame('2001-02-03', $this->localizationService->formatTimeGroupKey('2001-02-03', TimeEntryAggregationType::Week));
|
||||
$this->assertSame('2001-02', $this->localizationService->formatTimeGroupKey('2001-02', TimeEntryAggregationType::Month));
|
||||
$this->assertSame('2001', $this->localizationService->formatTimeGroupKey('2001', TimeEntryAggregationType::Year));
|
||||
$this->assertSame('some-uuid', $this->localizationService->formatTimeGroupKey('some-uuid', TimeEntryAggregationType::Project));
|
||||
|
||||
@@ -1302,4 +1302,111 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
|
||||
],
|
||||
], $result);
|
||||
}
|
||||
|
||||
public function test_aggregate_time_entries_by_week_keys_each_group_by_the_first_day_of_the_week(): void
|
||||
{
|
||||
// Arrange
|
||||
// 2026-07-20 is a Monday, 2026-07-26 is the Sunday of that week.
|
||||
foreach (['2026-07-20', '2026-07-26', '2026-07-27'] as $day) {
|
||||
TimeEntry::factory()->startWithDuration(Carbon::parse($day.' 09:00:00', 'UTC'), 10)->create();
|
||||
}
|
||||
$query = TimeEntry::query();
|
||||
|
||||
// Act
|
||||
$result = $this->service->getAggregatedTimeEntries(
|
||||
$query,
|
||||
TimeEntryAggregationType::Week,
|
||||
null,
|
||||
'UTC',
|
||||
Weekday::Monday,
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
true,
|
||||
null,
|
||||
null
|
||||
);
|
||||
|
||||
// Assert
|
||||
$this->assertSame([
|
||||
'seconds' => 30,
|
||||
'cost' => 0,
|
||||
'grouped_type' => 'week',
|
||||
'grouped_data' => [
|
||||
[
|
||||
'key' => '2026-07-20',
|
||||
'seconds' => 20,
|
||||
'cost' => 0,
|
||||
'grouped_type' => null,
|
||||
'grouped_data' => null,
|
||||
],
|
||||
[
|
||||
'key' => '2026-07-27',
|
||||
'seconds' => 10,
|
||||
'cost' => 0,
|
||||
'grouped_type' => null,
|
||||
'grouped_data' => null,
|
||||
],
|
||||
],
|
||||
], $result);
|
||||
}
|
||||
|
||||
public function test_aggregate_time_entries_by_week_aligns_the_group_key_to_the_organization_week_start(): void
|
||||
{
|
||||
// Arrange
|
||||
// With a Sunday week start the Sunday entry belongs to the following week instead.
|
||||
foreach (['2026-07-20', '2026-07-26', '2026-07-27'] as $day) {
|
||||
TimeEntry::factory()->startWithDuration(Carbon::parse($day.' 09:00:00', 'UTC'), 10)->create();
|
||||
}
|
||||
$query = TimeEntry::query();
|
||||
|
||||
// Act
|
||||
$result = $this->service->getAggregatedTimeEntries(
|
||||
$query,
|
||||
TimeEntryAggregationType::Week,
|
||||
null,
|
||||
'UTC',
|
||||
Weekday::Sunday,
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
true,
|
||||
null,
|
||||
null
|
||||
);
|
||||
|
||||
// Assert
|
||||
$this->assertSame(['2026-07-19', '2026-07-26'], array_column($result['grouped_data'], 'key'));
|
||||
$this->assertSame([10, 20], array_column($result['grouped_data'], 'seconds'));
|
||||
}
|
||||
|
||||
public function test_aggregate_time_entries_by_week_keys_a_week_spanning_new_year_by_its_start_in_the_earlier_year(): void
|
||||
{
|
||||
// Arrange
|
||||
// Note: 2025-12-29 is the Monday of the week containing 1 January 2026
|
||||
foreach (['2025-12-29', '2025-12-31', '2026-01-01', '2026-01-05'] as $day) {
|
||||
TimeEntry::factory()->startWithDuration(Carbon::parse($day.' 09:00:00', 'UTC'), 10)->create();
|
||||
}
|
||||
$query = TimeEntry::query();
|
||||
|
||||
// Act
|
||||
$result = $this->service->getAggregatedTimeEntries(
|
||||
$query,
|
||||
TimeEntryAggregationType::Week,
|
||||
null,
|
||||
'UTC',
|
||||
Weekday::Monday,
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
true,
|
||||
null,
|
||||
null
|
||||
);
|
||||
|
||||
// Assert
|
||||
$this->assertSame(['2025-12-29', '2026-01-05'], array_column($result['grouped_data'], 'key'));
|
||||
// Three of the four entries fall in the week that straddles new year.
|
||||
$this->assertSame([30, 10], array_column($result['grouped_data'], 'seconds'));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user