promote duration formats that omit seconds to HH:mm:ss in reporting

views and exports
This commit is contained in:
Gregor Vostrak
2026-04-17 12:15:26 +02:00
parent 1cf33ddb3f
commit 277dbaf6eb
16 changed files with 141 additions and 26 deletions

View File

@@ -96,6 +96,30 @@ class LocalizationService
} }
} }
/**
* Format a duration for reporting contexts (PDF reports, places that display duration
* directly next to cost). Promotes the verbose `Hh Mm` format to the compact `HH:MM:SS`
* so totals stay narrow and reconcile with cost, which is always computed to the second.
*/
public function formatIntervalForReporting(CarbonInterval $interval): string
{
$promoted = [
IntervalFormat::HoursMinutes,
IntervalFormat::HoursMinutesColonSeparated,
];
if (! in_array($this->intervalFormat, $promoted, true)) {
return $this->formatInterval($interval);
}
$previous = $this->intervalFormat;
$this->intervalFormat = IntervalFormat::HoursMinutesSecondsColonSeparated;
try {
return $this->formatInterval($interval);
} finally {
$this->intervalFormat = $previous;
}
}
public function formatCurrency(Money $money): string public function formatCurrency(Money $money): string
{ {
$currencyService = app(CurrencyService::class); $currencyService = app(CurrencyService::class);

View File

@@ -2,7 +2,7 @@
import VChart, { THEME_KEY } from 'vue-echarts'; import VChart, { THEME_KEY } from 'vue-echarts';
import { computed, provide, inject, shallowRef, type ComputedRef } from 'vue'; import { computed, provide, inject, shallowRef, type ComputedRef } from 'vue';
import LinearGradient from 'zrender/lib/graphic/LinearGradient'; import LinearGradient from 'zrender/lib/graphic/LinearGradient';
import { formatDate, formatHumanReadableDuration, formatWeek } from '@/packages/ui/src/utils/time'; import { formatDate, formatReportingDuration, formatWeek } from '@/packages/ui/src/utils/time';
import { use } from 'echarts/core'; import { use } from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers'; import { CanvasRenderer } from 'echarts/renderers';
import { BarChart } from 'echarts/charts'; import { BarChart } from 'echarts/charts';
@@ -137,7 +137,7 @@ const option = computed(() => ({
type: 'bar', type: 'bar',
tooltip: { tooltip: {
valueFormatter: (value: number) => { valueFormatter: (value: number) => {
return formatHumanReadableDuration( return formatReportingDuration(
value, value,
organization?.value?.interval_format, organization?.value?.interval_format,
organization?.value?.number_format organization?.value?.number_format

View File

@@ -8,7 +8,7 @@ import {
import { SaveIcon } from 'lucide-vue-next'; import { SaveIcon } from 'lucide-vue-next';
import { getOrganizationCurrencyString } from '@/utils/money'; import { getOrganizationCurrencyString } from '@/utils/money';
import { import {
formatHumanReadableDuration, formatReportingDuration,
getDayJsInstance, getDayJsInstance,
getLocalizedDayJs, getLocalizedDayJs,
} from '@/packages/ui/src/utils/time'; } from '@/packages/ui/src/utils/time';
@@ -426,7 +426,7 @@ const tableData = computed(() => {
class="justify-end flex items-center font-medium" class="justify-end flex items-center font-medium"
:class="!showBillableRate ? 'pr-6' : ''"> :class="!showBillableRate ? 'pr-6' : ''">
{{ {{
formatHumanReadableDuration( formatReportingDuration(
aggregatedTableTimeEntries.seconds, aggregatedTableTimeEntries.seconds,
organization?.interval_format, organization?.interval_format,
organization?.number_format organization?.number_format

View File

@@ -10,7 +10,7 @@ import {
TitleComponent, TitleComponent,
TooltipComponent, TooltipComponent,
} from 'echarts/components'; } from 'echarts/components';
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time'; import { formatReportingDuration } from '@/packages/ui/src/utils/time';
import { useCssVariable } from '@/packages/ui/src'; import { useCssVariable } from '@/packages/ui/src';
import type { Organization } from '@/packages/api/src'; import type { Organization } from '@/packages/api/src';
@@ -67,7 +67,7 @@ const option = computed(() => ({
}, },
tooltip: { tooltip: {
valueFormatter: (value: number) => { valueFormatter: (value: number) => {
return formatHumanReadableDuration( return formatReportingDuration(
value, value,
organization?.value?.interval_format, organization?.value?.interval_format,
organization?.value?.number_format organization?.value?.number_format

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time'; import { formatReportingDuration } from '@/packages/ui/src/utils/time';
import { formatCents } from '@/packages/ui/src/utils/money'; import { formatCents } from '@/packages/ui/src/utils/money';
import GroupedItemsCountButton from '@/packages/ui/src/GroupedItemsCountButton.vue'; import GroupedItemsCountButton from '@/packages/ui/src/GroupedItemsCountButton.vue';
import { ref, inject, type ComputedRef } from 'vue'; import { ref, inject, type ComputedRef } from 'vue';
@@ -44,7 +44,7 @@ const organization = inject<ComputedRef<Organization>>('organization');
</div> </div>
<div class="justify-end flex items-center" :class="!showCost ? 'pr-6' : ''"> <div class="justify-end flex items-center" :class="!showCost ? 'pr-6' : ''">
{{ {{
formatHumanReadableDuration( formatReportingDuration(
entry.seconds, entry.seconds,
organization?.interval_format, organization?.interval_format,
organization?.number_format organization?.number_format

View File

@@ -16,7 +16,7 @@ import CardTitle from '@/packages/ui/src/CardTitle.vue';
import LinearGradient from 'zrender/lib/graphic/LinearGradient'; import LinearGradient from 'zrender/lib/graphic/LinearGradient';
import ProjectsChartCard from '@/Components/Dashboard/ProjectsChartCard.vue'; import ProjectsChartCard from '@/Components/Dashboard/ProjectsChartCard.vue';
import ThisWeekReportingTable from '@/Components/Dashboard/ThisWeekReportingTable.vue'; import ThisWeekReportingTable from '@/Components/Dashboard/ThisWeekReportingTable.vue';
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time'; import { formatReportingDuration } from '@/packages/ui/src/utils/time';
import { formatCents } from '@/packages/ui/src/utils/money'; import { formatCents } from '@/packages/ui/src/utils/money';
import { getWeekStart } from '@/packages/ui/src/utils/settings'; import { getWeekStart } from '@/packages/ui/src/utils/settings';
import { useCssVariable } from '@/packages/ui/src'; import { useCssVariable } from '@/packages/ui/src';
@@ -223,7 +223,7 @@ const option = computed(() => {
type: 'bar', type: 'bar',
tooltip: { tooltip: {
valueFormatter: (value: number) => { valueFormatter: (value: number) => {
return formatHumanReadableDuration( return formatReportingDuration(
value, value,
organization?.value?.interval_format, organization?.value?.interval_format,
organization?.value?.number_format organization?.value?.number_format
@@ -252,7 +252,7 @@ const option = computed(() => {
title="Spent Time" title="Spent Time"
:value=" :value="
totalWeeklyTime totalWeeklyTime
? formatHumanReadableDuration( ? formatReportingDuration(
totalWeeklyTime, totalWeeklyTime,
organization?.interval_format, organization?.interval_format,
organization?.number_format organization?.number_format
@@ -263,7 +263,7 @@ const option = computed(() => {
title="Billable Time" title="Billable Time"
:value=" :value="
totalWeeklyBillableTime totalWeeklyBillableTime
? formatHumanReadableDuration( ? formatReportingDuration(
totalWeeklyBillableTime, totalWeeklyBillableTime,
organization?.interval_format, organization?.interval_format,
organization?.number_format organization?.number_format

View File

@@ -2,7 +2,7 @@
import ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue'; import ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue';
import ReportingGroupBySelect from '@/Components/Common/Reporting/ReportingGroupBySelect.vue'; import ReportingGroupBySelect from '@/Components/Common/Reporting/ReportingGroupBySelect.vue';
import { import {
formatHumanReadableDuration, formatReportingDuration,
getDayJsInstance, getDayJsInstance,
getLocalizedDayJs, getLocalizedDayJs,
} from '@/packages/ui/src/utils/time'; } from '@/packages/ui/src/utils/time';
@@ -174,7 +174,7 @@ const showBillableRate = computed(() => {
class="justify-end flex items-center font-medium" class="justify-end flex items-center font-medium"
:class="!showBillableRate ? 'pr-6' : ''"> :class="!showBillableRate ? 'pr-6' : ''">
{{ {{
formatHumanReadableDuration( formatReportingDuration(
aggregatedTableTimeEntries.seconds, aggregatedTableTimeEntries.seconds,
organization?.interval_format, organization?.interval_format,
organization?.number_format organization?.number_format

View File

@@ -390,6 +390,7 @@ async function downloadExport(format: ExportFormat) {
:organization-billable-rate="organization?.billable_rate ?? null" :organization-billable-rate="organization?.billable_rate ?? null"
:duplicate-time-entry="() => createTimeEntry(entry)" :duplicate-time-entry="() => createTimeEntry(entry)"
:members="members" :members="members"
is-report
show-date show-date
show-member show-member
:time-entry="entry" :time-entry="entry"

View File

@@ -3,7 +3,7 @@ import MainContainer from '@/packages/ui/src/MainContainer.vue';
import PageTitle from '@/Components/Common/PageTitle.vue'; import PageTitle from '@/Components/Common/PageTitle.vue';
import { ChartBarIcon } from '@heroicons/vue/20/solid'; import { ChartBarIcon } from '@heroicons/vue/20/solid';
import ReportingChart from '@/Components/Common/Reporting/ReportingChart.vue'; import ReportingChart from '@/Components/Common/Reporting/ReportingChart.vue';
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time'; import { formatReportingDuration } from '@/packages/ui/src/utils/time';
import ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue'; import ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue';
import ReportingPieChart from '@/Components/Common/Reporting/ReportingPieChart.vue'; import ReportingPieChart from '@/Components/Common/Reporting/ReportingPieChart.vue';
import { formatCents } from '@/packages/ui/src/utils/money'; import { formatCents } from '@/packages/ui/src/utils/money';
@@ -231,7 +231,7 @@ onMounted(async () => {
</div> </div>
<div class="justify-end flex items-center font-medium"> <div class="justify-end flex items-center font-medium">
{{ {{
formatHumanReadableDuration( formatReportingDuration(
aggregatedTableTimeEntries.seconds, aggregatedTableTimeEntries.seconds,
reportIntervalFormat, reportIntervalFormat,
reportNumberFormat reportNumberFormat

View File

@@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import FormSection from '@/Components/FormSection.vue'; import FormSection from '@/Components/FormSection.vue';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue'; import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import { onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { Field, FieldLabel } from '@/packages/ui/src/field'; import { Field, FieldDescription, FieldLabel } from '@/packages/ui/src/field';
import type { UpdateOrganizationBody } from '@/packages/api/src'; import type { UpdateOrganizationBody } from '@/packages/api/src';
import { useOrganizationStore } from '@/utils/useOrganization'; import { useOrganizationStore } from '@/utils/useOrganization';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
@@ -52,6 +52,12 @@ onMounted(async () => {
} }
}); });
const showsHhMmSsInReports = computed(
() =>
form.value.interval_format === 'hours-minutes' ||
form.value.interval_format === 'hours-minutes-colon-separated'
);
async function submit() { async function submit() {
mutation.mutate(form.value); mutation.mutate(form.value);
} }
@@ -149,6 +155,12 @@ async function submit() {
> >
</SelectContent> </SelectContent>
</Select> </Select>
<FieldDescription v-if="showsHhMmSsInReports">
Reports and totals shown next to cost use HH:MM:SS for this format, so the
duration reconciles with the billable amount down to the second. Everywhere
else (time tracker, calendar, entry rows) seconds are omitted and durations
stay in your chosen format.
</FieldDescription>
</Field> </Field>
</template> </template>

View File

@@ -52,6 +52,7 @@ const props = defineProps<{
selected?: boolean; selected?: boolean;
canCreateProject: boolean; canCreateProject: boolean;
enableEstimatedTime: boolean; enableEstimatedTime: boolean;
isReport?: boolean;
}>(); }>();
const emit = defineEmits<{ selected: []; unselected: [] }>(); const emit = defineEmits<{ selected: []; unselected: [] }>();
@@ -172,6 +173,7 @@ async function handleDeleteTimeEntry() {
<TimeEntryRowDurationInput <TimeEntryRowDurationInput
:start="timeEntry.start" :start="timeEntry.start"
:end="timeEntry.end" :end="timeEntry.end"
:is-report="props.isReport"
@changed="updateStartEndTime"></TimeEntryRowDurationInput> @changed="updateStartEndTime"></TimeEntryRowDurationInput>
<TimeTrackerStartStop <TimeTrackerStartStop
:active="!!(timeEntry.start && !timeEntry.end)" :active="!!(timeEntry.start && !timeEntry.end)"
@@ -196,6 +198,7 @@ async function handleDeleteTimeEntry() {
<TimeEntryRowDurationInput <TimeEntryRowDurationInput
:start="timeEntry.start" :start="timeEntry.start"
:end="timeEntry.end" :end="timeEntry.end"
:is-report="props.isReport"
@changed="updateStartEndTime"></TimeEntryRowDurationInput> @changed="updateStartEndTime"></TimeEntryRowDurationInput>
</div> </div>
<!-- Second row: project/task - tags - billable - start - more --> <!-- Second row: project/task - tags - billable - start - more -->

View File

@@ -2,6 +2,7 @@
import { import {
calculateDifference, calculateDifference,
formatHumanReadableDuration, formatHumanReadableDuration,
formatReportingDuration,
parseTimeInput, parseTimeInput,
} from '@/packages/ui/src/utils/time'; } from '@/packages/ui/src/utils/time';
import { computed, ref, inject, type ComputedRef } from 'vue'; import { computed, ref, inject, type ComputedRef } from 'vue';
@@ -18,6 +19,7 @@ const organizationSettings = computed(() => ({
const props = defineProps<{ const props = defineProps<{
start: string; start: string;
end: string | null; end: string | null;
isReport?: boolean;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
changed: [start: string, end: string | null]; changed: [start: string, end: string | null];
@@ -51,7 +53,8 @@ const currentTime = computed({
if (temporaryCustomTimerEntry.value !== '') { if (temporaryCustomTimerEntry.value !== '') {
return temporaryCustomTimerEntry.value; return temporaryCustomTimerEntry.value;
} }
return formatHumanReadableDuration( const formatter = props.isReport ? formatReportingDuration : formatHumanReadableDuration;
return formatter(
calculateDifference(props.start, props.end), calculateDifference(props.start, props.end),
organizationSettings.value.intervalFormat, organizationSettings.value.intervalFormat,
organizationSettings.value.numberFormat organizationSettings.value.numberFormat

View File

@@ -118,6 +118,26 @@ export function formatHumanReadableDuration(
} }
} }
/**
* Format a duration for reporting views where cost and duration must reconcile.
*
* When the org's `hours-minutes` format is selected, seconds are normally dropped for
* readability (e.g. "14h 45min"). In reports this can make the total duration appear
* inconsistent with the billable cost (which is computed to the second). To keep the
* two columns reconcilable without inflating column widths with "14h 45min 06s",
* promote to the compact `HH:MM:SS` format in reporting contexts.
*/
export function formatReportingDuration(
duration: number,
intervalFormat?: string,
numberFormat?: string
): string {
const promoted =
intervalFormat === 'hours-minutes' || intervalFormat === 'hours-minutes-colon-separated';
const effectiveFormat = promoted ? 'hours-minutes-seconds-colon-separated' : intervalFormat;
return formatHumanReadableDuration(duration, effectiveFormat, numberFormat);
}
export function formatDuration(duration: number): string { export function formatDuration(duration: number): string {
const dayJsDuration = dayjs.duration(duration, 's'); const dayJsDuration = dayjs.duration(duration, 's');
const hours = Math.floor(dayJsDuration.asHours()); const hours = Math.floor(dayJsDuration.asHours());

View File

@@ -150,7 +150,7 @@
<div style="padding: 8px 12px; border-radius: 8px;"> <div style="padding: 8px 12px; border-radius: 8px;">
<div style="color: #71717a; font-weight: 600;">Duration</div> <div style="color: #71717a; font-weight: 600;">Duration</div>
<div <div
style="font-size: 24px; font-weight: 500; margin-top: 2px;">{{ $localization->formatInterval(CarbonInterval::seconds($aggregatedData['seconds'])) }} </div> style="font-size: 24px; font-weight: 500; margin-top: 2px;">{{ $localization->formatIntervalForReporting(CarbonInterval::seconds($aggregatedData['seconds'])) }} </div>
</div> </div>
@if($showBillableRate) @if($showBillableRate)
<div style="padding: 8px 12px; border-radius: 8px;"> <div style="padding: 8px 12px; border-radius: 8px;">
@@ -199,7 +199,7 @@
</span> </span>
</td> </td>
<td style="text-align: left;"> <td style="text-align: left;">
{{ $localization->formatInterval(CarbonInterval::seconds($group1Entry['seconds'])) }} {{ $localization->formatIntervalForReporting(CarbonInterval::seconds($group1Entry['seconds'])) }}
</td> </td>
@if($showBillableRate) @if($showBillableRate)
<td style="text-align: right;"> <td style="text-align: right;">
@@ -214,7 +214,7 @@
Total Total
</td> </td>
<td style="font-weight: 500;color: #18181b;"> <td style="font-weight: 500;color: #18181b;">
{{ $localization->formatInterval(CarbonInterval::seconds($aggregatedData['seconds'])) }} {{ $localization->formatIntervalForReporting(CarbonInterval::seconds($aggregatedData['seconds'])) }}
</td> </td>
@if($showBillableRate) @if($showBillableRate)
<td style="text-align: right; font-weight: 500;color: #18181b;"> <td style="text-align: right; font-weight: 500;color: #18181b;">
@@ -282,7 +282,7 @@
@endif @endif
</td> </td>
<td> <td>
{{ $localization->formatInterval($duration) }} {{ $localization->formatIntervalForReporting($duration) }}
</td> </td>
<td> <td>
{{ $localization->formatNumber($duration->totalHours) }} {{ $localization->formatNumber($duration->totalHours) }}
@@ -403,7 +403,7 @@
type: "bar", type: "bar",
data: {!! json_encode(collect($dataHistoryChart['grouped_data'])->map(fn($value) => (object) [ data: {!! json_encode(collect($dataHistoryChart['grouped_data'])->map(fn($value) => (object) [
'value' => $value['seconds'], 'value' => $value['seconds'],
'name' => ((int) $value['seconds']) === 0 ? '' : $localization->formatInterval(CarbonInterval::seconds((int) $value['seconds'])) 'name' => ((int) $value['seconds']) === 0 ? '' : $localization->formatIntervalForReporting(CarbonInterval::seconds((int) $value['seconds']))
])->toArray()) !!}, ])->toArray()) !!},
itemStyle: { itemStyle: {
borderColor: "#7dd3fc", borderColor: "#7dd3fc",

View File

@@ -138,7 +138,7 @@
<div style="padding: 8px 12px; border-radius: 8px;"> <div style="padding: 8px 12px; border-radius: 8px;">
<div style="color: #71717a; font-weight: 600;">Duration</div> <div style="color: #71717a; font-weight: 600;">Duration</div>
<div <div
style="font-size: 24px; font-weight: 500; margin-top: 2px;">{{ $localization->formatInterval(CarbonInterval::seconds($aggregatedData['seconds'])) }} </div> style="font-size: 24px; font-weight: 500; margin-top: 2px;">{{ $localization->formatIntervalForReporting(CarbonInterval::seconds($aggregatedData['seconds'])) }} </div>
</div> </div>
@if($showBillableRate) @if($showBillableRate)
<div style="padding: 8px 12px; border-radius: 8px;"> <div style="padding: 8px 12px; border-radius: 8px;">
@@ -189,7 +189,7 @@
{{ $localization->formatTime($timeEntry->start->timezone($timezone)) }} - {{ $localization->formatTime($timeEntry->end->timezone($timezone)) }} {{ $localization->formatTime($timeEntry->start->timezone($timezone)) }} - {{ $localization->formatTime($timeEntry->end->timezone($timezone)) }}
</td> </td>
<td style="overflow-wrap: break-word; min-width: 75px;"> <td style="overflow-wrap: break-word; min-width: 75px;">
{{ $localization->formatInterval($timeEntry->getDuration()) }} {{ $localization->formatIntervalForReporting($timeEntry->getDuration()) }}
</td> </td>
<td style="overflow-wrap: break-word;">{{ $timeEntry->billable ? 'Yes' : 'No' }}</td> <td style="overflow-wrap: break-word;">{{ $timeEntry->billable ? 'Yes' : 'No' }}</td>
<td style="overflow-wrap: break-word; min-width: 75px;">{{ count($timeEntry->tagsRelation) === 0 ? '-' : $timeEntry->tagsRelation->implode('name', ', ') }}</td> <td style="overflow-wrap: break-word; min-width: 75px;">{{ count($timeEntry->tagsRelation) === 0 ? '-' : $timeEntry->tagsRelation->implode('name', ', ') }}</td>

View File

@@ -129,6 +129,58 @@ class LocalizationServiceTest extends TestCaseWithDatabase
$this->assertSame('30001:03:04', $formatted); $this->assertSame('30001:03:04', $formatted);
} }
public function test_format_interval_for_reporting_with_type_decimal(): void
{
// Arrange
$interval = CarbonInterval::seconds(4 + (60 * 3) + (60 * 60 * 30001));
$this->localizationService->setIntervalFormat(IntervalFormat::Decimal);
// Act
$formatted = $this->localizationService->formatIntervalForReporting($interval);
// Assert
$this->assertSame('30.001,05 h', $formatted);
}
public function test_format_interval_for_reporting_with_type_hours_minutes(): void
{
// Arrange
$interval = CarbonInterval::seconds(4 + (60 * 3) + (60 * 60 * 30001));
$this->localizationService->setIntervalFormat(IntervalFormat::HoursMinutes);
// Act
$formatted = $this->localizationService->formatIntervalForReporting($interval);
// Assert
$this->assertSame('30001:03:04', $formatted);
}
public function test_format_interval_for_reporting_with_type_hours_minutes_colon_separated(): void
{
// Arrange
$interval = CarbonInterval::seconds(4 + (60 * 3) + (60 * 60 * 30001));
$this->localizationService->setIntervalFormat(IntervalFormat::HoursMinutesColonSeparated);
// Act
$formatted = $this->localizationService->formatIntervalForReporting($interval);
// Assert
$this->assertSame('30001:03:04', $formatted);
}
public function test_format_interval_for_reporting_with_type_hours_minutes_seconds_colon_separated(): void
{
// Arrange
$interval = CarbonInterval::seconds(4 + (60 * 3) + (60 * 60 * 30001));
$this->localizationService->setIntervalFormat(IntervalFormat::HoursMinutesSecondsColonSeparated);
// Act
$formatted = $this->localizationService->formatIntervalForReporting($interval);
// Assert
$this->assertSame('30001:03:04', $formatted);
}
public function test_format_currency_with_type_symbol_after_with_space_and_number_format_thousands_space_decimal_comma(): void public function test_format_currency_with_type_symbol_after_with_space_and_number_format_thousands_space_decimal_comma(): void
{ {
// Arrange // Arrange