Replace fullcalendar calendar header with custom toolbar

This commit is contained in:
Gregor Vostrak
2026-03-04 15:47:23 +01:00
parent c581ad8854
commit 89a4a1962a
9 changed files with 231 additions and 105 deletions

View File

@@ -0,0 +1,67 @@
<script setup lang="ts">
import { Button } from '..';
import { ChevronLeft, ChevronRight } from 'lucide-vue-next';
import { Tabs, TabsList } from '@/Components/ui/tabs';
import TabBarItem from '@/Components/Common/TabBar/TabBarItem.vue';
import CalendarSettingsPopover from './CalendarSettingsPopover.vue';
import type { CalendarSettings } from './calendarSettings';
defineProps<{
viewTitle: string;
activeView: string;
settings: CalendarSettings;
}>();
const emit = defineEmits<{
prev: [];
next: [];
today: [];
'change-view': [view: string];
'update:settings': [value: CalendarSettings];
}>();
</script>
<template>
<div class="flex items-center justify-between bg-background px-2 py-1.5">
<!-- Left: Navigation -->
<div class="flex items-center gap-1">
<Button
variant="outline"
size="sm"
class="h-8 w-8 p-0"
aria-label="Previous"
@click="emit('prev')">
<ChevronLeft class="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
class="h-8 w-8 p-0"
aria-label="Next"
@click="emit('next')">
<ChevronRight class="h-4 w-4" />
</Button>
<Button variant="outline" size="sm" @click="emit('today')"> today </Button>
</div>
<!-- Center: Title -->
<span data-testid="calendar-title" class="text-base font-semibold text-foreground">{{
viewTitle
}}</span>
<!-- Right: View switcher + Settings -->
<div class="flex items-center gap-1">
<Tabs
:model-value="activeView"
@update:model-value="(v) => emit('change-view', String(v))">
<TabsList class="flex items-center space-x-0.5 sm:space-x-1">
<TabBarItem value="timeGridWeek">week</TabBarItem>
<TabBarItem value="timeGridDay">day</TabBarItem>
</TabsList>
</Tabs>
<CalendarSettingsPopover
:settings="settings"
@update:settings="(v) => emit('update:settings', v)" />
</div>
</div>
</template>

View File

@@ -29,7 +29,7 @@ import { getUserTimezone, getWeekStart } from '../utils/settings';
import { LoadingSpinner, TimeEntryCreateModal, TimeEntryEditModal } from '..';
import FullCalendarEventContent from './FullCalendarEventContent.vue';
import FullCalendarDayHeader from './FullCalendarDayHeader.vue';
import CalendarSettingsPopover from './CalendarSettingsPopover.vue';
import CalendarToolbar from './CalendarToolbar.vue';
import type { CalendarSettings } from './calendarSettings';
import { useVisualSnap } from './useVisualSnap';
import {
@@ -123,6 +123,27 @@ function onSettingsUpdate(newSettings: CalendarSettings) {
calendarSettings.value = newSettings;
}
// Toolbar state — updated via datesSet callback
const viewTitle = ref('');
const activeView = ref('timeGridWeek');
function handlePrev() {
calendarRef.value?.getApi().prev();
scrollToCurrentTime();
}
function handleNext() {
calendarRef.value?.getApi().next();
scrollToCurrentTime();
}
function handleToday() {
calendarRef.value?.getApi().today();
scrollToCurrentTime();
}
function handleChangeView(view: string) {
calendarRef.value?.getApi().changeView(view);
scrollToCurrentTime();
}
// Reactive "now" for running time entry - updates every minute
const currentTime = ref(getDayJsInstance()());
let currentTimeInterval: ReturnType<typeof setInterval> | null = null;
@@ -240,6 +261,8 @@ const dailyTotals = computed(() => {
});
function emitDatesChange(arg: DatesSetArg) {
viewTitle.value = arg.view.title;
activeView.value = arg.view.type;
emit('dates-change', { start: arg.start, end: arg.end });
// Render activity boxes after calendar view has been rendered
renderActivityBoxes();
@@ -506,11 +529,7 @@ const calendarOptions = computed(() => {
return {
plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin, activityStatusPlugin],
initialView: 'timeGridWeek',
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'timeGridWeek,timeGridDay',
},
headerToolbar: false as const,
height: 'parent',
slotMinTime: formatDuration(s.startHour * 3600),
slotMaxTime: formatDuration(s.endHour * 3600),
@@ -617,7 +636,7 @@ onUnmounted(() => {
</script>
<template>
<div class="w-full relative h-full flex-1">
<div class="w-full relative h-full flex-1 flex flex-col">
<div v-if="loading" class="flex items-center justify-center h-full">
<div class="flex flex-col items-center space-y-4">
<LoadingSpinner class="h-8 w-8" />
@@ -656,13 +675,20 @@ onUnmounted(() => {
:clients="clients"
:currency="currency"
:can-create-project="canCreateProject" />
<div class="calendar-settings-trigger">
<CalendarSettingsPopover
:settings="calendarSettings"
@update:settings="onSettingsUpdate" />
</div>
<CalendarToolbar
:view-title="viewTitle"
:active-view="activeView"
:settings="calendarSettings"
@prev="handlePrev"
@next="handleNext"
@today="handleToday"
@change-view="handleChangeView"
@update:settings="onSettingsUpdate" />
<ContextMenu>
<ContextMenuTrigger as="div" class="h-full" @contextmenu="handleCalendarContextMenu">
<ContextMenuTrigger
as="div"
class="flex-1 min-h-0"
@contextmenu="handleCalendarContextMenu">
<FullCalendar ref="calendarRef" class="fullcalendar" :options="calendarOptions">
<template #eventContent="arg">
<FullCalendarEventContent
@@ -743,13 +769,6 @@ onUnmounted(() => {
</template>
<style scoped>
.calendar-settings-trigger {
position: absolute;
top: 0.5rem;
right: 0.5rem;
z-index: 20;
}
.fullcalendar {
height: 100%;
--fc-border-color: var(--border);
@@ -764,49 +783,12 @@ onUnmounted(() => {
.fullcalendar :deep(.fc-timegrid-slot) {
height: 25px;
transition: height 0.2s ease;
}
.fullcalendar :deep(.fc-timegrid-slot-label) {
background-color: var(--background);
}
.fullcalendar :deep(.fc-toolbar) {
background-color: var(--background);
padding: 0.5rem;
padding-right: 2.75rem;
margin-bottom: 0;
}
.fullcalendar :deep(.fc-toolbar-title) {
color: var(--foreground);
font-size: 1rem;
font-weight: 600;
}
.fullcalendar :deep(.fc-button) {
background-color: var(--secondary);
border: 1px solid var(--border);
color: var(--foreground);
font-weight: 500;
font-size: 14px !important;
}
.fullcalendar :deep(.fc-button:hover) {
background-color: var(--muted);
border-color: var(--border);
}
.fullcalendar :deep(.fc-button:focus) {
box-shadow: 0 0 0 2px var(--ring);
}
.fullcalendar :deep(.fc-button-active) {
background-color: var(--primary);
border-color: var(--primary);
color: var(--primary-foreground);
}
.fullcalendar :deep(.fc-col-header) {
border-bottom: 1px solid var(--border);
}

View File

@@ -214,7 +214,7 @@ useSelectEvents(
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-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 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"

View File

@@ -154,7 +154,7 @@ function closeAndFocusInput() {
v-model="currentTime"
placeholder="00:00:00"
data-testid="time_entry_time"
class="w-[110px] lg:w-[130px] h-full text-text-primary py-2.5 rounded-lg border-border-secondary border text-center px-4 text-base lg:text-lg font-semibold bg-card-background border-none placeholder-text-tertiary focus:ring-0 transition"
class="w-[110px] lg:w-[120px] h-full text-text-primary py-2.5 rounded-lg border-border-secondary border text-center px-4 text-base font-semibold tabular-nums bg-card-background border-none placeholder-text-tertiary focus:ring-0 transition"
type="text"
@focusin="openModalOnTab"
@click="openModalOnClick"