feat(frontend) : add date filter component to time-tracking page

Reusable DateFilter component using @vuepic/vue-datepicker with day/week
toggle. Selecting a day switches to day view, selecting a week navigates
the calendar to that week. Includes "Aujourd'hui" and "Cette semaine"
quick shortcuts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-15 21:46:48 +01:00
parent 7047f64a6b
commit f7a76c9e9b
3 changed files with 117 additions and 70 deletions

View File

@@ -1,41 +1,57 @@
<template> <template>
<div class="date-filter"> <div class="date-filter">
<VueDatePicker <VueDatePicker
ref="datepicker"
v-model="internalValue" v-model="internalValue"
range :week-picker="mode === 'week'"
:enable-time-picker="false" :enable-time-picker="false"
:locale="frLocale" :locale="frLocale"
:format="formatDate" :format="formatDisplay"
auto-apply auto-apply
:multi-calendars="false" :multi-calendars="false"
position="left" position="left"
teleport
@update:model-value="onUpdate" @update:model-value="onUpdate"
> >
<template #dp-input="{ value, onInput, onEnter, onTab, onClear, openMenu }"> <template #trigger>
<div class="relative"> <div class="flex items-center gap-1">
<input <div class="flex shrink-0 overflow-hidden rounded-md border border-neutral-300">
:value="value" <button
class="w-full cursor-pointer rounded-md border border-neutral-300 bg-white px-3 py-[7px] text-sm text-neutral-700 outline-none transition placeholder:text-neutral-400 focus:border-primary-500" class="px-2 py-[7px] text-xs font-medium transition"
:placeholder="placeholder || t('common.dateFilter')" :class="mode === 'day' ? 'bg-primary-500 text-white' : 'text-neutral-500 hover:bg-neutral-100'"
readonly @click.stop="switchMode('day')"
@click="openMenu" >
@input="onInput" {{ t('common.day') }}
@keydown.enter="onEnter" </button>
@keydown.tab="onTab" <button
/> class="px-2 py-[7px] text-xs font-medium transition"
<button :class="mode === 'week' ? 'bg-primary-500 text-white' : 'text-neutral-500 hover:bg-neutral-100'"
v-if="value" @click.stop="switchMode('week')"
class="absolute right-2 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-neutral-600" >
@click.stop="onClear" {{ t('common.weekShort') }}
> </button>
<Icon name="mdi:close-circle" size="16" /> </div>
</button> <div class="relative cursor-pointer">
<Icon <input
v-else :value="displayValue"
name="mdi:calendar" class="w-full cursor-pointer rounded-md border border-neutral-300 bg-white px-3 py-[7px] pr-8 text-sm text-neutral-700 outline-none transition placeholder:text-neutral-400 focus:border-primary-500"
size="16" :placeholder="t('common.dateFilter')"
class="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-neutral-400" readonly
/> />
<button
v-if="internalValue"
class="absolute right-2 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-neutral-600"
@click.stop="onClear"
>
<Icon name="mdi:close-circle" size="16" />
</button>
<Icon
v-else
name="mdi:calendar"
size="16"
class="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-neutral-400"
/>
</div>
</div> </div>
</template> </template>
@@ -75,48 +91,87 @@ const emit = defineEmits<{
'update:modelValue': [value: Date | [Date, Date] | null] 'update:modelValue': [value: Date | [Date, Date] | null]
}>() }>()
const internalValue = ref<Date[] | null>(null) const datepicker = ref<InstanceType<typeof VueDatePicker> | null>(null)
const mode = ref<'day' | 'week'>('week')
const internalValue = ref<Date | Date[] | null>(null)
function formatDate(dates: Date[]): string { const displayValue = computed(() => {
if (!dates || dates.length === 0) return '' if (!internalValue.value) return ''
if (dates.length === 1) return formatSingleDate(dates[0]) if (internalValue.value instanceof Date) {
if (isSameDay(dates[0], dates[1])) return formatSingleDate(dates[0]) return formatFullDate(internalValue.value)
return `${formatSingleDate(dates[0])} - ${formatSingleDate(dates[1])}` }
if (Array.isArray(internalValue.value) && internalValue.value.length >= 2) {
const [start, end] = internalValue.value
if (!start || !end) return ''
return `${formatShortDate(start)} - ${formatShortDate(end)}`
}
return ''
})
function formatDisplay(dates: Date | Date[]): string {
if (!dates) return ''
if (dates instanceof Date) return formatFullDate(dates)
if (!Array.isArray(dates)) return ''
const valid = dates.filter((d): d is Date => d instanceof Date && !isNaN(d.getTime()))
if (valid.length === 0) return ''
if (valid.length === 1) return formatFullDate(valid[0])
return `${formatShortDate(valid[0])} - ${formatShortDate(valid[1])}`
} }
function formatSingleDate(d: Date): string { function formatFullDate(d: Date): string {
if (!d || !(d instanceof Date) || isNaN(d.getTime())) return ''
const day = String(d.getDate()).padStart(2, '0') const day = String(d.getDate()).padStart(2, '0')
const month = String(d.getMonth() + 1).padStart(2, '0') const month = String(d.getMonth() + 1).padStart(2, '0')
const year = d.getFullYear() const year = d.getFullYear()
return `${day}/${month}/${year}` return `${day}/${month}/${year}`
} }
function isSameDay(a: Date, b: Date): boolean { function formatShortDate(d: Date): string {
return a.getFullYear() === b.getFullYear() if (!d || !(d instanceof Date) || isNaN(d.getTime())) return ''
&& a.getMonth() === b.getMonth() const day = String(d.getDate()).padStart(2, '0')
&& a.getDate() === b.getDate() const month = String(d.getMonth() + 1).padStart(2, '0')
return `${day}/${month}`
} }
function onUpdate(value: Date[] | null) { function switchMode(newMode: 'day' | 'week') {
if (!value || value.length === 0) { if (mode.value === newMode) return
mode.value = newMode
internalValue.value = null
emit('update:modelValue', null)
}
function onUpdate(value: Date | Date[] | null) {
if (!value) {
emit('update:modelValue', null) emit('update:modelValue', null)
return return
} }
if (value.length === 2 && isSameDay(value[0], value[1])) {
emit('update:modelValue', value[0]) if (mode.value === 'week' && Array.isArray(value)) {
} else if (value.length === 2) { const valid = value.filter((d): d is Date => d instanceof Date && !isNaN(d.getTime()))
emit('update:modelValue', [value[0], value[1]]) if (valid.length >= 2) {
emit('update:modelValue', [valid[0], valid[1]])
}
} else if (mode.value === 'day' && value instanceof Date) {
emit('update:modelValue', value)
} }
} }
function onClear() {
internalValue.value = null
datepicker.value?.closeMenu()
emit('update:modelValue', null)
}
function selectToday() { function selectToday() {
mode.value = 'day'
const today = new Date() const today = new Date()
today.setHours(0, 0, 0, 0) today.setHours(0, 0, 0, 0)
internalValue.value = [today, today] internalValue.value = today
emit('update:modelValue', today) emit('update:modelValue', today)
} }
function selectThisWeek() { function selectThisWeek() {
mode.value = 'week'
const now = new Date() const now = new Date()
const day = now.getDay() const day = now.getDay()
const monday = new Date(now) const monday = new Date(now)
@@ -135,7 +190,7 @@ watch(() => props.modelValue, (val) => {
} else if (Array.isArray(val)) { } else if (Array.isArray(val)) {
internalValue.value = [...val] internalValue.value = [...val]
} else { } else {
internalValue.value = [val, val] internalValue.value = val
} }
}, { immediate: true }) }, { immediate: true })
</script> </script>

View File

@@ -172,7 +172,9 @@
"dateFilter": "Date", "dateFilter": "Date",
"today": "Aujourd'hui", "today": "Aujourd'hui",
"thisWeek": "Cette semaine", "thisWeek": "Cette semaine",
"clear": "Effacer" "clear": "Effacer",
"day": "Jour",
"weekShort": "Sem."
}, },
"gitea": { "gitea": {
"settings": { "settings": {

View File

@@ -75,7 +75,7 @@
</div> </div>
</div> </div>
<div class="mt-4 -mb-24 min-h-0 flex-1"> <div class="relative z-0 mt-4 -mb-24 min-h-0 flex-1">
<TimeEntryList <TimeEntryList
v-if="viewMode === 'list'" v-if="viewMode === 'list'"
:entries="filteredEntries" :entries="filteredEntries"
@@ -192,28 +192,6 @@ const filteredEntries = computed(() => {
if (selectedTagId.value) { if (selectedTagId.value) {
result = result.filter((e) => e.tags.some((t) => t.id === selectedTagId.value)) result = result.filter((e) => e.tags.some((t) => t.id === selectedTagId.value))
} }
if (selectedDateFilter.value) {
if (Array.isArray(selectedDateFilter.value)) {
const [start, end] = selectedDateFilter.value
const startDay = new Date(start)
startDay.setHours(0, 0, 0, 0)
const endDay = new Date(end)
endDay.setHours(23, 59, 59, 999)
result = result.filter((e) => {
const entryDate = new Date(e.startedAt)
return entryDate >= startDay && entryDate <= endDay
})
} else {
const day = new Date(selectedDateFilter.value)
day.setHours(0, 0, 0, 0)
const nextDay = new Date(day)
nextDay.setDate(nextDay.getDate() + 1)
result = result.filter((e) => {
const entryDate = new Date(e.startedAt)
return entryDate >= day && entryDate < nextDay
})
}
}
return result return result
}) })
@@ -367,4 +345,16 @@ watch(viewMode, () => {
watch(selectedUserId, () => { watch(selectedUserId, () => {
loadEntries() loadEntries()
}) })
watch(selectedDateFilter, (val) => {
if (!val) return
if (Array.isArray(val)) {
startDate.value = getMonday(val[0])
viewMode.value = 'week'
} else {
startDate.value = val
viewMode.value = 'day'
}
loadEntries()
})
</script> </script>