diff --git a/app/Service/LocalizationService.php b/app/Service/LocalizationService.php index 7d145f41..b375c51a 100644 --- a/app/Service/LocalizationService.php +++ b/app/Service/LocalizationService.php @@ -155,9 +155,10 @@ class LocalizationService } /** - * Time group types have no server-side descriptor; Day and Week keys are ISO dates and + * Time group types have no server-side descriptor; their keys are ISO dates and are * formatted here instead. A Week key is the first day of that week, so it renders as the - * range it covers. + * range it covers. A Year key is already a bare year, so it is returned unchanged - it must + * not be parsed, Carbon reads a four digit string as a time of day. */ public function formatTimeGroupKey(?string $key, TimeEntryAggregationType $groupType): ?string { @@ -175,6 +176,13 @@ class LocalizationService return $this->formatDate($weekStart).' - '.$this->formatDate($weekStart->copy()->addDays(6)); } + if ($groupType === TimeEntryAggregationType::Month) { + // Note: the leading "!" resets all fields the format does not name. Without it the + // day of the month is taken from today, and a day that the parsed month does not + // have overflows the date into the next month. + return Carbon::createFromFormat('!Y-m', $key)->format('F Y'); + } + return $key; } diff --git a/resources/js/packages/ui/src/utils/time.test.ts b/resources/js/packages/ui/src/utils/time.test.ts index 8c9923c7..6c62bd39 100644 --- a/resources/js/packages/ui/src/utils/time.test.ts +++ b/resources/js/packages/ui/src/utils/time.test.ts @@ -1,5 +1,10 @@ import { describe, expect, test } from 'vitest'; -import { formatHumanReadableDuration, formatReportingDuration, formatWeekRange } from './time'; +import { + formatHumanReadableDuration, + formatMonth, + formatReportingDuration, + formatWeekRange, +} from './time'; const seconds = 14 * 3600 + 45 * 60 + 6; // 14h 45m 06s @@ -60,3 +65,9 @@ describe('formatWeekRange', () => { ); }); }); + +describe('formatMonth', () => { + test('renders the name of the month that the key covers', () => { + expect(formatMonth('2001-02')).toBe('February 2001'); + }); +}); diff --git a/resources/js/packages/ui/src/utils/time.ts b/resources/js/packages/ui/src/utils/time.ts index 7a7a93be..41469075 100644 --- a/resources/js/packages/ui/src/utils/time.ts +++ b/resources/js/packages/ui/src/utils/time.ts @@ -245,6 +245,15 @@ export function formatWeekRange(date: string, format?: DateFormat): string { return `${formatDate(date, format)} - ${formatDate(end, format)}`; } +/* + * Returns the month that the given key falls in. There is no `DateFormat` variant for a + * month, so this is not affected by the organization date format. + * @param date - a month, in the format of 'YYYY-MM' + */ +export function formatMonth(date: string): string { + return getDayJsInstance()(date).format('MMMM YYYY'); +} + /* * 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 8e0181ef..9d3f5a66 100644 --- a/resources/js/utils/useReporting.ts +++ b/resources/js/utils/useReporting.ts @@ -15,7 +15,12 @@ import { } from '@heroicons/vue/16/solid'; import { Coffee } from '@lucide/vue'; import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue'; -import { type DateFormat, formatDate, formatWeekRange } from '@/packages/ui/src/utils/time'; +import { + type DateFormat, + formatDate, + formatMonth, + formatWeekRange, +} from '@/packages/ui/src/utils/time'; export type GroupingOption = | 'project' @@ -27,7 +32,9 @@ export type GroupingOption = | 'tag' | 'type' | 'day' - | 'week'; + | 'week' + | 'month' + | 'year'; export const useReportingStore = defineStore('reporting', () => { // Cache query composables to avoid creating new subscriptions on every call @@ -94,6 +101,10 @@ export const useReportingStore = defineStore('reporting', () => { if (type === 'week') { return formatWeekRange(key, dateFormat); } + if (type === 'month') { + return formatMonth(key); + } + // A `year` key is already a bare year, so it falls through unchanged. return key; } diff --git a/tests/Unit/Service/LocalizationServiceTest.php b/tests/Unit/Service/LocalizationServiceTest.php index c1e3bf9d..09c85416 100644 --- a/tests/Unit/Service/LocalizationServiceTest.php +++ b/tests/Unit/Service/LocalizationServiceTest.php @@ -364,14 +364,27 @@ class LocalizationServiceTest extends TestCaseWithDatabase $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 + public function test_format_time_group_key_formats_a_month_key_and_does_not_change_year_and_entity_group_types(): void { // Arrange $this->localizationService->setDateFormat(DateFormat::SlashSeparatedDDMMYYYY); // Act & Assert - $this->assertSame('2001-02', $this->localizationService->formatTimeGroupKey('2001-02', TimeEntryAggregationType::Month)); + $this->assertSame('February 2001', $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)); } + + public function test_format_time_group_key_formats_a_month_key_independently_of_the_current_day_of_month(): void + { + // Arrange + // Note: the current day of the month must not leak into the parsed month. The 31st does + // not exist in April, so a leaked day overflows the date into the following month. + $this->travelTo(Carbon::create(2026, 8, 31, 12, 0, 0, 'UTC')); + + // Act & Assert + $this->assertSame('February 2001', $this->localizationService->formatTimeGroupKey('2001-02', TimeEntryAggregationType::Month)); + $this->assertSame('April 2026', $this->localizationService->formatTimeGroupKey('2026-04', TimeEntryAggregationType::Month)); + $this->assertSame('December 2026', $this->localizationService->formatTimeGroupKey('2026-12', TimeEntryAggregationType::Month)); + } }