diff --git a/app/Service/LocalizationService.php b/app/Service/LocalizationService.php index bee7490a..7d145f41 100644 --- a/app/Service/LocalizationService.php +++ b/app/Service/LocalizationService.php @@ -155,14 +155,9 @@ class LocalizationService } /** - * Time group types have no server-side descriptor, so reports fall back to rendering the raw - * aggregation key. For the Day type that key is an ISO date (Y-m-d), which is formatted here - * so exports match the rest of the UI instead of leaking the key. - * - * Week, month and year keys have different shapes ('Y-m-d' for the week start, 'Y-m' and 'Y'), - * and are not offered as a grouping in the reporting UI, so they are returned unchanged. - * Presenting those needs its own decision: a week rendered as its start date reads as a single - * day, and a month rendered with a full date format reads as a specific day. + * Time group types have no server-side descriptor; Day and Week keys are ISO dates and + * formatted here instead. A Week key is the first day of that week, so it renders as the + * range it covers. */ public function formatTimeGroupKey(?string $key, TimeEntryAggregationType $groupType): ?string { @@ -170,11 +165,17 @@ class LocalizationService return null; } - if ($groupType !== TimeEntryAggregationType::Day) { - return $key; + if ($groupType === TimeEntryAggregationType::Day) { + return $this->formatDate(Carbon::parse($key)); } - return $this->formatDate(Carbon::parse($key)); + if ($groupType === TimeEntryAggregationType::Week) { + $weekStart = Carbon::parse($key); + + return $this->formatDate($weekStart).' - '.$this->formatDate($weekStart->copy()->addDays(6)); + } + + return $key; } public function setDateFormat(DateFormat $dateFormat): void diff --git a/e2e/reporting.spec.ts b/e2e/reporting.spec.ts index 1c855dbd..f19ded9e 100644 --- a/e2e/reporting.spec.ts +++ b/e2e/reporting.spec.ts @@ -10,6 +10,7 @@ import { createTimeEntryWithTagViaApi, createTimeEntryWithBillableStatusViaApi, createBareTimeEntryViaApi, + createTimeEntryOnDateViaApi, createPublicProjectViaApi, updateOrganizationSettingViaApi, } from './utils/api'; @@ -885,6 +886,83 @@ test('test that group by date groups the report by day and formats the date labe ); }); +test('test that group by week requests week grouping and does not leak the raw group key', async ({ + page, + ctx, +}) => { + await createTimeEntryViaApi(ctx, { + description: 'Entry for group by week', + duration: '1h', + }); + + await goToReporting(page); + await expect(page.getByRole('button', { name: 'Export' })).toBeVisible(); + + const groupBySelects = page.locator('[data-testid="reporting_view"]').getByRole('combobox'); + await groupBySelects.filter({ hasText: 'Project' }).first().click(); + + const [aggregateResponse] = await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/time-entries/aggregate') && + response.url().includes('group=week') && + response.status() === 200 + ), + page.getByRole('option', { name: 'Week', exact: true }).click(), + ]); + + const requestUrl = new URL(aggregateResponse.url()); + expect(requestUrl.searchParams.get('group')).toBe('week'); + + // The raw group key is the first day of the week and must not leak through. + await expect(page.getByTestId('reporting_view').getByText(/^\d{4}-\d{2}-\d{2}$/)).toHaveCount( + 0 + ); +}); + +test('test that group by week labels a week spanning new year with a range crossing the year', async ({ + page, + ctx, +}) => { + await updateOrganizationSettingViaApi(ctx, { date_format: 'slash-separated-dd-mm-yyyy' }); + + for (const day of ['2025-12-22', '2025-12-29', '2026-01-05']) { + await createTimeEntryOnDateViaApi(ctx, { + date: new Date(`${day}T09:00:00Z`), + duration: '1h', + description: `Entry for ${day}`, + }); + } + + // The reporting page keeps its range in session storage, so seed a range spanning new year + // rather than driving the date picker. + await page.addInitScript(() => { + window.sessionStorage.setItem('reporting-start-date', '2025-12-15'); + window.sessionStorage.setItem('reporting-end-date', '2026-01-15'); + }); + + await goToReporting(page); + await expect(page.getByRole('button', { name: 'Export' })).toBeVisible(); + + const groupBySelects = page.locator('[data-testid="reporting_view"]').getByRole('combobox'); + await groupBySelects.filter({ hasText: 'Project' }).first().click(); + await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/time-entries/aggregate') && + response.url().includes('group=week') && + response.status() === 200 + ), + page.getByRole('option', { name: 'Week', exact: true }).click(), + ]); + + const reportingView = page.getByTestId('reporting_view'); + await expect(reportingView.getByText('22/12/2025 - 28/12/2025', { exact: true })).toBeVisible(); + await expect(reportingView.getByText('29/12/2025 - 04/01/2026', { exact: true })).toBeVisible(); + await expect(reportingView.getByText('05/01/2026 - 11/01/2026', { exact: true })).toBeVisible(); + await expect(reportingView.getByText(/^\d{4}-\d{2}-\d{2}$/)).toHaveCount(0); +}); + // ────────────────────────────────────────────────── // Export Tests // ────────────────────────────────────────────────── diff --git a/resources/js/packages/ui/src/utils/time.test.ts b/resources/js/packages/ui/src/utils/time.test.ts index 35a873a9..8c9923c7 100644 --- a/resources/js/packages/ui/src/utils/time.test.ts +++ b/resources/js/packages/ui/src/utils/time.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'vitest'; -import { formatHumanReadableDuration, formatReportingDuration } from './time'; +import { formatHumanReadableDuration, formatReportingDuration, formatWeekRange } from './time'; const seconds = 14 * 3600 + 45 * 60 + 6; // 14h 45m 06s @@ -42,3 +42,21 @@ describe('formatReportingDuration', () => { ); }); }); + +describe('formatWeekRange', () => { + test('renders the six days following the given first day of the week', () => { + expect(formatWeekRange('2026-07-27', 'slash-separated-dd-mm-yyyy')).toBe( + '27/07/2026 - 02/08/2026' + ); + }); + + test('spans a month boundary', () => { + expect(formatWeekRange('2026-07-27')).toBe('27.7.2026 - 2.8.2026'); + }); + + test('spans a year boundary', () => { + expect(formatWeekRange('2025-12-29', 'slash-separated-dd-mm-yyyy')).toBe( + '29/12/2025 - 04/01/2026' + ); + }); +}); diff --git a/resources/js/packages/ui/src/utils/time.ts b/resources/js/packages/ui/src/utils/time.ts index 4031f2fe..7a7a93be 100644 --- a/resources/js/packages/ui/src/utils/time.ts +++ b/resources/js/packages/ui/src/utils/time.ts @@ -236,6 +236,15 @@ export function formatWeek(date: string | null): string { return 'Week ' + getDayJsInstance()(date).week(); } +/* + * Returns the range covered by the week starting on the given day. + * @param date - first day of a week, in the format of 'YYYY-MM-DD' + */ +export function formatWeekRange(date: string, format?: DateFormat): string { + const end = getDayJsInstance()(date).add(6, 'day').format('YYYY-MM-DD'); + return `${formatDate(date, format)} - ${formatDate(end, format)}`; +} + /* * Returns a human readable date format. * @param date - date in the format of 'YYYY-MM-DD' diff --git a/resources/js/utils/useReporting.ts b/resources/js/utils/useReporting.ts index fccceec2..8e0181ef 100644 --- a/resources/js/utils/useReporting.ts +++ b/resources/js/utils/useReporting.ts @@ -7,10 +7,15 @@ import { useTasksQuery } from '@/utils/useTasksQuery'; import { useClientsQuery } from '@/utils/useClientsQuery'; import { useTagsQuery } from '@/utils/useTagsQuery'; import { CheckCircleIcon, UserCircleIcon, UserGroupIcon } from '@heroicons/vue/20/solid'; -import { CalendarIcon, DocumentTextIcon, FolderIcon } from '@heroicons/vue/16/solid'; +import { + CalendarDaysIcon, + CalendarIcon, + DocumentTextIcon, + FolderIcon, +} from '@heroicons/vue/16/solid'; import { Coffee } from '@lucide/vue'; import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue'; -import { type DateFormat, formatDate } from '@/packages/ui/src/utils/time'; +import { type DateFormat, formatDate, formatWeekRange } from '@/packages/ui/src/utils/time'; export type GroupingOption = | 'project' @@ -21,7 +26,8 @@ export type GroupingOption = | 'description' | 'tag' | 'type' - | 'day'; + | 'day' + | 'week'; export const useReportingStore = defineStore('reporting', () => { // Cache query composables to avoid creating new subscriptions on every call @@ -85,6 +91,9 @@ export const useReportingStore = defineStore('reporting', () => { if (type === 'day') { return formatDate(key, dateFormat); } + if (type === 'week') { + return formatWeekRange(key, dateFormat); + } return key; } @@ -138,6 +147,11 @@ export const useReportingStore = defineStore('reporting', () => { value: 'day', icon: CalendarIcon, }, + { + label: 'Week', + value: 'week', + icon: CalendarDaysIcon, + }, ]; return { diff --git a/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php b/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php index a6eff697..0ba74fff 100644 --- a/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php @@ -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