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
{
$currencyService = app(CurrencyService::class);

View File

@@ -2,7 +2,7 @@
import VChart, { THEME_KEY } from 'vue-echarts';
import { computed, provide, inject, shallowRef, type ComputedRef } from 'vue';
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 { CanvasRenderer } from 'echarts/renderers';
import { BarChart } from 'echarts/charts';
@@ -137,7 +137,7 @@ const option = computed(() => ({
type: 'bar',
tooltip: {
valueFormatter: (value: number) => {
return formatHumanReadableDuration(
return formatReportingDuration(
value,
organization?.value?.interval_format,
organization?.value?.number_format

View File

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

View File

@@ -10,7 +10,7 @@ import {
TitleComponent,
TooltipComponent,
} 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 type { Organization } from '@/packages/api/src';
@@ -67,7 +67,7 @@ const option = computed(() => ({
},
tooltip: {
valueFormatter: (value: number) => {
return formatHumanReadableDuration(
return formatReportingDuration(
value,
organization?.value?.interval_format,
organization?.value?.number_format

View File

@@ -1,5 +1,5 @@
<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 GroupedItemsCountButton from '@/packages/ui/src/GroupedItemsCountButton.vue';
import { ref, inject, type ComputedRef } from 'vue';
@@ -44,7 +44,7 @@ const organization = inject<ComputedRef<Organization>>('organization');
</div>
<div class="justify-end flex items-center" :class="!showCost ? 'pr-6' : ''">
{{
formatHumanReadableDuration(
formatReportingDuration(
entry.seconds,
organization?.interval_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 ProjectsChartCard from '@/Components/Dashboard/ProjectsChartCard.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 { getWeekStart } from '@/packages/ui/src/utils/settings';
import { useCssVariable } from '@/packages/ui/src';
@@ -223,7 +223,7 @@ const option = computed(() => {
type: 'bar',
tooltip: {
valueFormatter: (value: number) => {
return formatHumanReadableDuration(
return formatReportingDuration(
value,
organization?.value?.interval_format,
organization?.value?.number_format
@@ -252,7 +252,7 @@ const option = computed(() => {
title="Spent Time"
:value="
totalWeeklyTime
? formatHumanReadableDuration(
? formatReportingDuration(
totalWeeklyTime,
organization?.interval_format,
organization?.number_format
@@ -263,7 +263,7 @@ const option = computed(() => {
title="Billable Time"
:value="
totalWeeklyBillableTime
? formatHumanReadableDuration(
? formatReportingDuration(
totalWeeklyBillableTime,
organization?.interval_format,
organization?.number_format

View File

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

View File

@@ -390,6 +390,7 @@ async function downloadExport(format: ExportFormat) {
:organization-billable-rate="organization?.billable_rate ?? null"
:duplicate-time-entry="() => createTimeEntry(entry)"
:members="members"
is-report
show-date
show-member
: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 { ChartBarIcon } from '@heroicons/vue/20/solid';
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 ReportingPieChart from '@/Components/Common/Reporting/ReportingPieChart.vue';
import { formatCents } from '@/packages/ui/src/utils/money';
@@ -231,7 +231,7 @@ onMounted(async () => {
</div>
<div class="justify-end flex items-center font-medium">
{{
formatHumanReadableDuration(
formatReportingDuration(
aggregatedTableTimeEntries.seconds,
reportIntervalFormat,
reportNumberFormat

View File

@@ -1,8 +1,8 @@
<script setup lang="ts">
import FormSection from '@/Components/FormSection.vue';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import { onMounted, ref } from 'vue';
import { Field, FieldLabel } from '@/packages/ui/src/field';
import { computed, onMounted, ref } from 'vue';
import { Field, FieldDescription, FieldLabel } from '@/packages/ui/src/field';
import type { UpdateOrganizationBody } from '@/packages/api/src';
import { useOrganizationStore } from '@/utils/useOrganization';
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() {
mutation.mutate(form.value);
}
@@ -149,6 +155,12 @@ async function submit() {
>
</SelectContent>
</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>
</template>

View File

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

View File

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

View File

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

View File

@@ -138,7 +138,7 @@
<div style="padding: 8px 12px; border-radius: 8px;">
<div style="color: #71717a; font-weight: 600;">Duration</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>
@if($showBillableRate)
<div style="padding: 8px 12px; border-radius: 8px;">
@@ -189,7 +189,7 @@
{{ $localization->formatTime($timeEntry->start->timezone($timezone)) }} - {{ $localization->formatTime($timeEntry->end->timezone($timezone)) }}
</td>
<td style="overflow-wrap: break-word; min-width: 75px;">
{{ $localization->formatInterval($timeEntry->getDuration()) }}
{{ $localization->formatIntervalForReporting($timeEntry->getDuration()) }}
</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>

View File

@@ -129,6 +129,58 @@ class LocalizationServiceTest extends TestCaseWithDatabase
$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
{
// Arrange