add support for interval / duration format in frontend views

This commit is contained in:
Gregor Vostrak
2025-05-07 19:39:23 +02:00
committed by Constantin Graf
parent b8d9bc5b7e
commit c1d43bcc67
24 changed files with 1219 additions and 843 deletions

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import parse from 'parse-duration';
import { onMounted, ref, watch } from 'vue';
import { onMounted, ref, watch, inject } from 'vue';
import {
formatHumanReadableDuration,
getDayJsInstance,
@@ -8,6 +8,9 @@ import {
import dayjs from 'dayjs';
import { twMerge } from 'tailwind-merge';
import { TextInput } from '@/packages/ui/src';
import type { Organization } from '@/packages/api/src';
import { type ComputedRef } from 'vue';
const temporaryCustomTimerEntry = ref<string>('');
const start = defineModel('start', {
@@ -18,6 +21,8 @@ const end = defineModel('end', {
default: '',
});
const organization = inject<ComputedRef<Organization>>('organization');
function isHHMM(value: string): boolean {
return HHMMtimeRegex.test(value);
}
@@ -70,7 +75,11 @@ function updateTimeEntryInputValue() {
if (start.value && end.value) {
const startTime = dayjs(start.value);
const diff = getDayJsInstance()(end.value).diff(startTime, 'seconds');
temporaryCustomTimerEntry.value = formatHumanReadableDuration(diff);
temporaryCustomTimerEntry.value = formatHumanReadableDuration(
diff,
organization?.value?.interval_format,
organization?.value?.number_format
);
}
}
</script>

View File

@@ -9,13 +9,14 @@ import type {
Task,
TimeEntry,
Client,
Organization,
} from '@/packages/api/src';
import TimeEntryDescriptionInput from '@/packages/ui/src/TimeEntry/TimeEntryDescriptionInput.vue';
import TimeEntryRowTagDropdown from '@/packages/ui/src/TimeEntry/TimeEntryRowTagDropdown.vue';
import TimeEntryMoreOptionsDropdown from '@/packages/ui/src/TimeEntry/TimeEntryMoreOptionsDropdown.vue';
import TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
import BillableToggleButton from '@/packages/ui/src/Input/BillableToggleButton.vue';
import { ref } from 'vue';
import { ref, inject, type ComputedRef } from 'vue';
import {
formatHumanReadableDuration,
formatStartEnd,
@@ -48,6 +49,8 @@ const emit = defineEmits<{
unselected: [TimeEntry[]];
}>();
const organization = inject<ComputedRef<Organization>>('organization');
function updateTimeEntryDescription(description: string) {
props.updateTimeEntries(
props.timeEntry.timeEntries.map((timeEntry: TimeEntry) => timeEntry.id),
@@ -113,10 +116,10 @@ function onSelectChange(checked: boolean) {
</GroupedItemsCountButton>
<TimeEntryDescriptionInput
class="min-w-0 mr-4"
:model-value="
timeEntry.description
"
@changed="updateTimeEntryDescription"></TimeEntryDescriptionInput>
:model-value="timeEntry.description"
@changed="
updateTimeEntryDescription
"></TimeEntryDescriptionInput>
<TimeTrackerProjectTaskDropdown
:clients
:create-project
@@ -128,10 +131,10 @@ function onSelectChange(checked: boolean) {
:project="timeEntry.project_id"
:enable-estimated-time
:currency="currency"
:task="
timeEntry.task_id
"
@changed="updateProjectAndTask"></TimeTrackerProjectTaskDropdown>
:task="timeEntry.task_id"
@changed="
updateProjectAndTask
"></TimeTrackerProjectTaskDropdown>
</div>
</div>
<div class="flex items-center font-medium lg:space-x-2">
@@ -139,7 +142,9 @@ function onSelectChange(checked: boolean) {
:create-tag
:tags="tags"
:model-value="timeEntry.tags"
@changed="updateTimeEntryTags"></TimeEntryRowTagDropdown>
@changed="
updateTimeEntryTags
"></TimeEntryRowTagDropdown>
<BillableToggleButton
:model-value="timeEntry.billable"
class="opacity-50 focus-visible:opacity-100 group-hover:opacity-100"
@@ -155,17 +160,23 @@ function onSelectChange(checked: boolean) {
</button>
</div>
<button
class="text-text-primary min-w-[90px] px-2 py-1.5 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-semibold focus-visible:outline-none focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:bg-tertiary"
class="text-text-primary min-w-[90px] px-2.5 py-1.5 bg-transparent text-right hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-semibold focus-visible:outline-none focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:bg-tertiary"
@click="expanded = !expanded">
{{
formatHumanReadableDuration(timeEntry.duration ?? 0)
formatHumanReadableDuration(
timeEntry.duration ?? 0,
organization?.interval_format,
organization?.number_format
)
}}
</button>
<TimeTrackerStartStop
:active="!!(timeEntry.start && !timeEntry.end)"
class="opacity-20 hidden sm:flex group-hover:opacity-100 focus-visible:opacity-100"
@changed="onStartStopClick(timeEntry)"></TimeTrackerStartStop>
@changed="
onStartStopClick(timeEntry)
"></TimeTrackerStartStop>
<TimeEntryMoreOptionsDropdown
@delete="
deleteTimeEntries(timeEntry?.timeEntries ?? [])

View File

@@ -2,10 +2,18 @@
import {
calculateDifference,
formatHumanReadableDuration,
parseTimeInput,
} from '@/packages/ui/src/utils/time';
import { computed, defineProps, ref } from 'vue';
import parse from 'parse-duration';
import { computed, defineProps, ref, inject, type ComputedRef } from 'vue';
import dayjs from 'dayjs';
import type { Organization } from '@/packages/api/src';
const organization = inject<ComputedRef<Organization>>('organization');
const organizationSettings = computed(() => ({
intervalFormat: organization?.value?.interval_format ?? 'hours-minutes',
numberFormat: organization?.value?.number_format ?? 'point',
}));
const props = defineProps<{
start: string;
@@ -19,15 +27,22 @@ const temporaryCustomTimerEntry = ref<string>('');
const open = ref(false);
function updateTimerAndStartLiveTimerUpdate() {
const time = parse(temporaryCustomTimerEntry.value, 's');
if (time && time > 0) {
const defaultUnit =
organizationSettings?.value?.intervalFormat === 'decimal'
? 'hours'
: 'minutes';
const { seconds } = parseTimeInput(
temporaryCustomTimerEntry.value,
defaultUnit
);
if (seconds && seconds > 0) {
let newEndDate = props.end;
let newStartDate = props.start;
if (props.end) {
// only update end for time entries that are already finished
newEndDate = dayjs(props.start).utc().add(time, 's').format();
newEndDate = dayjs(props.start).utc().add(seconds, 's').format();
} else {
newStartDate = dayjs().utc().subtract(time, 's').format();
newStartDate = dayjs().utc().subtract(seconds, 's').format();
}
emit('changed', newStartDate, newEndDate);
}
@@ -40,7 +55,9 @@ const currentTime = computed({
return temporaryCustomTimerEntry.value;
}
return formatHumanReadableDuration(
calculateDifference(props.start, props.end)
calculateDifference(props.start, props.end),
organizationSettings.value.intervalFormat,
organizationSettings.value.numberFormat
);
},
// setter
@@ -63,8 +80,9 @@ function selectInput(event: Event) {
<template>
<input
v-model="currentTime"
data-testid="time_entry_duration_input"
name="Duration"
class="text-text-primary w-[90px] px-2 py-1.5 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-semibold focus-visible:bg-tertiary focus-visible:border-transparent focus-visible:ring-2 focus-visible:ring-ring"
class="text-text-primary w-[90px] px-2.5 py-1.5 bg-transparent text-right hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-semibold focus-visible:bg-tertiary focus-visible:border-transparent focus-visible:ring-2 focus-visible:ring-ring"
@focus="selectInput"
@keydown.tab="open = false"
@blur="updateTimerAndStartLiveTimerUpdate"

View File

@@ -6,6 +6,11 @@ import {
formatWeekday,
} from '@/packages/ui/src/utils/time';
import Checkbox from '../Input/Checkbox.vue';
import { inject, type ComputedRef } from 'vue';
import type { Organization } from '@/packages/api/src';
const organization = inject<ComputedRef<Organization>>('organization');
defineProps<{
date: string;
duration: number;
@@ -58,7 +63,13 @@ function selectUnselectAll(value: boolean) {
</div>
<div class="text-text-secondary pr-[90px] lg:pr-[92px]">
<span class="font-semibold">
{{ formatHumanReadableDuration(duration) }}
{{
formatHumanReadableDuration(
duration,
organization?.interval_format,
organization?.number_format
)
}}
</span>
</div>
</div>

View File

@@ -3,8 +3,11 @@ import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
import { computed, ref } from 'vue';
import TimeRangeSelector from '@/packages/ui/src/Input/TimeRangeSelector.vue';
import dayjs, { Dayjs } from 'dayjs';
import parse from 'parse-duration';
import { formatDuration, getDayJsInstance } from '@/packages/ui/src/utils/time';
import {
formatDuration,
getDayJsInstance,
parseTimeInput,
} from '@/packages/ui/src/utils/time';
import type { TimeEntry } from '@/packages/api/src';
const currentTimeEntry = defineModel<TimeEntry>('currentTimeEntry', {
@@ -28,6 +31,7 @@ function pauseLiveTimerUpdate(event: FocusEvent) {
function onTimeEntryEnterPress() {
updateTimerAndStartLiveTimerUpdate();
open.value = false;
const activeElement = document.activeElement as HTMLElement;
activeElement?.blur();
}
@@ -55,36 +59,13 @@ const currentTime = computed({
});
function updateTimerAndStartLiveTimerUpdate() {
const time = parse(temporaryCustomTimerEntry.value, 's');
const { seconds } = parseTimeInput(
temporaryCustomTimerEntry.value,
'minutes'
);
if (isNumeric(temporaryCustomTimerEntry.value)) {
const newStartDate = dayjs().subtract(
parseInt(temporaryCustomTimerEntry.value),
'm'
);
currentTimeEntry.value.start = newStartDate.utc().format();
if (currentTimeEntry.value.id !== '') {
emit('updateTimer');
} else {
emit('startTimer');
}
} else if (isHHMM(temporaryCustomTimerEntry.value)) {
const results = parseHHMM(temporaryCustomTimerEntry.value);
if (results) {
const newStartDate = dayjs()
.subtract(parseInt(results[1]), 'h')
.subtract(parseInt(results[2]), 'm');
currentTimeEntry.value.start = newStartDate.utc().format();
if (currentTimeEntry.value.id !== '') {
emit('updateTimer');
} else {
emit('startTimer');
}
}
}
// try to parse natural language like "1h 30m"
else if (time && time > 1) {
const newStartDate = dayjs().subtract(time, 's');
if (seconds && seconds > 0) {
const newStartDate = dayjs().subtract(seconds, 's');
currentTimeEntry.value.start = newStartDate.utc().format();
if (currentTimeEntry.value.id !== '') {
emit('updateTimer');
@@ -92,26 +73,11 @@ function updateTimerAndStartLiveTimerUpdate() {
emit('startTimer');
}
}
// fallback to minutes if just a number is given
now.value = dayjs().utc();
temporaryCustomTimerEntry.value = '';
emit('startLiveTimer');
}
function isNumeric(value: string) {
return /^-?\d+$/.test(value);
}
const HHMMtimeRegex = /^([0-9]{1,2}):([0-5]?[0-9])$/;
function isHHMM(value: string): boolean {
return HHMMtimeRegex.test(value);
}
function parseHHMM(value: string): string[] | null {
return value.match(HHMMtimeRegex);
}
const temporaryCustomTimerEntry = ref<string>('');
async function updateTimeRange(newStart: string) {
@@ -161,8 +127,8 @@ function focusNextElement(e: KeyboardEvent) {
}
function closeAndFocusInput() {
inputField.value?.focus();
open.value = false;
inputField.value?.focus();
}
</script>
@@ -173,7 +139,7 @@ function closeAndFocusInput() {
align="center"
:auto-focus="false"
:close-on-content-click="false"
@submit="open = false">
@submit="closeAndFocusInput">
<template #trigger>
<input
ref="inputField"

View File

@@ -0,0 +1,41 @@
export type NumberFormat =
| 'point-comma'
| 'comma-point'
| 'space-comma'
| 'space-point'
| 'apostrophe-point';
/**
* Formats a number according to the specified format
* @param value - The number to format
* @param format - The format to use
* @returns The formatted number as a string
*/
export function formatNumber(value: number, format?: string): string {
// Convert to fixed 2 decimal places first
const parts = value.toFixed(2).split('.');
const wholePart = parts[0];
const decimalPart = parts[1];
// Format the whole number part based on the format
let formattedWhole: string;
switch (format) {
case 'point-comma':
formattedWhole = wholePart.replace(/\B(?=(\d{3})+(?!\d))/g, '.');
return `${formattedWhole},${decimalPart}`;
case 'comma-point':
formattedWhole = wholePart.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
return `${formattedWhole}.${decimalPart}`;
case 'space-comma':
formattedWhole = wholePart.replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
return `${formattedWhole},${decimalPart}`;
case 'space-point':
formattedWhole = wholePart.replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
return `${formattedWhole}.${decimalPart}`;
case 'apostrophe-point':
formattedWhole = wholePart.replace(/\B(?=(\d{3})+(?!\d))/g, "'");
return `${formattedWhole}.${decimalPart}`;
default:
return value.toString();
}
}

View File

@@ -6,10 +6,34 @@ import isYesterday from 'dayjs/plugin/isYesterday';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
import weekOfYear from 'dayjs/plugin/weekOfYear';
import parse from 'parse-duration';
import { getUserTimezone, getWeekStart } from './settings';
import updateLocale from 'dayjs/plugin/updateLocale';
import { computed } from 'vue';
import { formatNumber } from './number';
export type CurrencyFormat =
| 'iso-code-before-with-space'
| 'iso-code-after-with-space'
| 'symbol-before'
| 'symbol-after'
| 'symbol-before-with-space'
| 'symbol-after-with-space';
export type DateFormat =
| 'point-separated-d-m-yyyy'
| 'slash-separated-mm-dd-yyyy'
| 'slash-separated-dd-mm-yyyy'
| 'hyphen-separated-dd-mm-yyyy'
| 'hyphen-separated-mm-dd-yyyy'
| 'hyphen-separated-yyyy-mm-dd';
export type TimeFormat = '12-hours' | '24-hours';
export type IntervalFormat =
| 'decimal'
| 'hours-minutes'
| 'hours-minutes-colon-separated'
| 'hours-minutes-seconds-colon-separated';
export type TimeInputUnit = 'minutes' | 'hours';
dayjs.extend(relativeTime);
dayjs.extend(isToday);
@@ -40,11 +64,28 @@ export const firstDayIndex = computed(() => {
return apiDayOrder.indexOf(getWeekStart());
});
export function formatHumanReadableDuration(duration: number): string {
export function formatHumanReadableDuration(
duration: number,
intervalFormat?: string,
numberFormat?: string
): string {
const dayJsDuration = dayjs.duration(duration, 's');
const hours = Math.floor(dayJsDuration.asHours());
const minutes = dayJsDuration.minutes();
return `${hours}h ${minutes.toString().padStart(2, '0')}min`;
const seconds = dayJsDuration.seconds();
switch (intervalFormat) {
case 'decimal':
return formatNumber(dayJsDuration.asHours(), numberFormat) + ' h';
case 'hours-minutes':
return `${hours}h ${minutes.toString().padStart(2, '0')}min`;
case 'hours-minutes-colon-separated':
return `${hours}:${minutes.toString().padStart(2, '0')}`;
case 'hours-minutes-seconds-colon-separated':
return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
default:
return `${hours}h ${minutes.toString().padStart(2, '0')}min`;
}
}
export function formatDuration(duration: number): string {
@@ -131,3 +172,44 @@ export function formatStartEnd(start: string, end: string | null) {
return `${formatTime(start)} - ...`;
}
}
export function parseTimeInput(
input: string,
defaultUnit: TimeInputUnit = 'minutes'
): {
seconds: number | null;
isHHMM: boolean;
} {
// Check if input is a decimal number (hours)
const decimalRegex = /^-?\d+[.,]\d+$/;
if (decimalRegex.test(input)) {
const hours = parseFloat(input.replace(',', '.'));
return { seconds: Math.round(hours * 3600), isHHMM: false };
}
// Check if input is just a number (minutes or hours based on defaultUnit)
if (/^-?\d+$/.test(input)) {
const value = parseInt(input);
const seconds = defaultUnit === 'minutes' ? value * 60 : value * 3600;
return { seconds, isHHMM: false };
}
// Check if input is in HH:MM format
const HHMMtimeRegex = /^([0-9]{1,2}):([0-5]?[0-9])$/;
if (HHMMtimeRegex.test(input)) {
const match = input.match(HHMMtimeRegex);
if (match) {
const hours = parseInt(match[1]);
const minutes = parseInt(match[2]);
return { seconds: (hours * 60 + minutes) * 60, isHHMM: true };
}
}
// Try to parse natural language like "1h 30m"
const parsedDuration = parse(input, 's');
if (parsedDuration && parsedDuration > 0) {
return { seconds: parsedDuration, isHHMM: false };
}
return { seconds: null, isHHMM: false };
}