add format check, update prettier rules, apply rules consistently

This commit is contained in:
Gregor Vostrak
2025-07-29 18:58:31 +02:00
parent b11672732b
commit cb30487a21
323 changed files with 3728 additions and 5579 deletions

View File

@@ -22,10 +22,7 @@ const props = withDefaults(
:type="type"
:disabled="loading"
class="inline-flex items-center px-2 sm:px-3 py-1 sm:py-2 bg-button-primary-background border border-button-primary-border rounded-md font-medium text-xs sm:text-sm text-button-primary-text hover:bg-button-primary-background-hover active:bg-button-primary-background-hover focus:outline-none focus-visible:ring-2 focus-visible:border-transparent focus-visible:ring-ring transition ease-in-out duration-150">
<span
:class="
twMerge('flex items-center ', props.icon ? 'space-x-1.5' : '')
">
<span :class="twMerge('flex items-center ', props.icon ? 'space-x-1.5' : '')">
<LoadingSpinner v-if="loading"></LoadingSpinner>
<component
:is="props.icon"

View File

@@ -36,10 +36,7 @@ const sizeClasses = {
props.class
)
">
<span
:class="
twMerge('flex items-center ', props.icon ? 'space-x-1.5' : '')
">
<span :class="twMerge('flex items-center ', props.icon ? 'space-x-1.5' : '')">
<LoadingSpinner v-if="loading"></LoadingSpinner>
<component
:is="props.icon"

View File

@@ -11,10 +11,7 @@ defineProps<{
<div class="flex w-full items-center justify-between pb-2.5 lg:pb-3">
<h3
class="text-text-primary font-medium text-sm lg:text-base flex items-center space-x-1.5 lg:space-x-2">
<component
:is="icon"
v-if="icon"
class="w-4 lg:w-4 text-icon-default"></component>
<component :is="icon" v-if="icon" class="w-4 lg:w-4 text-icon-default"></component>
<span>
{{ title }}
</span>

View File

@@ -42,9 +42,7 @@ watch(open, (isOpen) => {
const filteredClients = computed(() => {
return props.clients.filter((client) => {
return client.name
.toLowerCase()
.includes(searchValue.value?.toLowerCase()?.trim() || '');
return client.name.toLowerCase().includes(searchValue.value?.toLowerCase()?.trim() || '');
});
});
@@ -84,9 +82,7 @@ function updateValue(client: { id: string | null; name: string }) {
<slot name="trigger"></slot>
</template>
<template #content>
<UseFocusTrap
v-if="open"
:options="{ immediate: true, allowOutsideClick: true }">
<UseFocusTrap v-if="open" :options="{ immediate: true, allowOutsideClick: true }">
<ComboboxRoot
v-model:search-term="searchValue"
:open="open"
@@ -100,14 +96,11 @@ function updateValue(client: { id: string | null; name: string }) {
placeholder="Search for a client..." />
</ComboboxAnchor>
<ComboboxContent>
<ComboboxViewport
class="w-60 max-h-60 overflow-y-scroll">
<ComboboxViewport class="w-60 max-h-60 overflow-y-scroll">
<ComboboxItem
:value="{ id: null, name: 'No Client' }"
class="data-[highlighted]:bg-card-background-active">
<ClientDropdownItem
:selected="model === null"
name="No Client" />
<ClientDropdownItem :selected="model === null" name="No Client" />
</ComboboxItem>
<ComboboxItem
v-for="client in filteredClients"
@@ -120,19 +113,13 @@ function updateValue(client: { id: string | null; name: string }) {
:name="client.name" />
</ComboboxItem>
<div
v-if="
searchValue.length > 0 &&
filteredClients.length === 0
"
v-if="searchValue.length > 0 && filteredClients.length === 0"
class="bg-card-background-active">
<div
class="flex space-x-3 items-center px-4 py-3 text-xs text-text-primary font-medium border-t rounded-b-lg border-card-background-separator"
@click="addClientIfNoneExists">
<PlusCircleIcon class="w-5 flex-shrink-0" />
<span
>Add "{{ searchValue }}" as a new
Client</span
>
<span>Add "{{ searchValue }}" as a new Client</span>
</div>
</div>
</ComboboxViewport>

View File

@@ -24,11 +24,7 @@ const close = () => {
</script>
<template>
<Modal
:show="show"
:max-width="maxWidth"
:closeable="closeable"
@close="close">
<Modal :show="show" :max-width="maxWidth" :closeable="closeable" @close="close">
<div class="px-6 py-4">
<div class="text-lg font-medium text-text-primary" role="heading">
<slot name="title" />

View File

@@ -34,8 +34,7 @@ const iconSizeClasses = computed(() => {
}
});
const iconSizeWrapperClasses =
props.size === 'small' ? 'w-6 sm:w-8 h-6 sm:h-8' : 'w-10 h-10';
const iconSizeWrapperClasses = props.size === 'small' ? 'w-6 sm:w-8 h-6 sm:h-8' : 'w-10 h-10';
</script>
<template>

View File

@@ -1,9 +1,6 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import {
getDayJsInstance,
getLocalizedDayJs,
} from '@/packages/ui/src/utils/time';
import { getDayJsInstance, getLocalizedDayJs } from '@/packages/ui/src/utils/time';
import { twMerge } from 'tailwind-merge';
const props = defineProps<{

View File

@@ -1,19 +1,12 @@
<script setup lang="ts">
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/Components/ui/popover';
import { Popover, PopoverContent, PopoverTrigger } from '@/Components/ui/popover';
import { Button } from '@/Components/ui/button';
import { RangeCalendar } from '@/Components/ui/range-calendar';
import { CalendarDate } from '@internationalized/date';
import { CalendarIcon } from 'lucide-vue-next';
import { computed, ref, inject, type ComputedRef, watch } from 'vue';
import { twMerge } from 'tailwind-merge';
import {
getDayJsInstance,
getLocalizedDayJs,
} from '@/packages/ui/src/utils/time';
import { getDayJsInstance, getLocalizedDayJs } from '@/packages/ui/src/utils/time';
import { type Organization } from '@/packages/api/src';
import { getUserTimezone } from '@/packages/ui/src/utils/settings';
import { formatDate } from '@/packages/ui/src/utils/time';
@@ -72,128 +65,68 @@ const modelValue = computed<CalendarDateRange>({
const open = ref(false);
function setToday() {
emit(
'update:start',
getLocalizedDayJs().startOf('day').format()
);
emit('update:start', getLocalizedDayJs().startOf('day').format());
emit('update:end', getLocalizedDayJs().endOf('day').format());
open.value = false;
}
function setThisWeek() {
emit(
'update:start',
getLocalizedDayJs().startOf('week').format()
);
emit('update:start', getLocalizedDayJs().startOf('week').format());
emit('update:end', getLocalizedDayJs().endOf('week').format());
open.value = false;
}
function setLastWeek() {
emit(
'update:start',
getLocalizedDayJs()
.subtract(1, 'week')
.startOf('week')
.format()
);
emit(
'update:end',
getLocalizedDayJs()
.subtract(1, 'week')
.endOf('week')
.format()
);
emit('update:start', getLocalizedDayJs().subtract(1, 'week').startOf('week').format());
emit('update:end', getLocalizedDayJs().subtract(1, 'week').endOf('week').format());
open.value = false;
}
function setLast14Days() {
emit(
'update:start',
getLocalizedDayJs().subtract(14, 'days').format()
);
emit('update:start', getLocalizedDayJs().subtract(14, 'days').format());
emit('update:end', getLocalizedDayJs().format());
open.value = false;
}
function setThisMonth() {
emit(
'update:start',
getLocalizedDayJs().startOf('month').format()
);
emit('update:start', getLocalizedDayJs().startOf('month').format());
emit('update:end', getLocalizedDayJs().endOf('month').format());
open.value = false;
}
function setLastMonth() {
emit(
'update:start',
getLocalizedDayJs()
.subtract(1, 'month')
.startOf('month')
.format()
);
emit(
'update:end',
getLocalizedDayJs()
.subtract(1, 'month')
.endOf('month')
.format()
);
emit('update:start', getLocalizedDayJs().subtract(1, 'month').startOf('month').format());
emit('update:end', getLocalizedDayJs().subtract(1, 'month').endOf('month').format());
open.value = false;
}
function setLast30Days() {
emit(
'update:start',
getLocalizedDayJs().subtract(30, 'days').format()
);
emit('update:start', getLocalizedDayJs().subtract(30, 'days').format());
emit('update:end', getLocalizedDayJs().format());
open.value = false;
}
function setLast90Days() {
emit(
'update:start',
getDayJsInstance()().subtract(90, 'days').format()
);
emit('update:start', getDayJsInstance()().subtract(90, 'days').format());
emit('update:end', getDayJsInstance()().format());
open.value = false;
}
function setLast12Months() {
emit(
'update:start',
getLocalizedDayJs().subtract(12, 'months').format()
);
emit('update:start', getLocalizedDayJs().subtract(12, 'months').format());
emit('update:end', getLocalizedDayJs().format());
open.value = false;
}
function setThisYear() {
emit(
'update:start',
getLocalizedDayJs().startOf('year').format()
);
emit('update:start', getLocalizedDayJs().startOf('year').format());
emit('update:end', getLocalizedDayJs().endOf('year').format());
open.value = false;
}
function setLastYear() {
emit(
'update:start',
getLocalizedDayJs()
.subtract(1, 'year')
.startOf('year')
.format()
);
emit(
'update:end',
getLocalizedDayJs()
.subtract(1, 'year')
.endOf('year')
.format()
);
emit('update:start', getLocalizedDayJs().subtract(1, 'year').startOf('year').format());
emit('update:end', getLocalizedDayJs().subtract(1, 'year').endOf('year').format());
open.value = false;
}
@@ -220,27 +153,12 @@ watch(open, (value) => {
<CalendarIcon class="-ml-0.5 text-text-quaternary h-4 w-4" />
<template v-if="modelValue.start">
<template v-if="modelValue.end">
{{
formatDate(
modelValue.start.toString(),
organization?.date_format
)
}}
{{ formatDate(modelValue.start.toString(), organization?.date_format) }}
-
{{
formatDate(
modelValue.end.toString(),
organization?.date_format
)
}}
{{ formatDate(modelValue.end.toString(), organization?.date_format) }}
</template>
<template v-else>
{{
formatDate(
modelValue.start.toString(),
organization?.date_format
)
}}
{{ formatDate(modelValue.start.toString(), organization?.date_format) }}
</template>
</template>
<template v-else> Pick a date </template>
@@ -250,17 +168,39 @@ watch(open, (value) => {
<div class="flex divide-x divide-border-secondary">
<div
class="text-text-primary text-sm flex flex-col space-y-0.5 items-start py-2 px-2">
<Button variant="ghost" size="sm" class="justify-start" @click="setToday">Today</Button>
<Button variant="ghost" size="sm" class="justify-start" @click="setThisWeek">This Week</Button>
<Button variant="ghost" size="sm" class="justify-start" @click="setLastWeek">Last Week</Button>
<Button variant="ghost" size="sm" class="justify-start" @click="setLast14Days">Last 14 days</Button>
<Button variant="ghost" size="sm" class="justify-start" @click="setThisMonth">This Month</Button>
<Button variant="ghost" size="sm" class="justify-start" @click="setLastMonth">Last Month</Button>
<Button variant="ghost" size="sm" class="justify-start" @click="setLast30Days">Last 30 days</Button>
<Button variant="ghost" size="sm" class="justify-start" @click="setLast90Days">Last 90 days</Button>
<Button variant="ghost" size="sm" class="justify-start" @click="setLast12Months">Last 12 months</Button>
<Button variant="ghost" size="sm" class="justify-start" @click="setThisYear">This year</Button>
<Button variant="ghost" size="sm" class="justify-start" @click="setLastYear">Last year</Button>
<Button variant="ghost" size="sm" class="justify-start" @click="setToday"
>Today</Button
>
<Button variant="ghost" size="sm" class="justify-start" @click="setThisWeek"
>This Week</Button
>
<Button variant="ghost" size="sm" class="justify-start" @click="setLastWeek"
>Last Week</Button
>
<Button variant="ghost" size="sm" class="justify-start" @click="setLast14Days"
>Last 14 days</Button
>
<Button variant="ghost" size="sm" class="justify-start" @click="setThisMonth"
>This Month</Button
>
<Button variant="ghost" size="sm" class="justify-start" @click="setLastMonth"
>Last Month</Button
>
<Button variant="ghost" size="sm" class="justify-start" @click="setLast30Days"
>Last 30 days</Button
>
<Button variant="ghost" size="sm" class="justify-start" @click="setLast90Days"
>Last 90 days</Button
>
<Button variant="ghost" size="sm" class="justify-start" @click="setLast12Months"
>Last 12 months</Button
>
<Button variant="ghost" size="sm" class="justify-start" @click="setThisYear"
>This year</Button
>
<Button variant="ghost" size="sm" class="justify-start" @click="setLastYear"
>Last year</Button
>
</div>
<div class="pl-2">
<RangeCalendar

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { Popover, PopoverContent, PopoverTrigger } from '@/Components/ui/popover';
import { watch } from "vue";
import { watch } from 'vue';
const props = withDefaults(
defineProps<{
@@ -42,7 +42,6 @@ watch(open, (value) => {
emit('submit');
}
});
</script>
<template>

View File

@@ -1,10 +1,7 @@
<script setup lang="ts">
import parse from 'parse-duration';
import { onMounted, ref, watch, inject } from 'vue';
import {
formatHumanReadableDuration,
getDayJsInstance,
} from '@/packages/ui/src/utils/time';
import { formatHumanReadableDuration, getDayJsInstance } from '@/packages/ui/src/utils/time';
import dayjs from 'dayjs';
import { twMerge } from 'tailwind-merge';
import { TextInput } from '@/packages/ui/src';

View File

@@ -61,8 +61,7 @@ function updateAndSubmit() {
@blur="updateDuration"
@keydown.enter="updateAndSubmit">
</TextInput>
<div
class="absolute top-0 right-0 h-full flex items-center px-4 font-medium">
<div class="absolute top-0 right-0 h-full flex items-center px-4 font-medium">
<span> hours </span>
</div>
</div>

View File

@@ -8,8 +8,7 @@ const props = defineProps<{
</script>
<template>
<label
:class="twMerge('block font-medium text-sm text-text-primary', props.class)">
<label :class="twMerge('block font-medium text-sm text-text-primary', props.class)">
<span v-if="value">{{ value }}</span>
<span v-else><slot /></span>
</label>

View File

@@ -44,17 +44,13 @@ watch(open, (isOpen) => {
const aIsSelected = model.value.includes(props.getKeyFromItem(a));
const bIsSelected = model.value.includes(props.getKeyFromItem(b));
if (aIsSelected === bIsSelected) {
return props
.getNameForItem(a)
.localeCompare(props.getNameForItem(b));
return props.getNameForItem(a).localeCompare(props.getNameForItem(b));
}
return model.value.includes(props.getKeyFromItem(a)) ? -1 : 1;
});
nextTick(() => {
if (filteredItems.value.length > 0) {
highlightedItemId.value = props.getKeyFromItem(
filteredItems.value[0]
);
highlightedItemId.value = props.getKeyFromItem(filteredItems.value[0]);
}
});
}
@@ -85,9 +81,7 @@ function updateSearchValue(event: Event) {
(item) => props.getKeyFromItem(item) === highlightedTagId
);
if (highlightedItem) {
addOrRemoveItemFromSelection(
props.getKeyFromItem(highlightedItem)
);
addOrRemoveItemFromSelection(props.getKeyFromItem(highlightedItem));
}
}
} else {
@@ -110,9 +104,7 @@ function toggleItem(newValue: string | null) {
function moveHighlightUp() {
if (highlightedItem.value) {
const currentHightlightedIndex = filteredItems.value.indexOf(
highlightedItem.value
);
const currentHightlightedIndex = filteredItems.value.indexOf(highlightedItem.value);
if (currentHightlightedIndex === 0) {
highlightedItemId.value = props.getKeyFromItem(
filteredItems.value[filteredItems.value.length - 1]
@@ -127,13 +119,9 @@ function moveHighlightUp() {
function moveHighlightDown() {
if (highlightedItem.value) {
const currentHightlightedIndex = filteredItems.value.indexOf(
highlightedItem.value
);
const currentHightlightedIndex = filteredItems.value.indexOf(highlightedItem.value);
if (currentHightlightedIndex === filteredItems.value.length - 1) {
highlightedItemId.value = props.getKeyFromItem(
filteredItems.value[0]
);
highlightedItemId.value = props.getKeyFromItem(filteredItems.value[0]);
} else {
highlightedItemId.value = props.getKeyFromItem(
filteredItems.value[currentHightlightedIndex + 1]
@@ -144,9 +132,7 @@ function moveHighlightDown() {
const highlightedItemId = ref<string | null>(null);
const highlightedItem = computed(() => {
return props.items.find(
(item) => props.getKeyFromItem(item) === highlightedItemId.value
);
return props.items.find((item) => props.getKeyFromItem(item) === highlightedItemId.value);
});
</script>
@@ -165,9 +151,7 @@ const highlightedItem = computed(() => {
@keydown.up.prevent="moveHighlightUp"
@keydown.down.prevent="moveHighlightDown"
@keydown.enter="toggleItem(highlightedItemId)" />
<div
ref="dropdownViewport"
class="min-w-60 max-w-80 max-h-60 overflow-y-scroll">
<div ref="dropdownViewport" class="min-w-60 max-w-80 max-h-60 overflow-y-scroll">
<div
v-for="item in filteredItems"
:key="props.getKeyFromItem(item)"
@@ -180,9 +164,7 @@ const highlightedItem = computed(() => {
:data-item-id="props.getKeyFromItem(item)">
<MultiselectDropdownItem
:selected="isItemSelected(props.getKeyFromItem(item))"
:name="
props.getNameForItem(item)
"
:name="props.getNameForItem(item)"
@click="toggleItem(props.getKeyFromItem(item))"></MultiselectDropdownItem>
</div>
</div>

View File

@@ -21,9 +21,7 @@ const iconClasses = computed(() => {
<div
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-text-primary hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
<CheckCircleIcon :class="twMerge(iconClasses, 'w-5')"></CheckCircleIcon>
<span class="flex-1 min-w-0 overflow-ellipsis overflow-hidden">{{
name
}}</span>
<span class="flex-1 min-w-0 overflow-ellipsis overflow-hidden">{{ name }}</span>
</div>
</template>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts" generic="T">
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
import {computed, nextTick, onMounted, ref, watch} from 'vue';
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import SelectDropdownItem from '@/packages/ui/src/Input/SelectDropdownItem.vue';
import { onKeyStroke } from '@vueuse/core';
import { twMerge } from 'tailwind-merge';
@@ -42,7 +42,7 @@ const filteredItems = computed<T[]>(() => {
const highlightedItemId = ref<string | null>(model.value);
watch(model, () => {
if(model.value){
if (model.value) {
highlightedItemId.value = model.value;
}
});
@@ -57,7 +57,7 @@ watch(filteredItems, () => {
resetHightlightedItem();
});
function resetHightlightedItem(){
function resetHightlightedItem() {
if (
filteredItems.value.length > 0 &&
filteredItems.value.find(
@@ -91,9 +91,7 @@ function setItem(newValue: string | null) {
function moveHighlightUp() {
if (highlightedItem.value) {
const currentHightlightedIndex = filteredItems.value.indexOf(
highlightedItem.value
);
const currentHightlightedIndex = filteredItems.value.indexOf(highlightedItem.value);
if (currentHightlightedIndex === 0) {
highlightedItemId.value = props.getKeyFromItem(
filteredItems.value[filteredItems.value.length - 1]
@@ -108,13 +106,9 @@ function moveHighlightUp() {
function moveHighlightDown() {
if (highlightedItem.value) {
const currentHightlightedIndex = filteredItems.value.indexOf(
highlightedItem.value
);
const currentHightlightedIndex = filteredItems.value.indexOf(highlightedItem.value);
if (currentHightlightedIndex === filteredItems.value.length - 1) {
highlightedItemId.value = props.getKeyFromItem(
filteredItems.value[0]
);
highlightedItemId.value = props.getKeyFromItem(filteredItems.value[0]);
} else {
highlightedItemId.value = props.getKeyFromItem(
filteredItems.value[currentHightlightedIndex + 1]
@@ -124,9 +118,7 @@ function moveHighlightDown() {
}
const highlightedItem = computed(() => {
return props.items.find(
(item) => props.getKeyFromItem(item) === highlightedItemId.value
);
return props.items.find((item) => props.getKeyFromItem(item) === highlightedItemId.value);
});
onKeyStroke('ArrowDown', (e) => {
@@ -173,12 +165,7 @@ watch(open, () => {
<template #content>
<div
ref="dropdownViewport"
:class="
twMerge(
'w-60 py-1.5 max-h-60 overflow-y-scroll',
props.class
)
">
:class="twMerge('w-60 py-1.5 max-h-60 overflow-y-scroll', props.class)">
<div
v-for="item in filteredItems"
:key="props.getKeyFromItem(item) ?? 'none'"
@@ -187,14 +174,10 @@ watch(open, () => {
:value="props.getKeyFromItem(item)"
:data-item-id="props.getKeyFromItem(item)">
<SelectDropdownItem
:highlighted="
props.getKeyFromItem(item) === highlightedItemId
"
:highlighted="props.getKeyFromItem(item) === highlightedItemId"
:selected="props.getKeyFromItem(item) === model"
:name="props.getNameForItem(item)"
@mouseenter="
highlightedItemId = props.getKeyFromItem(item)
"
@mouseenter="highlightedItemId = props.getKeyFromItem(item)"
@click="setItem(props.getKeyFromItem(item))"></SelectDropdownItem>
</div>
</div>

View File

@@ -15,9 +15,7 @@ const props = defineProps<{
twMerge(
'flex items-center space-x-3 w-full px-1.5 py-1.5 rounded text-start text-sm font-medium leading-5 text-text-primary focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out cursor-pointer ',
props.highlighted && 'bg-card-background-active',
props.selected
? 'bg-accent-300/20'
: 'hover:bg-card-background-active'
props.selected ? 'bg-accent-300/20' : 'hover:bg-card-background-active'
)
">
<span>{{ name }}</span>

View File

@@ -6,21 +6,24 @@ const model = defineModel<string | number>({
default: '',
});
const props = withDefaults(defineProps<{
id?: string;
type?: string;
class?: string;
rows?: number;
placeholder?: string;
disabled?: boolean;
required?: boolean;
}>(), {
type: 'text',
class: '',
rows: 3,
disabled: false,
required: false,
});
const props = withDefaults(
defineProps<{
id?: string;
type?: string;
class?: string;
rows?: number;
placeholder?: string;
disabled?: boolean;
required?: boolean;
}>(),
{
type: 'text',
class: '',
rows: 3,
disabled: false,
required: false,
}
);
const classes = computed(() => {
return twMerge(
@@ -39,6 +42,5 @@ const classes = computed(() => {
:rows="rows"
:placeholder="placeholder"
:disabled="disabled"
:required="required"
/>
:required="required" />
</template>

View File

@@ -1,9 +1,6 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import {
getDayJsInstance,
getLocalizedDayJs,
} from '@/packages/ui/src/utils/time';
import { getDayJsInstance, getLocalizedDayJs } from '@/packages/ui/src/utils/time';
import { useFocus } from '@vueuse/core';
import { SelectDropdown, TextInput } from '@/packages/ui/src';
import { twMerge } from 'tailwind-merge';
@@ -41,10 +38,7 @@ function updateTime(event: Event) {
else if (/^\d+$/.test(newValue)) {
if (newValue.length === 4) {
// parse 1300 to 13:00
const [hours, minutes] = [
newValue.slice(0, 2),
newValue.slice(2, 4),
];
const [hours, minutes] = [newValue.slice(0, 2), newValue.slice(2, 4)];
model.value = getLocalizedDayJs(model.value)
.set('hours', Math.min(parseInt(hours), 23))
.set('minutes', Math.min(parseInt(minutes), 59))
@@ -52,10 +46,7 @@ function updateTime(event: Event) {
emit('changed', model.value);
} else if (newValue.length === 3) {
// parse 130 to 01:30
const [hours, minutes] = [
newValue.slice(0, 1),
newValue.slice(1, 3),
];
const [hours, minutes] = [newValue.slice(0, 1), newValue.slice(1, 3)];
model.value = getLocalizedDayJs(model.value)
.set('hours', Math.min(parseInt(hours), 23))
.set('minutes', Math.min(parseInt(minutes), 59))
@@ -113,9 +104,7 @@ const getStartOptions = computed<TimeOption[]>(() => {
}
return options;
});
const inputValue = ref(
model.value ? getLocalizedDayJs(model.value).format('HH:mm') : null
);
const inputValue = ref(model.value ? getLocalizedDayJs(model.value).format('HH:mm') : null);
const open = ref(false);
const closestValue = computed({
get() {
@@ -123,9 +112,7 @@ const closestValue = computed({
let closestDiff: number | null = null;
let closest = target;
for (const option of getStartOptions.value) {
const diff = Math.abs(
getDayJsInstance()(option.timestamp).diff(target)
);
const diff = Math.abs(getDayJsInstance()(option.timestamp).diff(target));
if (closestDiff === null || diff < closestDiff) {
closestDiff = diff;
closest = getDayJsInstance()(option.timestamp);
@@ -153,12 +140,7 @@ const closestValue = computed({
<TextInput
ref="timeInput"
v-model="inputValue"
:class="
twMerge(
'text-center w-24 px-3 py-2',
size === 'large' && 'w-28'
)
"
:class="twMerge('text-center w-24 px-3 py-2', size === 'large' && 'w-28')"
data-testid="time_picker_input"
type="text"
@blur="updateTime"

View File

@@ -38,10 +38,7 @@ function updateTime(event: Event) {
else if (/^\d+$/.test(newValue)) {
if (newValue.length === 4) {
// parse 1300 to 13:00
const [hours, minutes] = [
newValue.slice(0, 2),
newValue.slice(2, 4),
];
const [hours, minutes] = [newValue.slice(0, 2), newValue.slice(2, 4)];
model.value = getLocalizedDayJs(model.value)
.set('hours', Math.min(parseInt(hours), 23))
.set('minutes', Math.min(parseInt(minutes), 59))
@@ -49,10 +46,7 @@ function updateTime(event: Event) {
emit('changed', model.value);
} else if (newValue.length === 3) {
// parse 130 to 01:30
const [hours, minutes] = [
newValue.slice(0, 1),
newValue.slice(1, 3),
];
const [hours, minutes] = [newValue.slice(0, 1), newValue.slice(1, 3)];
model.value = getLocalizedDayJs(model.value)
.set('hours', Math.min(parseInt(hours), 23))
.set('minutes', Math.min(parseInt(minutes), 59))
@@ -87,9 +81,7 @@ const emit = defineEmits(['changed']);
useFocus(timeInput, { initialValue: props.focus });
const inputValue = ref(
model.value ? getLocalizedDayJs(model.value).format('HH:mm') : null
);
const inputValue = ref(model.value ? getLocalizedDayJs(model.value).format('HH:mm') : null);
</script>
<template>
@@ -97,9 +89,7 @@ const inputValue = ref(
v-bind="$attrs"
ref="timeInput"
v-model="inputValue"
:class="
twMerge('text-center w-24 px-3 py-2', size === 'large' && 'w-28')
"
:class="twMerge('text-center w-24 px-3 py-2', size === 'large' && 'w-28')"
data-testid="time_picker_input"
type="text"
@blur="updateTime"

View File

@@ -2,10 +2,7 @@
import { defineProps, nextTick, ref, watch } from 'vue';
import { useFocusWithin } from '@vueuse/core';
import DatePicker from '@/packages/ui/src/Input/DatePicker.vue';
import {
getDayJsInstance,
getLocalizedDayJs,
} from '@/packages/ui/src/utils/time';
import { getDayJsInstance, getLocalizedDayJs } from '@/packages/ui/src/utils/time';
import dayjs from 'dayjs';
import TimePickerSimple from '@/packages/ui/src/Input/TimePickerSimple.vue';
@@ -18,9 +15,7 @@ const props = defineProps<{
// The timestamps for the changed event are UTC
const emit = defineEmits(['changed', 'close']);
const tempStart = ref(
props.start ? getLocalizedDayJs(props.start).format() : dayjs().format()
);
const tempStart = ref(props.start ? getLocalizedDayJs(props.start).format() : dayjs().format());
const tempEnd = ref(props.end ? getLocalizedDayJs(props.end).format() : null);
watch(props, () => {
@@ -29,9 +24,7 @@ watch(props, () => {
});
function updateTimeEntry() {
const tempStartUtc = getDayJsInstance()(tempStart.value).utc().format();
const tempEndUtc = tempEnd.value
? getDayJsInstance()(tempEnd.value).utc().format()
: null;
const tempEndUtc = tempEnd.value ? getDayJsInstance()(tempEnd.value).utc().format() : null;
if (tempStartUtc !== props.start || tempEndUtc !== props.end) {
emit(
'changed',
@@ -55,9 +48,7 @@ watch(focused, (newValue, oldValue) => {
<div
ref="dropdownContent"
class="grid grid-cols-2 divide-x divide-card-background-separator text-center py-2">
<div
class="px-2"
@keydown.enter.prevent="nextTick(() => emit('close'))">
<div class="px-2" @keydown.enter.prevent="nextTick(() => emit('close'))">
<div class="font-semibold text-text-primary text-sm pb-2">Start</div>
<div class="space-y-2">
<TimePickerSimple

View File

@@ -8,9 +8,7 @@ const props = defineProps<{
<template>
<svg
:class="
twMerge('animate-spin -ml-1 mr-3 h-5 w-5 text-text-primary', props.class)
"
:class="twMerge('animate-spin -ml-1 mr-3 h-5 w-5 text-text-primary', props.class)"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24">

View File

@@ -1,10 +1,6 @@
<script setup lang="ts">
import {
Dialog,
DialogContent,
DialogFooter,
} from '@/Components/ui/dialog'
import { computed } from 'vue'
import { Dialog, DialogContent, DialogFooter } from '@/Components/ui/dialog';
import { computed } from 'vue';
const props = defineProps({
show: {
@@ -43,7 +39,6 @@ const maxWidthClass = computed(() => {
<template>
<Dialog :open="show" @update:open="close">
<DialogContent :class="maxWidthClass">
<div>
<slot />
</div>

View File

@@ -31,12 +31,7 @@ const indicatorClasses = {
<Badge :name :size :tag :class="props.class" :color :border>
<div
:style="{ backgroundColor: props.color }"
:class="
twMerge(
indicatorClasses[size],
'inline-block rounded-full shrink-0'
)
"></div>
:class="twMerge(indicatorClasses[size], 'inline-block rounded-full shrink-0')"></div>
<div class="min-w-0">
<slot>
{{ name }}

View File

@@ -42,8 +42,8 @@ defineEmits<{
>.
</p>
<p class="py-1 text-center font-semibold max-w-md mx-auto">
Do you want to update all existing time entries, where the project
billable rate applies as well?
Do you want to update all existing time entries, where the project billable rate applies
as well?
</p>
</BillableRateModal>
</template>

View File

@@ -49,10 +49,7 @@ function getNameForKey(key: BillableKey | undefined) {
:get-name-for-item="getNameFromItem"
:items="options">
<template #trigger>
<Badge
tag="button"
size="xlarge"
class="bg-input-background cursor-pointer">
<Badge tag="button" size="xlarge" class="bg-input-background cursor-pointer">
<span>
{{ getNameForKey(model) }}
</span>

View File

@@ -3,11 +3,7 @@ import TextInput from '@/packages/ui/src/Input/TextInput.vue';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import DialogModal from '@/packages/ui/src/DialogModal.vue';
import { computed, ref } from 'vue';
import type {
CreateClientBody,
CreateProjectBody,
Project,
} from '@/packages/api/src';
import type { CreateClientBody, CreateProjectBody, Project } from '@/packages/api/src';
import { getRandomColor } from '@/packages/ui/src/utils/color';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import { useFocus } from '@vueuse/core';
@@ -63,9 +59,7 @@ useFocus(projectNameInput, { initialValue: true });
const currentClientName = computed(() => {
if (project.value.client_id) {
return props.clients.find(
(client) => client.id === project.value.client_id
)?.name;
return props.clients.find((client) => client.id === project.value.client_id)?.name;
}
return 'No Client';
});
@@ -80,8 +74,7 @@ const currentClientName = computed(() => {
</template>
<template #content>
<div
class="sm:flex items-center space-y-2 sm:space-y-0 sm:space-x-4">
<div class="sm:flex items-center space-y-2 sm:space-y-0 sm:space-x-4">
<div class="flex-1 flex items-center">
<div class="text-center pr-5">
<InputLabel for="color" value="Color" />
@@ -117,8 +110,7 @@ const currentClientName = computed(() => {
class="bg-input-background cursor-pointer hover:bg-tertiary"
size="xlarge">
<div class="flex items-center space-x-2">
<UserCircleIcon
class="w-5 text-icon-default"></UserCircleIcon>
<UserCircleIcon class="w-5 text-icon-default"></UserCircleIcon>
<span>
{{ currentClientName }}
</span>
@@ -132,9 +124,7 @@ const currentClientName = computed(() => {
<div>
<ProjectEditBillableSection
v-model:is-billable="project.is_billable"
v-model:billable-rate="
project.billable_rate
"
v-model:billable-rate="project.billable_rate"
:currency="currency"></ProjectEditBillableSection>
</div>
<div>

View File

@@ -10,9 +10,7 @@ defineProps<{
<div
class="flex justify-between items-center w-full text-start text-sm font-medium leading-5 text-text-primary hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
<div class="flex space-x-3 items-center px-3 py-1.5">
<div
:style="{ backgroundColor: color }"
class="w-3 h-3 rounded-full"></div>
<div :style="{ backgroundColor: color }" class="w-3 h-3 rounded-full"></div>
<span>{{ name }}</span>
</div>
<slot name="actions"></slot>

View File

@@ -40,8 +40,7 @@ watch(billableRateSelect, () => {
billableRateSelect.value = 'non-billable';
const billableOptionInfoTexts: { [key in BillableKey]: string } = {
'non-billable':
'New time entries for this project will not be marked billable by default.',
'non-billable': 'New time entries for this project will not be marked billable by default.',
'default-rate':
'New time entries for this project will be billable at the default rate by default.',
'custom-rate':
@@ -58,8 +57,7 @@ const emit = defineEmits(['submit']);
<div class="sm:flex items-center space-y-2 sm:space-y-0 sm:space-x-4 pt-6">
<div>
<div class="flex items-center space-x-1 mb-2">
<BillableIcon
class="text-text-quaternary h-4 ml-1 mr-0.5"></BillableIcon>
<BillableIcon class="text-text-quaternary h-4 ml-1 mr-0.5"></BillableIcon>
<InputLabel for="billable" value="Billable Default" />
</div>
<ProjectBillableSelect

View File

@@ -5,7 +5,7 @@ import { type Component, computed, nextTick, ref, watch } from 'vue';
import TagCreateModal from '@/packages/ui/src/Tag/TagCreateModal.vue';
import MultiselectDropdownItem from '@/packages/ui/src/Input/MultiselectDropdownItem.vue';
import type { Tag } from '@/packages/api/src';
import {UseFocusTrap} from "@vueuse/integrations/useFocusTrap/component";
import { UseFocusTrap } from '@vueuse/integrations/useFocusTrap/component';
const props = withDefaults(
defineProps<{
@@ -68,9 +68,7 @@ watch(open, (isOpen) => {
const filteredTags = computed(() => {
return sortedTags.value.filter((tag: Tag) => {
return tag.name
.toLowerCase()
.includes(searchValue.value?.toLowerCase()?.trim() || '');
return tag.name.toLowerCase().includes(searchValue.value?.toLowerCase()?.trim() || '');
});
});
@@ -101,9 +99,7 @@ function updateSearchValue(event: Event) {
searchValue.value = '';
const highlightedTagId = highlightedItemId.value;
if (highlightedTagId) {
const highlightedTag = props.tags.find(
(tag) => tag.id === highlightedTagId
);
const highlightedTag = props.tags.find((tag) => tag.id === highlightedTagId);
if (highlightedTag) {
addOrRemoveTagFromSelection(highlightedTag.id);
}
@@ -129,29 +125,22 @@ function toggleTag(newValue: string) {
function moveHighlightUp() {
if (highlightedItem.value) {
const currentHightlightedIndex = filteredTags.value.indexOf(
highlightedItem.value
);
const currentHightlightedIndex = filteredTags.value.indexOf(highlightedItem.value);
if (currentHightlightedIndex === 0) {
highlightedItemId.value =
filteredTags.value[filteredTags.value.length - 1].id;
highlightedItemId.value = filteredTags.value[filteredTags.value.length - 1].id;
} else {
highlightedItemId.value =
filteredTags.value[currentHightlightedIndex - 1].id;
highlightedItemId.value = filteredTags.value[currentHightlightedIndex - 1].id;
}
}
}
function moveHighlightDown() {
if (highlightedItem.value) {
const currentHightlightedIndex = filteredTags.value.indexOf(
highlightedItem.value
);
const currentHightlightedIndex = filteredTags.value.indexOf(highlightedItem.value);
if (currentHightlightedIndex === filteredTags.value.length - 1) {
highlightedItemId.value = filteredTags.value[0].id;
} else {
highlightedItemId.value =
filteredTags.value[currentHightlightedIndex + 1].id;
highlightedItemId.value = filteredTags.value[currentHightlightedIndex + 1].id;
}
}
}
@@ -177,9 +166,7 @@ const showCreateTagModal = ref(false);
<slot name="trigger"></slot>
</template>
<template #content>
<UseFocusTrap
v-if="open"
:options="{ immediate: true, allowOutsideClick: true }">
<UseFocusTrap v-if="open" :options="{ immediate: true, allowOutsideClick: true }">
<input
ref="searchInput"
:value="searchValue"
@@ -198,9 +185,8 @@ const showCreateTagModal = ref(false);
role="option"
:value="tag.id"
:class="{
'bg-card-background-active':
tag.id === highlightedItemId,
}"
'bg-card-background-active': tag.id === highlightedItemId,
}"
data-testid="tag_dropdown_entries"
:data-tag-id="tag.id">
<MultiselectDropdownItem
@@ -213,9 +199,9 @@ const showCreateTagModal = ref(false);
<button
class="text-text-primary w-full flex space-x-3 items-center px-4 py-3 text-xs font-semibold border-t border-card-background-separator"
@click="
open = false;
showCreateTagModal = true;
">
open = false;
showCreateTagModal = true;
">
<PlusCircleIcon
class="w-5 flex-shrink-0 text-icon-default"></PlusCircleIcon>
<span>Create new Tag</span>

View File

@@ -17,10 +17,7 @@ import TimeEntryMoreOptionsDropdown from '@/packages/ui/src/TimeEntry/TimeEntryM
import TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
import BillableToggleButton from '@/packages/ui/src/Input/BillableToggleButton.vue';
import { ref, inject, type ComputedRef } from 'vue';
import {
formatHumanReadableDuration,
formatStartEnd,
} from '@/packages/ui/src/utils/time';
import { formatHumanReadableDuration, formatStartEnd } from '@/packages/ui/src/utils/time';
import TimeEntryRow from '@/packages/ui/src/TimeEntry/TimeEntryRow.vue';
import GroupedItemsCountButton from '@/packages/ui/src/GroupedItemsCountButton.vue';
import type { TimeEntriesGroupedByType } from '@/types/time-entries';
@@ -95,31 +92,23 @@ function onSelectChange(checked: boolean) {
class="border-b border-default-background-separator bg-row-background min-w-0 transition"
data-testid="time_entry_row">
<MainContainer class="min-w-0">
<div
class="sm:flex py-1.5 items-center min-w-0 justify-between group">
<div class="sm:flex py-1.5 items-center min-w-0 justify-between group">
<div class="flex space-x-3 items-center min-w-0">
<Checkbox
:checked="
timeEntry.timeEntries.every(
(aggregateTimeEntry: TimeEntry) =>
selectedTimeEntries.includes(
aggregateTimeEntry
)
timeEntry.timeEntries.every((aggregateTimeEntry: TimeEntry) =>
selectedTimeEntries.includes(aggregateTimeEntry)
)
"
@update:checked="onSelectChange" />
<div class="flex items-center min-w-0">
<GroupedItemsCountButton
:expanded="expanded"
@click="expanded = !expanded">
<GroupedItemsCountButton :expanded="expanded" @click="expanded = !expanded">
{{ timeEntry?.timeEntries?.length }}
</GroupedItemsCountButton>
<TimeEntryDescriptionInput
class="min-w-0 mr-4"
:model-value="timeEntry.description"
@changed="
updateTimeEntryDescription
"></TimeEntryDescriptionInput>
@changed="updateTimeEntryDescription"></TimeEntryDescriptionInput>
<TimeTrackerProjectTaskDropdown
:clients
:create-project
@@ -132,9 +121,7 @@ function onSelectChange(checked: boolean) {
:enable-estimated-time
:currency="currency"
:task="timeEntry.task_id"
@changed="
updateProjectAndTask
"></TimeTrackerProjectTaskDropdown>
@changed="updateProjectAndTask"></TimeTrackerProjectTaskDropdown>
</div>
</div>
<div class="flex items-center font-medium lg:space-x-2">
@@ -142,21 +129,30 @@ 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"
size="small"
@changed="
updateTimeEntryBillable
"></BillableToggleButton>
@changed="updateTimeEntryBillable"></BillableToggleButton>
<div class="flex-1">
<button
:class="twMerge('text-text-secondary px-1 py-1.5 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-medium focus-visible:outline-none focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:bg-tertiary', organization?.time_format === '12-hours' ? 'w-[170px]' : 'w-[120px]')"
:class="
twMerge(
'text-text-secondary px-1 py-1.5 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-medium focus-visible:outline-none focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:bg-tertiary',
organization?.time_format === '12-hours'
? 'w-[170px]'
: 'w-[120px]'
)
"
@click="expanded = !expanded">
{{ formatStartEnd(timeEntry.start, timeEntry.end, organization?.time_format) }}
{{
formatStartEnd(
timeEntry.start,
timeEntry.end,
organization?.time_format
)
}}
</button>
</div>
<button
@@ -174,9 +170,7 @@ function onSelectChange(checked: boolean) {
<TimeTrackerStartStop
:active="!!(timeEntry.start && !timeEntry.end)"
class="opacity-20 flex group-hover:opacity-100 focus-visible:opacity-100"
@changed="
onStartStopClick(timeEntry)
"></TimeTrackerStartStop>
@changed="onStartStopClick(timeEntry)"></TimeTrackerStartStop>
<TimeEntryMoreOptionsDropdown
@delete="
deleteTimeEntries(timeEntry?.timeEntries ?? [])
@@ -196,8 +190,7 @@ function onSelectChange(checked: boolean) {
:tasks="tasks"
:selected="
!!selectedTimeEntries.find(
(filterEntry: TimeEntry) =>
filterEntry.id === subEntry.id
(filterEntry: TimeEntry) => filterEntry.id === subEntry.id
)
"
:create-client
@@ -205,9 +198,7 @@ function onSelectChange(checked: boolean) {
:create-project
:tags="tags"
indent
:update-time-entry="
(timeEntry: TimeEntry) => updateTimeEntry(timeEntry)
"
:update-time-entry="(timeEntry: TimeEntry) => updateTimeEntry(timeEntry)"
:on-start-stop-click="() => onStartStopClick(subEntry)"
:delete-time-entry="() => deleteTimeEntries([subEntry])"
:currency="currency"

View File

@@ -7,10 +7,7 @@ import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue';
import { TagIcon } from '@heroicons/vue/20/solid';
import {
getDayJsInstance,
getLocalizedDayJs,
} from '@/packages/ui/src/utils/time';
import { getDayJsInstance, getLocalizedDayJs } from '@/packages/ui/src/utils/time';
import type {
CreateClientBody,
CreateProjectBody,
@@ -29,16 +26,14 @@ import DurationHumanInput from '@/packages/ui/src/Input/DurationHumanInput.vue';
import { InformationCircleIcon } from '@heroicons/vue/20/solid';
import type { Tag, Task } from '@/packages/api/src';
import TimePickerSimple from "@/packages/ui/src/Input/TimePickerSimple.vue";
import TimePickerSimple from '@/packages/ui/src/Input/TimePickerSimple.vue';
const show = defineModel('show', { default: false });
const saving = ref(false);
const props = defineProps<{
enableEstimatedTime: boolean;
createTimeEntry: (
entry: Omit<CreateTimeEntryBody, 'member_id'>
) => Promise<void>;
createTimeEntry: (entry: Omit<CreateTimeEntryBody, 'member_id'>) => Promise<void>;
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
createTag: (name: string) => Promise<Tag | undefined>;
@@ -83,9 +78,7 @@ watch(
}
);
const localStart = ref(
getLocalizedDayJs(timeEntryDefaultValues.start).format()
);
const localStart = ref(getLocalizedDayJs(timeEntryDefaultValues.start).format());
const localEnd = ref(getLocalizedDayJs(timeEntryDefaultValues.end).format());
@@ -143,15 +136,12 @@ type BillableOption = {
@keydown.enter="submit" />
</div>
</div>
<div
class="sm:flex justify-between items-end space-y-2 sm:space-y-0 pt-4 sm:space-x-4">
<div class="sm:flex justify-between items-end space-y-2 sm:space-y-0 pt-4 sm:space-x-4">
<div class="flex w-full items-center space-x-2 justify-between">
<div class="flex-1 min-w-0">
<TimeTrackerProjectTaskDropdown
v-model:project="timeEntry.project_id"
v-model:task="
timeEntry.task_id
"
v-model:task="timeEntry.task_id"
:clients
:create-project
:create-client
@@ -161,19 +151,15 @@ type BillableOption = {
class="bg-input-background"
:projects="projects"
:tasks="tasks"
:enable-estimated-time="enableEstimatedTime"></TimeTrackerProjectTaskDropdown>
:enable-estimated-time="
enableEstimatedTime
"></TimeTrackerProjectTaskDropdown>
</div>
<div class="flex items-center space-x-2">
<div class="flex-col">
<TagDropdown
v-model="timeEntry.tags"
:create-tag
:tags="tags">
<TagDropdown v-model="timeEntry.tags" :create-tag :tags="tags">
<template #trigger>
<Badge
class="bg-input-background"
tag="button"
size="xlarge">
<Badge class="bg-input-background" tag="button" size="xlarge">
<TagIcon
v-if="timeEntry.tags.length === 0"
class="w-4"></TagIcon>
@@ -190,12 +176,8 @@ type BillableOption = {
<div class="flex-col">
<SelectDropdown
v-model="billableProxy"
:get-key-from-item="
(item: BillableOption) => item.value
"
:get-name-for-item="
(item: BillableOption) => item.label
"
:get-key-from-item="(item: BillableOption) => item.value"
:get-name-for-item="(item: BillableOption) => item.label"
:items="[
{
label: 'Billable',
@@ -207,16 +189,10 @@ type BillableOption = {
},
]">
<template #trigger>
<Badge
class="bg-input-background"
tag="button"
size="xlarge">
<BillableIcon
class="h-4"></BillableIcon>
<Badge class="bg-input-background" tag="button" size="xlarge">
<BillableIcon class="h-4"></BillableIcon>
<span>{{
timeEntry.billable
? 'Billable'
: 'Non-Billable'
timeEntry.billable ? 'Billable' : 'Non-Billable'
}}</span>
</Badge>
</template>
@@ -246,10 +222,7 @@ type BillableOption = {
<div class="">
<InputLabel>Start</InputLabel>
<div class="flex flex-col items-center space-y-2 mt-1">
<TimePickerSimple
v-model="localStart"
size="large"></TimePickerSimple>
<TimePickerSimple v-model="localStart" size="large"></TimePickerSimple>
<DatePicker
v-model="localStart"
tabindex="1"
@@ -259,9 +232,7 @@ type BillableOption = {
<div class="">
<InputLabel>End</InputLabel>
<div class="flex flex-col items-center space-y-2 mt-1">
<TimePickerSimple
v-model="localEnd"
size="large"></TimePickerSimple>
<TimePickerSimple v-model="localEnd" size="large"></TimePickerSimple>
<DatePicker
v-model="localEnd"
tabindex="1"

View File

@@ -31,8 +31,7 @@ const displaysPlaceholder = computed(() => {
</script>
<template>
<div
class="relative min-w-0 flex-1 text-ellipsis whitespace-nowrap overflow-hidden">
<div class="relative min-w-0 flex-1 text-ellipsis whitespace-nowrap overflow-hidden">
<div class="relative text-sm font-medium min-w-0">
<div
:class="[

View File

@@ -10,10 +10,7 @@ import type {
TimeEntry,
Client,
} from '@/packages/api/src';
import {
getDayJsInstance,
getLocalizedDateFromTimestamp,
} from '@/packages/ui/src/utils/time';
import { getDayJsInstance, getLocalizedDateFromTimestamp } from '@/packages/ui/src/utils/time';
import TimeEntryAggregateRow from '@/packages/ui/src/TimeEntry/TimeEntryAggregateRow.vue';
import TimeEntryRowHeading from '@/packages/ui/src/TimeEntry/TimeEntryRowHeading.vue';
import TimeEntryRow from '@/packages/ui/src/TimeEntry/TimeEntryRow.vue';
@@ -48,17 +45,13 @@ const groupedTimeEntries = computed(() => {
if (entry.end === null) {
continue;
}
const oldEntries =
groupedEntriesByDay[getLocalizedDateFromTimestamp(entry.start)];
const oldEntries = groupedEntriesByDay[getLocalizedDateFromTimestamp(entry.start)];
groupedEntriesByDay[getLocalizedDateFromTimestamp(entry.start)] = [
...(oldEntries ?? []),
entry,
];
}
const groupedEntriesByDayAndType: Record<
string,
TimeEntriesGroupedByType[]
> = {};
const groupedEntriesByDayAndType: Record<string, TimeEntriesGroupedByType[]> = {};
for (const dailyEntriesKey in groupedEntriesByDay) {
const dailyEntries = groupedEntriesByDay[dailyEntriesKey];
const newDailyEntries: TimeEntriesGroupedByType[] = [];
@@ -77,15 +70,12 @@ const groupedTimeEntries = computed(() => {
// Add up durations for time entries of the same type
newDailyEntries[oldEntriesIndex].duration =
(newDailyEntries[oldEntriesIndex].duration ?? 0) +
(entry?.duration ?? 0);
(newDailyEntries[oldEntriesIndex].duration ?? 0) + (entry?.duration ?? 0);
// adapt start end times so they show the earliest start and latest end time
if (
getDayJsInstance()(entry.start).isBefore(
getDayJsInstance()(
newDailyEntries[oldEntriesIndex].start
)
getDayJsInstance()(newDailyEntries[oldEntriesIndex].start)
)
) {
newDailyEntries[oldEntriesIndex].start = entry.start;
@@ -133,17 +123,15 @@ function selectAllTimeEntries(value: TimeEntriesGroupedByType[]) {
}
}
function unselectAllTimeEntries(value: TimeEntriesGroupedByType[]) {
selectedTimeEntries.value = selectedTimeEntries.value.filter(
(timeEntry) => {
return !value.find(
(filterTimeEntry) =>
filterTimeEntry.id === timeEntry.id ||
filterTimeEntry.timeEntries?.find(
(subTimeEntry) => subTimeEntry.id === timeEntry.id
)
);
}
);
selectedTimeEntries.value = selectedTimeEntries.value.filter((timeEntry) => {
return !value.find(
(filterTimeEntry) =>
filterTimeEntry.id === timeEntry.id ||
filterTimeEntry.timeEntries?.find(
(subTimeEntry) => subTimeEntry.id === timeEntry.id
)
);
});
}
</script>
@@ -153,9 +141,7 @@ function unselectAllTimeEntries(value: TimeEntriesGroupedByType[]) {
:date="key"
:duration="sumDuration(value)"
:checked="
value.every((timeEntry: TimeEntry) =>
selectedTimeEntries.includes(timeEntry)
)
value.every((timeEntry: TimeEntry) => selectedTimeEntries.includes(timeEntry))
"
@select-all="selectAllTimeEntries(value)"
@unselect-all="unselectAllTimeEntries(value)"></TimeEntryRowHeading>
@@ -180,10 +166,7 @@ function unselectAllTimeEntries(value: TimeEntriesGroupedByType[]) {
:time-entry="entry"
@selected="
(timeEntries: TimeEntry[]) => {
selectedTimeEntries = [
...selectedTimeEntries,
...timeEntries,
];
selectedTimeEntries = [...selectedTimeEntries, ...timeEntries];
}
"
@unselected="
@@ -191,8 +174,7 @@ function unselectAllTimeEntries(value: TimeEntriesGroupedByType[]) {
selectedTimeEntries = selectedTimeEntries.filter(
(item: TimeEntry) =>
!timeEntriesToUnselect.find(
(filterEntry: TimeEntry) =>
filterEntry.id === item.id
(filterEntry: TimeEntry) => filterEntry.id === item.id
)
);
}

View File

@@ -28,9 +28,7 @@ const props = defineProps<{
createTag: (name: string) => Promise<Tag | undefined>;
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
updateTimeEntries: (
changeset: UpdateMultipleTimeEntriesChangeset
) => Promise<void>;
updateTimeEntries: (changeset: UpdateMultipleTimeEntriesChangeset) => Promise<void>;
currency: string;
enableEstimatedTime: boolean;
canCreateProject: boolean;
@@ -71,9 +69,7 @@ const showMassUpdateModal = ref(false);
<Checkbox
id="selectAll"
:checked="allSelected"
@update:checked="
allSelected ? emit('unselectAll') : emit('selectAll')
">
@update:checked="allSelected ? emit('unselectAll') : emit('selectAll')">
</Checkbox>
<InputLabel
v-if="selectedTimeEntries.length > 0"
@@ -81,10 +77,7 @@ const showMassUpdateModal = ref(false);
class="select-none text-text-secondary">
{{ selectedTimeEntries.length }} selected
</InputLabel>
<InputLabel
v-else
for="selectAll"
class="text-text-secondary select-none"
<InputLabel v-else for="selectAll" class="text-text-secondary select-none"
>Select All</InputLabel
>
<button

View File

@@ -31,9 +31,7 @@ const props = defineProps<{
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
createTag: (name: string) => Promise<Tag | undefined>;
updateTimeEntries: (
changeset: UpdateMultipleTimeEntriesChangeset
) => Promise<void>;
updateTimeEntries: (changeset: UpdateMultipleTimeEntriesChangeset) => Promise<void>;
currency: string;
enableEstimatedTime: boolean;
canCreateProject: boolean;
@@ -172,15 +170,9 @@ type SelectOption = { label: string; value: string };
<div class="space-y-2">
<InputLabel for="project" value="Tag" />
<div class="flex space-x-5">
<TagDropdown
v-model="selectedTags"
:create-tag
:tags="tags">
<TagDropdown v-model="selectedTags" :create-tag :tags="tags">
<template #trigger>
<Badge
:disabled="removeAllTags"
tag="button"
size="xlarge">
<Badge :disabled="removeAllTags" tag="button" size="xlarge">
<span v-if="selectedTags.length > 0">
Set {{ selectedTags.length }} tags
</span>
@@ -189,9 +181,7 @@ type SelectOption = { label: string; value: string };
</template>
</TagDropdown>
<div class="flex items-center space-x-2">
<Checkbox
id="no_tags"
v-model:checked="removeAllTags"></Checkbox>
<Checkbox id="no_tags" v-model:checked="removeAllTags"></Checkbox>
<InputLabel for="no_tags" value="Remove all tags" />
</div>
</div>
@@ -201,12 +191,8 @@ type SelectOption = { label: string; value: string };
<div class="flex">
<SelectDropdown
v-model="timeEntryBillable"
:get-key-from-item="
(item: SelectOption) => item.value
"
:get-name-for-item="
(item: SelectOption) => item.label
"
:get-key-from-item="(item: SelectOption) => item.value"
:get-name-for-item="(item: SelectOption) => item.label"
:items="[
{
label: 'Keep current billable status',
@@ -223,12 +209,8 @@ type SelectOption = { label: string; value: string };
]">
<template #trigger>
<Badge tag="button" size="xlarge">
<span v-if="billable === undefined">
Set billable status
</span>
<span v-else-if="billable === true">
Billable
</span>
<span v-if="billable === undefined"> Set billable status </span>
<span v-else-if="billable === true"> Billable </span>
<span v-else> Non Billable </span></Badge
>
</template>

View File

@@ -1,10 +1,7 @@
<script setup lang="ts">
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
import { defineProps, ref, inject, type ComputedRef } from 'vue';
import {
formatDateLocalized,
formatStartEnd,
} from '@/packages/ui/src/utils/time';
import { formatDateLocalized, formatStartEnd } from '@/packages/ui/src/utils/time';
import TimeRangeSelector from '@/packages/ui/src/Input/TimeRangeSelector.vue';
import { twMerge } from 'tailwind-merge';
import type { Organization } from '@/packages/api/src';
@@ -23,7 +20,6 @@ const open = ref(false);
const triggerElement = ref<HTMLButtonElement | null>(null);
const organization = inject<ComputedRef<Organization>>('organization');
</script>
<template>
@@ -59,8 +55,7 @@ const organization = inject<ComputedRef<Organization>>('organization');
:start="start"
:end="end"
@changed="
(newStart: string, newEnd: string) =>
emit('changed', newStart, newEnd)
(newStart: string, newEnd: string) => emit('changed', newStart, newEnd)
"
@close="open = false">
</TimeRangeSelector>

View File

@@ -72,9 +72,7 @@ function updateProjectAndTask(projectId: string, taskId: string) {
const memberName = computed(() => {
if (props.members) {
const member = props.members.find(
(member) => member.user_id === props.timeEntry.user_id
);
const member = props.members.find((member) => member.user_id === props.timeEntry.user_id);
if (member) {
return member.name;
}
@@ -82,7 +80,7 @@ const memberName = computed(() => {
return '';
});
function onSelectChange(checked : boolean) {
function onSelectChange(checked: boolean) {
if (checked) {
emit('selected');
} else {
@@ -96,18 +94,13 @@ function onSelectChange(checked : boolean) {
class="border-b border-default-background-separator transition min-w-0 bg-row-background"
data-testid="time_entry_row">
<MainContainer class="min-w-0">
<div
class="sm:flex py-2 min-w-0 items-center justify-between group">
<div class="sm:flex py-2 min-w-0 items-center justify-between group">
<div class="flex items-center min-w-0">
<Checkbox
:checked="selected"
@update:checked="onSelectChange" />
<Checkbox :checked="selected" @update:checked="onSelectChange" />
<div v-if="indent === true" class="w-10 h-7"></div>
<TimeEntryDescriptionInput
class="min-w-0 mr-4"
:model-value="
timeEntry.description
"
:model-value="timeEntry.description"
@changed="updateTimeEntryDescription"></TimeEntryDescriptionInput>
<TimeTrackerProjectTaskDropdown
:create-project
@@ -120,13 +113,10 @@ function onSelectChange(checked : boolean) {
:project="timeEntry.project_id"
:currency="currency"
:enable-estimated-time
:task="
timeEntry.task_id
"
:task="timeEntry.task_id"
@changed="updateProjectAndTask"></TimeTrackerProjectTaskDropdown>
</div>
<div
class="flex items-center font-medium space-x-1 lg:space-x-2">
<div class="flex items-center font-medium space-x-1 lg:space-x-2">
<div v-if="showMember && members" class="text-sm px-2">
{{ memberName }}
</div>
@@ -137,34 +127,28 @@ function onSelectChange(checked : boolean) {
@changed="updateTimeEntryTags"></TimeEntryRowTagDropdown>
<BillableToggleButton
:model-value="timeEntry.billable"
:class="twMerge('opacity-50 group-hover:opacity-100 focus-visible:opacity-100')"
:class="
twMerge('opacity-50 group-hover:opacity-100 focus-visible:opacity-100')
"
size="small"
@changed="
updateTimeEntryBillable
"></BillableToggleButton>
@changed="updateTimeEntryBillable"></BillableToggleButton>
<div class="flex-1">
<TimeEntryRangeSelector
:start="timeEntry.start"
:end="timeEntry.end"
:show-date
@changed="
updateStartEndTime
"></TimeEntryRangeSelector>
@changed="updateStartEndTime"></TimeEntryRangeSelector>
</div>
<TimeEntryRowDurationInput
:start="timeEntry.start"
:end="timeEntry.end"
@changed="
updateStartEndTime
"></TimeEntryRowDurationInput>
@changed="updateStartEndTime"></TimeEntryRowDurationInput>
<TimeTrackerStartStop
:active="!!(timeEntry.start && !timeEntry.end)"
class="opacity-20 flex focus-visible:opacity-100 group-hover:opacity-100"
@changed="onStartStopClick"></TimeTrackerStartStop>
<TimeEntryMoreOptionsDropdown
@delete="
deleteTimeEntry
"></TimeEntryMoreOptionsDropdown>
@delete="deleteTimeEntry"></TimeEntryMoreOptionsDropdown>
</div>
</div>
</MainContainer>

View File

@@ -28,13 +28,8 @@ const open = ref(false);
function updateTimerAndStartLiveTimerUpdate() {
const defaultUnit =
organizationSettings?.value?.intervalFormat === 'decimal'
? 'hours'
: 'minutes';
const { seconds } = parseTimeInput(
temporaryCustomTimerEntry.value,
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;

View File

@@ -36,9 +36,7 @@ const timeEntryTags = computed<Tag[]>(() => {
:border="false"
size="large"
class="border-0 sm:px-1.5 text-icon-default group-focus-within/dropdown:text-text-primary"
:name="
timeEntryTags.map((tag: Tag) => tag.name).join(', ')
"></TagBadge>
:name="timeEntryTags.map((tag: Tag) => tag.name).join(', ')"></TagBadge>
</button>
</template>
</TagDropdown>

View File

@@ -13,19 +13,19 @@ import type {
TimeEntry,
Client,
} from '@/packages/api/src';
import {computed, nextTick, ref, watch} from 'vue';
import type {Dayjs} from 'dayjs';
import {useTimeEntriesStore} from '@/utils/useTimeEntries';
import {storeToRefs} from 'pinia';
import {useFocus} from "@vueuse/core";
import {autoUpdate, flip, limitShift, offset, shift, useFloating} from "@floating-ui/vue";
import TimeTrackerRecentlyTrackedEntry from "@/packages/ui/src/TimeTracker/TimeTrackerRecentlyTrackedEntry.vue";
import {useSelectEvents} from "@/packages/ui/src/utils/select";
import { computed, nextTick, ref, watch } from 'vue';
import type { Dayjs } from 'dayjs';
import { useTimeEntriesStore } from '@/utils/useTimeEntries';
import { storeToRefs } from 'pinia';
import { useFocus } from '@vueuse/core';
import { autoUpdate, flip, limitShift, offset, shift, useFloating } from '@floating-ui/vue';
import TimeTrackerRecentlyTrackedEntry from '@/packages/ui/src/TimeTracker/TimeTrackerRecentlyTrackedEntry.vue';
import { useSelectEvents } from '@/packages/ui/src/utils/select';
const currentTimeEntry = defineModel<TimeEntry>('currentTimeEntry', {
required: true,
});
const liveTimer = defineModel<Dayjs | null>('liveTimer', {required: true});
const liveTimer = defineModel<Dayjs | null>('liveTimer', { required: true });
const currentTimeEntryDescriptionInput = ref<HTMLInputElement | null>(null);
@@ -60,13 +60,12 @@ function setAndStartTimer(timeEntry: TimeEntry) {
setCurrentTimeEntry(timeEntry);
if (!props.isActive) {
emit('startTimer');
}
else {
} else {
emit('updateTimeEntry');
}
}
function setCurrentTimeEntry(timeEntry: TimeEntry){
function setCurrentTimeEntry(timeEntry: TimeEntry) {
currentTimeEntry.value.description = timeEntry.description;
currentTimeEntry.value.project_id = timeEntry.project_id;
currentTimeEntry.value.task_id = timeEntry.task_id;
@@ -75,21 +74,21 @@ function setCurrentTimeEntry(timeEntry: TimeEntry){
}
function startTimerIfNotActive() {
if(highlightedDropdownEntryId.value){
const timeEntry = filteredRecentlyTrackedTimeEntries.value.find((item) => item.id === highlightedDropdownEntryId.value);
if(timeEntry){
if (highlightedDropdownEntryId.value) {
const timeEntry = filteredRecentlyTrackedTimeEntries.value.find(
(item) => item.id === highlightedDropdownEntryId.value
);
if (timeEntry) {
setCurrentTimeEntry(timeEntry);
showDropdown.value = false;
}
}
else {
} else {
currentTimeEntry.value.description = tempDescription.value;
}
if (!props.isActive) {
emit('startTimer');
}
else {
} else {
emit('updateTimeEntry');
}
}
@@ -108,7 +107,7 @@ const blockRefocus = ref(false);
function onToggleButtonPress(newState: boolean) {
if (newState) {
emit('startTimer');
if (!blockRefocus.value){
if (!blockRefocus.value) {
currentTimeEntryDescriptionInput.value?.focus();
}
} else {
@@ -131,41 +130,47 @@ function updateTimeEntryDescription() {
}
}
const {timeEntries} = storeToRefs(useTimeEntriesStore());
const { timeEntries } = storeToRefs(useTimeEntriesStore());
const filteredRecentlyTrackedTimeEntries = computed(() => {
// do not include running time entries
const finishedTimeEntries = timeEntries.value.filter((item) => item.end !== null);
// filter out duplicates based on description, task, project, tags and billable
const nonDuplicateTimeEntries = finishedTimeEntries.filter((item, index, self) => {
return index === self.findIndex((t) => (
t.description === item.description &&
t.task_id === item.task_id &&
t.project_id === item.project_id &&
t.tags.length === item.tags.length &&
t.tags.every((tag) => item.tags.includes(tag)) &&
t.billable === item.billable
));
return (
index ===
self.findIndex(
(t) =>
t.description === item.description &&
t.task_id === item.task_id &&
t.project_id === item.project_id &&
t.tags.length === item.tags.length &&
t.tags.every((tag) => item.tags.includes(tag)) &&
t.billable === item.billable
)
);
});
// filter time entries based on current description
return nonDuplicateTimeEntries.filter((item) => {
return item.description
?.toLowerCase()
?.includes(tempDescription.value?.toLowerCase()?.trim() || '');
}).slice(0, 5);
return nonDuplicateTimeEntries
.filter((item) => {
return item.description
?.toLowerCase()
?.includes(tempDescription.value?.toLowerCase()?.trim() || '');
})
.slice(0, 5);
});
const showDropdown = ref(false);
const {focused} = useFocus(currentTimeEntryDescriptionInput)
const { focused } = useFocus(currentTimeEntryDescriptionInput);
watch(focused, (focused) => {
nextTick(() => {
// make sure the click event on the dropdown does not get interrupted
showDropdown.value = focused
showDropdown.value = focused;
// make sure that the input does not get refocused after the dropdown is closed
if(!focused){
if (!focused) {
blockRefocus.value = true;
setTimeout(() => {
blockRefocus.value = false;
@@ -174,9 +179,8 @@ watch(focused, (focused) => {
});
});
const floating = ref(null);
const {floatingStyles} = useFloating(currentTimeEntryDescriptionInput, floating, {
const { floatingStyles } = useFloating(currentTimeEntryDescriptionInput, floating, {
placement: 'bottom-start',
whileElementsMounted: autoUpdate,
middleware: [
@@ -193,16 +197,16 @@ const {floatingStyles} = useFloating(currentTimeEntryDescriptionInput, floating,
});
const highlightedDropdownEntryId = ref<string | null>(null);
useSelectEvents(filteredRecentlyTrackedTimeEntries,
useSelectEvents(
filteredRecentlyTrackedTimeEntries,
highlightedDropdownEntryId,
(item) => item.id,
showDropdown)
showDropdown
);
</script>
<template>
<div
class="flex items-center relative @container"
data-testid="dashboard_timer">
<div class="flex items-center relative @container" data-testid="dashboard_timer">
<div
class="flex flex-col @2xl:flex-row w-full justify-between rounded-lg bg-card-background border-card-border border transition shadow-card">
<div class="flex flex-1 items-center pr-6 relative">
@@ -211,18 +215,18 @@ useSelectEvents(filteredRecentlyTrackedTimeEntries,
v-model="tempDescription"
placeholder="What are you working on?"
data-testid="time_entry_description"
class="w-full rounded-l-lg py-4 sm:py-2.5 px-3.5 border-b border-b-card-background-separator @2xl:px-4 text-base @4xl:text-lg text-text-primary bg-transparent border-none placeholder-text-secondary font-medium focus:ring-0 transition"
class="w-full rounded-l-lg py-4 sm:py-2.5 px-3.5 border-b border-b-card-background-separator @2xl:px-4 text-base @4xl:text-lg text-text-primary bg-transparent border-none placeholder-text-secondary font-medium focus:ring-0 transition"
type="text"
@keydown.enter="startTimerIfNotActive"
@keydown.esc="showDropdown = false"
@blur="updateTimeEntryDescription"/>
@blur="updateTimeEntryDescription" />
<div
v-if="showDropdown && filteredRecentlyTrackedTimeEntries.length > 0"
ref="floating"
class="z-50 w-full max-w-2xl"
:style="floatingStyles">
<div
class="rounded-lg w-full fixed min-w-xl top-0 left-0 border border-card-border overflow-none shadow-dropdown bg-card-background">
class="rounded-lg w-full fixed min-w-xl top-0 left-0 border border-card-border overflow-none shadow-dropdown bg-card-background">
<div
class="text-text-tertiary text-xs font-semibold border-b border-border-tertiary px-2 py-1.5">
Recently Tracked Time Entries
@@ -236,20 +240,18 @@ useSelectEvents(filteredRecentlyTrackedTimeEntries,
:projects="projects"
:tasks="tasks"
@mousedown="setAndStartTimer(timeEntry)"
@mouseenter="highlightedDropdownEntryId = timeEntry.id"
></TimeTrackerRecentlyTrackedEntry>
@mouseenter="
highlightedDropdownEntryId = timeEntry.id
"></TimeTrackerRecentlyTrackedEntry>
</div>
</div>
</div>
</div>
<div class="flex items-center justify-between pl-2 shrink min-w-0">
<div
class="flex items-center w-[130px] @2xl:w-auto shrink min-w-0">
<div class="flex items-center w-[130px] @2xl:w-auto shrink min-w-0">
<TimeTrackerProjectTaskDropdown
v-model:project="currentTimeEntry.project_id"
v-model:task="
currentTimeEntry.task_id
"
v-model:task="currentTimeEntry.task_id"
:create-client
:can-create-project
:clients
@@ -262,16 +264,12 @@ useSelectEvents(filteredRecentlyTrackedTimeEntries,
</div>
<div class="flex items-center @2xl:space-x-2 px-2 @2xl:px-4">
<TimeTrackerTagDropdown
v-model="
currentTimeEntry.tags
"
v-model="currentTimeEntry.tags"
:create-tag
:tags="tags"
@changed="$emit('updateTimeEntry')"></TimeTrackerTagDropdown>
<BillableToggleButton
v-model="
currentTimeEntry.billable
"
v-model="currentTimeEntry.billable"
@changed="$emit('updateTimeEntry')"></BillableToggleButton>
</div>
<div class="border-l border-card-border">
@@ -282,14 +280,11 @@ useSelectEvents(filteredRecentlyTrackedTimeEntries,
@stop-live-timer="emit('stopLiveTimer')"
@update-timer="emit('updateTimeEntry')"
@start-timer="emit('startTimer')"
@keydown.enter="
startTimerIfNotActive
"></TimeTrackerRangeSelector>
@keydown.enter="startTimerIfNotActive"></TimeTrackerRangeSelector>
</div>
</div>
</div>
<div
class="pl-4 @2xl:pl-6 pr-3 absolute sm:relative top-[6px] sm:top-0 right-0">
<div class="pl-4 @2xl:pl-6 pr-3 absolute sm:relative top-[6px] sm:top-0 right-0">
<TimeTrackerStartStop
:active="isActive"
size="large"

View File

@@ -12,12 +12,7 @@ import type {
} from '@/packages/api/src';
import ProjectBadge from '@/packages/ui/src/Project/ProjectBadge.vue';
import Badge from '@/packages/ui/src/Badge.vue';
import {
PlusIcon,
PlusCircleIcon,
MinusIcon,
XMarkIcon,
} from '@heroicons/vue/16/solid';
import { PlusIcon, PlusCircleIcon, MinusIcon, XMarkIcon } from '@heroicons/vue/16/solid';
import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';
import { twMerge } from 'tailwind-merge';
@@ -58,9 +53,7 @@ const props = withDefaults(
projects: Project[];
tasks: Task[];
clients: Client[];
createProject: (
project: CreateProjectBody
) => Promise<Project | undefined>;
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
currency: string;
emptyPlaceholder?: string;
@@ -95,9 +88,7 @@ function addProjectToFilterObject(
(client) => client.id === project.client_id
);
const client = props.clients.find(
(client) => client.id === project.client_id
);
const client = props.clients.find((client) => client.id === project.client_id);
if (projectClientIndex !== -1) {
// client already exists in filter array
@@ -216,20 +207,10 @@ watchEffect(() => {
(!filterProject.is_archived || project.value === filterProject.id)
) {
// search term matches project name
addProjectToFilterObject(
tempFilteredClients,
filterProject,
filteredTasks,
false
);
addProjectToFilterObject(tempFilteredClients, filterProject, filteredTasks, false);
} else if (filteredTasks.length > 0 && !filterProject.is_archived) {
// search term matches task name
addProjectToFilterObject(
tempFilteredClients,
filterProject,
filteredTasks,
true
);
addProjectToFilterObject(tempFilteredClients, filterProject, filteredTasks, true);
}
}
@@ -316,11 +297,8 @@ function moveHighlightUp() {
// check if it is a project id
if (currentHighlightedIndex === -1) {
// the ID is a task ID
const currentProjectWithTasks = filteredProjects.value.find(
(projectWithTasks) =>
projectWithTasks.tasks.some(
(task) => task.id === highlightedItemId.value
)
const currentProjectWithTasks = filteredProjects.value.find((projectWithTasks) =>
projectWithTasks.tasks.some((task) => task.id === highlightedItemId.value)
);
if (currentProjectWithTasks) {
const taskIndex = currentProjectWithTasks.tasks.findIndex(
@@ -334,34 +312,27 @@ function moveHighlightUp() {
highlightedItemId.value = currentProjectWithTasks.id;
return;
}
highlightedItemId.value =
currentProjectWithTasks.tasks[taskIndex - 1].id;
highlightedItemId.value = currentProjectWithTasks.tasks[taskIndex - 1].id;
}
}
if (currentHighlightedIndex === 0) {
// selected project is the first project in the list
// highlight the last project or the last task of the last project
const lastProject =
filteredProjects.value[filteredProjects.value.length - 1];
const lastProject = filteredProjects.value[filteredProjects.value.length - 1];
if (lastProject.tasks.length > 0 && lastProject.expanded) {
// highlight last task of last project
highlightedItemId.value =
lastProject.tasks[lastProject.tasks.length - 1].id;
highlightedItemId.value = lastProject.tasks[lastProject.tasks.length - 1].id;
} else {
highlightedItemId.value =
filteredProjects.value[filteredProjects.value.length - 1].id;
highlightedItemId.value = filteredProjects.value[filteredProjects.value.length - 1].id;
}
} else {
// selected item is a project that is not the first project in the list
const previousProject =
filteredProjects.value[currentHighlightedIndex - 1];
const previousProject = filteredProjects.value[currentHighlightedIndex - 1];
if (previousProject.tasks.length > 0 && previousProject.expanded) {
// highlight last task of previous project
highlightedItemId.value =
previousProject.tasks[previousProject.tasks.length - 1].id;
highlightedItemId.value = previousProject.tasks[previousProject.tasks.length - 1].id;
} else {
highlightedItemId.value =
filteredProjects.value[currentHighlightedIndex - 1].id;
highlightedItemId.value = filteredProjects.value[currentHighlightedIndex - 1].id;
}
}
}
@@ -375,11 +346,8 @@ function moveHighlightDown() {
// check if it is a project id
if (currentHighlightedIndex === -1) {
// the ID is a task ID
const currentProjectWithTasks = filteredProjects.value.find(
(projectWithTasks) =>
projectWithTasks.tasks.some(
(task) => task.id === highlightedItemId.value
)
const currentProjectWithTasks = filteredProjects.value.find((projectWithTasks) =>
projectWithTasks.tasks.some((task) => task.id === highlightedItemId.value)
);
if (currentProjectWithTasks) {
const taskIndex = currentProjectWithTasks.tasks.findIndex(
@@ -390,27 +358,22 @@ function moveHighlightDown() {
}
if (taskIndex === currentProjectWithTasks.tasks.length - 1) {
// highlight the next project if it was the last task in current project
const projectIndex = filteredProjects.value.indexOf(
currentProjectWithTasks
);
const projectIndex = filteredProjects.value.indexOf(currentProjectWithTasks);
if (projectIndex === filteredProjects.value.length - 1) {
// highlight the first project if it was the last project
highlightedItemId.value = filteredProjects.value[0].id;
} else {
highlightedItemId.value =
filteredProjects.value[projectIndex + 1].id;
highlightedItemId.value = filteredProjects.value[projectIndex + 1].id;
}
return;
}
highlightedItemId.value =
currentProjectWithTasks.tasks[taskIndex + 1].id;
highlightedItemId.value = currentProjectWithTasks.tasks[taskIndex + 1].id;
}
}
if (currentHighlightedIndex === filteredProjects.value.length - 1) {
// selected project is the last project in the list
// highlight the first project or the last project of the last project
const lastProject =
filteredProjects.value[filteredProjects.value.length - 1];
const lastProject = filteredProjects.value[filteredProjects.value.length - 1];
if (lastProject.tasks.length > 0 && lastProject.expanded) {
// highlight last task of last project
highlightedItemId.value = lastProject.tasks[0].id;
@@ -419,17 +382,12 @@ function moveHighlightDown() {
}
} else {
// selected item is a project that is not the last project in the list
const currentProjectWithTasks =
filteredProjects.value[currentHighlightedIndex];
if (
currentProjectWithTasks.tasks.length > 0 &&
currentProjectWithTasks.expanded
) {
const currentProjectWithTasks = filteredProjects.value[currentHighlightedIndex];
if (currentProjectWithTasks.tasks.length > 0 && currentProjectWithTasks.expanded) {
// highlight last task of previous project
highlightedItemId.value = currentProjectWithTasks.tasks[0].id;
} else {
highlightedItemId.value =
filteredProjects.value[currentHighlightedIndex + 1].id;
highlightedItemId.value = filteredProjects.value[currentHighlightedIndex + 1].id;
}
}
}
@@ -481,15 +439,11 @@ function collapseProject() {
}
const currentProject = computed(() => {
return props.projects.find(
(iteratingProject) => iteratingProject.id === project.value
);
return props.projects.find((iteratingProject) => iteratingProject.id === project.value);
});
const currentTask = computed(() => {
return props.tasks.find(
(iteratingTasks) => iteratingTasks.id === task.value
);
return props.tasks.find((iteratingTasks) => iteratingTasks.id === task.value);
});
const selectedProjectName = computed(() => {
@@ -518,8 +472,7 @@ function setHighlightItemId(newId: string) {
function selectTask(taskId: string) {
task.value = taskId;
project.value =
props.tasks.find((task) => task.id === taskId)?.project_id || null;
project.value = props.tasks.find((task) => task.id === taskId)?.project_id || null;
open.value = false;
searchValue.value = '';
emit('changed', project.value, task.value);
@@ -569,9 +522,7 @@ const showCreateProject = ref(false);
<ChevronRightIcon
v-if="currentTask"
class="w-4 lg:w-5 text-text-secondary shrink-0"></ChevronRightIcon>
<div
v-if="currentTask"
class="min-w-0 shrink text-xs lg:text-sm truncate">
<div v-if="currentTask" class="min-w-0 shrink text-xs lg:text-sm truncate">
{{ currentTask.name }}
</div>
</div>
@@ -587,9 +538,7 @@ const showCreateProject = ref(false);
</ProjectBadge>
</template>
<template #content>
<UseFocusTrap
v-if="open"
:options="{ immediate: true, allowOutsideClick: true }">
<UseFocusTrap v-if="open" :options="{ immediate: true, allowOutsideClick: true }">
<input
ref="searchInput"
:value="searchValue"
@@ -607,9 +556,7 @@ const showCreateProject = ref(false);
ref="dropdownViewport"
class="min-w-[350px] max-h-[350px] overflow-y-scroll relative"
@mousemove="mouseEnterHighlightActivated = true">
<template
v-for="client in filteredResults"
:key="client.id">
<template v-for="client in filteredResults" :key="client.id">
<div
v-if="client.id !== 'no_project_no_client'"
class="w-full pb-1 pt-2 px-2 text-text-tertiary text-xs font-semibold flex space-x-1 items-center">
@@ -630,27 +577,17 @@ const showCreateProject = ref(false);
class="rounded-lg"
:class="{
'bg-card-background-active':
projectWithTasks.id ===
highlightedItemId,
projectWithTasks.id === highlightedItemId,
}">
<ProjectDropdownItem
class="hover:!bg-transparent"
:selected="
isProjectSelected(projectWithTasks)
"
:selected="isProjectSelected(projectWithTasks)"
:name="projectWithTasks.name"
:color="projectWithTasks.color"
@mouseenter="
setHighlightItemId(
projectWithTasks.id
)
">
@mouseenter="setHighlightItemId(projectWithTasks.id)">
<template #actions>
<button
v-if="
projectWithTasks.tasks
.length > 0
"
v-if="projectWithTasks.tasks.length > 0"
tabindex="-1"
class="px-2 py-0.5 mr-2 relative transition items-center rounded flex space-x-0.5 text-xs"
:class="{
@@ -667,11 +604,7 @@ const showCreateProject = ref(false);
}
">
<span
>{{
projectWithTasks.tasks
.length
}}
Tasks</span
>{{ projectWithTasks.tasks.length }} Tasks</span
>
<ChevronDownIcon
:class="{
@@ -684,31 +617,25 @@ const showCreateProject = ref(false);
</ProjectDropdownItem>
</div>
</div>
<div
v-if="projectWithTasks.expanded"
class="bg-quaternary">
<div v-if="projectWithTasks.expanded" class="bg-quaternary">
<div
v-for="task in projectWithTasks.tasks"
:key="task.id"
:data-task-id="task.id"
:class="{
'bg-card-background-active':
task.id === highlightedItemId,
'bg-card-background-active': task.id === highlightedItemId,
}"
class="flex items-center space-x-2 w-full px-5 py-1.5 text-start text-xs font-semibold leading-5 text-text-primary focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
@click="selectTask(task.id)"
@mouseenter="setHighlightItemId(task.id)">
<MinusIcon
class="w-3 h-3 text-text-quaternary"></MinusIcon>
<MinusIcon class="w-3 h-3 text-text-quaternary"></MinusIcon>
<span>{{ task.name }}</span>
</div>
</div>
</template>
</template>
</div>
<div
v-if="canCreateProject"
class="hover:bg-card-background-active rounded-b-lg">
<div v-if="canCreateProject" class="hover:bg-card-background-active rounded-b-lg">
<button
class="text-text-primary flex space-x-3 items-center px-4 py-3 text-xs font-semibold border-t border-card-background-separator"
@click="

View File

@@ -3,11 +3,7 @@ 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 {
formatDuration,
getDayJsInstance,
parseTimeInput,
} 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', {
@@ -59,10 +55,7 @@ const currentTime = computed({
});
function updateTimerAndStartLiveTimerUpdate() {
const { seconds } = parseTimeInput(
temporaryCustomTimerEntry.value,
'minutes'
);
const { seconds } = parseTimeInput(temporaryCustomTimerEntry.value, 'minutes');
if (seconds && seconds > 0) {
const newStartDate = dayjs().subtract(seconds, 's');
@@ -105,12 +98,7 @@ const timeRangeSelector = ref<HTMLElement | null>(null);
function openModalOnTab(e: FocusEvent) {
// check if the source is inside the dropdown
const source = e.relatedTarget as HTMLElement;
if (
source &&
window.document.body
.querySelector<HTMLElement>('#app')
?.contains(source)
) {
if (source && window.document.body.querySelector<HTMLElement>('#app')?.contains(source)) {
open.value = true;
}
}
@@ -118,10 +106,9 @@ function openModalOnTab(e: FocusEvent) {
function focusNextElement(e: KeyboardEvent) {
if (open.value) {
e.preventDefault();
const focusableElement =
timeRangeSelector.value?.querySelector<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const focusableElement = timeRangeSelector.value?.querySelector<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
focusableElement?.focus();
}
}

View File

@@ -1,27 +1,24 @@
<script setup lang="ts">
import {ProjectBadge} from "@/packages/ui/src";
import type {TimeEntry} from "@/packages/api/src";
import {twMerge} from "tailwind-merge";
import {ChevronRightIcon} from "@heroicons/vue/16/solid";
import {computed} from "vue";
import type {Project, Task} from "@/packages/api/src";
import { ProjectBadge } from '@/packages/ui/src';
import type { TimeEntry } from '@/packages/api/src';
import { twMerge } from 'tailwind-merge';
import { ChevronRightIcon } from '@heroicons/vue/16/solid';
import { computed } from 'vue';
import type { Project, Task } from '@/packages/api/src';
const props = defineProps<{
timeEntry: TimeEntry,
highlighted: boolean,
projects?: Project[]
tasks?: Task[]
}>()
timeEntry: TimeEntry;
highlighted: boolean;
projects?: Project[];
tasks?: Task[];
}>();
const project = computed(() => {
return props.projects?.find(
(iteratingProject) => iteratingProject.id === props.timeEntry.project_id
);
});
const task = computed(() => {
return props.tasks?.find(
(iteratingTask) => iteratingTask.id === props.timeEntry.task_id
);
return props.tasks?.find((iteratingTask) => iteratingTask.id === props.timeEntry.task_id);
});
</script>
@@ -29,40 +26,35 @@ const task = computed(() => {
<button
tabindex="-1"
:data-select-id="timeEntry.id"
:class="twMerge('px-2 py-1.5 flex justify-between items-center space-x-2 w-full rounded', props.highlighted && 'bg-card-background-active')">
:class="
twMerge(
'px-2 py-1.5 flex justify-between items-center space-x-2 w-full rounded',
props.highlighted && 'bg-card-background-active'
)
">
<span v-if="timeEntry.description !== ''" class="text-sm font-medium">
{{
timeEntry.description
}}
</span>
<span v-else class="text-sm text-text-tertiary font-medium">
No Description
{{ timeEntry.description }}
</span>
<span v-else class="text-sm text-text-tertiary font-medium"> No Description </span>
<ProjectBadge
ref="projectDropdownTrigger"
:color="project?.color"
:name="project?.name"
class="">
<div v-if="project" class="flex items-center lg:space-x-1 min-w-0">
<span class="whitespace-nowrap text-xs ">
{{ project?.name }}
</span>
<span class="whitespace-nowrap text-xs">
{{ project?.name }}
</span>
<ChevronRightIcon
v-if="task"
class="w-4 lg:w-5 text-text-secondary shrink-0"></ChevronRightIcon>
<div
v-if="task"
class="min-w-0 shrink text-xs truncate">
<div v-if="task" class="min-w-0 shrink text-xs truncate">
{{ task.name }}
</div>
</div>
<div v-else>
No Project
</div>
<div v-else>No Project</div>
</ProjectBadge>
</button>
</template>
<style scoped>
</style>
<style scoped></style>

View File

@@ -7,8 +7,7 @@ defineEmits<{
</script>
<template>
<div
class="absolute w-full h-full backdrop-blur-sm z-10 flex items-center justify-center">
<div class="absolute w-full h-full backdrop-blur-sm z-10 flex items-center justify-center">
<div
class="w-full h-[calc(100%+10px)] absolute bg-default-background opacity-75 backdrop-blur-sm"></div>
<div class="flex space-x-3 items-center w-full z-20 justify-center">

View File

@@ -26,11 +26,7 @@ defineProps<{
</script>
<template>
<TagDropdown
v-model="model"
:create-tag
:tags="tags"
@changed="emit('changed')">
<TagDropdown v-model="model" :create-tag :tags="tags" @changed="emit('changed')">
<template #trigger>
<button
data-testid="tag_dropdown"

View File

@@ -40,13 +40,7 @@ export function formatCents(
currencySymbol?: string,
numberFormat?: NumberFormat
) {
return formatMoney(
amount / 100,
currency,
format,
currencySymbol,
numberFormat
);
return formatMoney(amount / 100, currency, format, currencySymbol, numberFormat);
}
export function getOrganizationCurrencySymbol(currency: string) {

View File

@@ -28,8 +28,7 @@ export default class Prando {
} else {
// Pseudo-random seed
this._seed = this.getSafeSeed(
Prando.MIN +
Math.floor((Prando.MAX - Prando.MIN) * Math.random())
Prando.MIN + Math.floor((Prando.MAX - Prando.MIN) * Math.random())
);
}
this.reset();
@@ -59,9 +58,7 @@ export default class Prando {
*/
public nextInt(min = 10, max = 100): number {
this.recalculate();
return Math.floor(
this.map(this._value, Prando.MIN, Prando.MAX, min, max + 1)
);
return Math.floor(this.map(this._value, Prando.MIN, Prando.MAX, min, max + 1));
}
/**
@@ -176,9 +173,7 @@ export default class Prando {
minTo: number,
maxTo: number
): number {
return (
((val - minFrom) / (maxFrom - minFrom)) * (maxTo - minTo) + minTo
);
return ((val - minFrom) / (maxFrom - minFrom)) * (maxTo - minTo) + minTo;
}
private hashCode(str: string): number {

View File

@@ -1,16 +1,15 @@
import {computed, type Ref, watch} from "vue";
import {onKeyStroke} from "@vueuse/core";
export function useSelectEvents<Type>(filteredItems: Ref<Array<Type>>,
highlightedItemId: Ref<string | null>,
getKeyFromItem: (item: Type) => string,
open: Ref<boolean>) {
import { computed, type Ref, watch } from 'vue';
import { onKeyStroke } from '@vueuse/core';
export function useSelectEvents<Type>(
filteredItems: Ref<Array<Type>>,
highlightedItemId: Ref<string | null>,
getKeyFromItem: (item: Type) => string,
open: Ref<boolean>
) {
function moveHighlightUp() {
if (highlightedItem.value) {
const currentHightlightedIndex = filteredItems.value.indexOf(
highlightedItem.value
);
const currentHightlightedIndex = filteredItems.value.indexOf(highlightedItem.value);
if (currentHightlightedIndex === 0) {
highlightedItemId.value = getKeyFromItem(
filteredItems.value[filteredItems.value.length - 1]
@@ -20,36 +19,30 @@ export function useSelectEvents<Type>(filteredItems: Ref<Array<Type>>,
filteredItems.value[currentHightlightedIndex - 1]
);
}
}
else {
highlightedItemId.value = getKeyFromItem(filteredItems.value[filteredItems.value.length - 1]);
} else {
highlightedItemId.value = getKeyFromItem(
filteredItems.value[filteredItems.value.length - 1]
);
}
}
function moveHighlightDown() {
if (highlightedItem.value) {
const currentHightlightedIndex = filteredItems.value.indexOf(
highlightedItem.value
);
const currentHightlightedIndex = filteredItems.value.indexOf(highlightedItem.value);
if (currentHightlightedIndex === filteredItems.value.length - 1) {
highlightedItemId.value = getKeyFromItem(
filteredItems.value[0]
);
highlightedItemId.value = getKeyFromItem(filteredItems.value[0]);
} else {
highlightedItemId.value = getKeyFromItem(
filteredItems.value[currentHightlightedIndex + 1]
);
}
}
else{
} else {
highlightedItemId.value = getKeyFromItem(filteredItems.value[0]);
}
}
const highlightedItem = computed(() => {
return filteredItems.value.find(
(item) => getKeyFromItem(item) === highlightedItemId.value
);
return filteredItems.value.find((item) => getKeyFromItem(item) === highlightedItemId.value);
});
onKeyStroke('ArrowDown', (e) => {
@@ -72,4 +65,3 @@ export function useSelectEvents<Type>(filteredItems: Ref<Array<Type>>,
}
});
}

View File

@@ -13,7 +13,6 @@ import updateLocale from 'dayjs/plugin/updateLocale';
import { computed } from 'vue';
import { formatNumber } from './number';
export type DateFormat =
| 'point-separated-d-m-yyyy'
| 'slash-separated-mm-dd-yyyy'
@@ -28,7 +27,7 @@ const dateFormatMap: Record<DateFormat, string> = {
'slash-separated-dd-mm-yyyy': 'DD/MM/YYYY',
'hyphen-separated-dd-mm-yyyy': 'DD-MM-YYYY',
'hyphen-separated-mm-dd-yyyy': 'MM-DD-YYYY',
'hyphen-separated-yyyy-mm-dd': 'YYYY-MM-DD'
'hyphen-separated-yyyy-mm-dd': 'YYYY-MM-DD',
};
export type TimeFormat = '12-hours' | '24-hours';
@@ -131,9 +130,7 @@ export function getLocalizedDateFromTimestamp(timestamp: string) {
*/
export function formatDate(date: string, format: DateFormat = 'point-separated-d-m-yyyy'): string {
if (date?.includes('+')) {
console.warn(
'Date contains timezone information, use formatDateLocalized instead'
);
console.warn('Date contains timezone information, use formatDateLocalized instead');
}
return getDayJsInstance()(date).format(dateFormatMap[format]);
}
@@ -142,11 +139,18 @@ export function formatDate(date: string, format: DateFormat = 'point-separated-d
* Returns a formatted date.
* @param date - date in the format of 'YYYY-MM-DD'
*/
export function formatDateLocalized(date: string, format: DateFormat = 'point-separated-d-m-yyyy'): string {
export function formatDateLocalized(
date: string,
format: DateFormat = 'point-separated-d-m-yyyy'
): string {
return getLocalizedDayJs(date).format(dateFormatMap[format]);
}
export function formatDateTimeLocalized(date: string, dateFormat?: DateFormat, timeFormat?: TimeFormat): string {
export function formatDateTimeLocalized(
date: string,
dateFormat?: DateFormat,
timeFormat?: TimeFormat
): string {
const format = `${dateFormatMap[dateFormat ?? 'point-separated-d-m-yyyy']} ${timeFormat === '12-hours' ? 'hh:mm A' : 'HH:mm'}`;
return getLocalizedDayJs(date).format(format);
}
@@ -162,16 +166,16 @@ export function formatWeek(date: string | null): string {
export function formatHumanReadableDate(date: string) {
const dateObj = dayjs(date);
const today = dayjs();
if (dateObj.isToday()) {
return 'Today';
} else if (dateObj.isYesterday()) {
return 'Yesterday';
}
// Calculate difference in days
const diffInDays = today.diff(dateObj, 'day');
if (diffInDays > 0 && diffInDays <= 30) {
// For dates in the past (2-30 days ago)
return `${diffInDays} ${diffInDays === 1 ? 'day' : 'days'} ago`;
@@ -180,7 +184,7 @@ export function formatHumanReadableDate(date: string) {
const futureDays = Math.abs(diffInDays);
return `In ${futureDays} ${futureDays === 1 ? 'day' : 'days'}`;
}
// For dates older than 30 days, show the actual date
return dateObj.format('MMM D, YYYY');
}
@@ -189,7 +193,11 @@ export function formatWeekday(date: string) {
return dayjs(date).format('dddd');
}
export function formatStartEnd(start: string, end: string | null, timeFormat: TimeFormat = '24-hours') {
export function formatStartEnd(
start: string,
end: string | null,
timeFormat: TimeFormat = '24-hours'
) {
if (end) {
return `${formatTime(start, timeFormat)} - ${formatTime(end, timeFormat)}`;
} else {