mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-13 18:52:14 +01:00
add support for interval / duration format in frontend views
This commit is contained in:
committed by
Constantin Graf
parent
b8d9bc5b7e
commit
c1d43bcc67
@@ -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>
|
||||
|
||||
@@ -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 ?? [])
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
41
resources/js/packages/ui/src/utils/number.ts
Normal file
41
resources/js/packages/ui/src/utils/number.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user