Files
chrono/src/components/DaysView.vue
T
Sebastian df531908f3 refactor: code clean up
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-06 21:09:07 -04:00

503 lines
13 KiB
Vue

<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import { layoutMultiDayLanes, type MultiDaySegment } from '@/utils/multiDayLanes';
import { shiftLocalDays, startOfLocalDay } from '@/utils/date';
import { DAYS_VIEW_SPANS, spanToDays, type DaysViewSpan } from '@/types/spans';
import type { EntityObject } from '@ChronoManager/models/entity';
import type { CollectionObject } from '@ChronoManager/models/collection';
import { useChronoInstancesStore } from '@/stores/chronoInstancesStore';
import type { CalendarInstance } from '@/types/instance';
import ViewControls from './ViewControls.vue';
import { formatEpochTime, formatHour, formatWeekDay } from '@/utils/format';
const ALL_DAY_EVENT_HEIGHT = 24;
const props = defineProps<{
currentDate: Date;
calendars: CollectionObject[];
initialSpan?: DaysViewSpan;
}>();
const instancesStore = useChronoInstancesStore();
const selectedSpan = ref<DaysViewSpan>(props.initialSpan ?? '7d');
const daysCount = computed(() => spanToDays(selectedSpan.value));
const emit = defineEmits<{
'event-click': [event: EntityObject];
'event-hover': [data: { event: MouseEvent; entity: EntityObject }];
'event-hover-end': [];
'date-click': [date: Date];
'update:span': [span: DaysViewSpan];
'update:current-date': [date: Date];
}>();
watch(() => props.initialSpan, (newSpan) => {
if (newSpan && newSpan !== selectedSpan.value) {
selectedSpan.value = newSpan;
}
});
function setSpan(span: DaysViewSpan) {
selectedSpan.value = span;
emit('update:span', span);
}
const visibleDates = computed(() => {
const dates: Date[] = [];
const start = new Date(props.currentDate);
for (let i = 0; i < daysCount.value; i++) {
dates.push(new Date(start));
start.setDate(start.getDate() + 1);
}
return dates;
});
// Compute all-day/multi-day event segments
const allDaySegments = computed(() => {
if (visibleDates.value.length === 0) {
return { segments: [], laneCount: 0 };
}
const rangeStart = startOfLocalDay(visibleDates.value[0]);
const rangeEnd = startOfLocalDay(visibleDates.value[visibleDates.value.length - 1]);
const getColumnIndex = (date: Date): number => {
const targetDay = startOfLocalDay(date);
return visibleDates.value.findIndex(d => startOfLocalDay(d).getTime() === targetDay.getTime());
};
const instances = instancesStore.instancesForRange({
start: rangeStart.getTime(),
end: shiftLocalDays(rangeEnd, 1).getTime(),
});
return layoutMultiDayLanes(instances, rangeStart, rangeEnd, daysCount.value, getColumnIndex);
});
function isToday(date: Date): boolean {
const today = new Date();
return date.toDateString() === today.toDateString();
}
function getTimedEvents(date: Date): CalendarInstance[] {
return instancesStore.instancesForDay(date).filter(instance => !instance.timeless);
}
function getEventColor(instance: CalendarInstance): string {
if (instance.color) return instance.color;
const calendar = props.calendars.find(cal => cal.identifier === instance.entity.collection);
return calendar?.properties?.color || '#1976D2';
}
function getAllDayEventStyle(segment: MultiDaySegment) {
return {
backgroundColor: getEventColor(segment.instance),
left: `calc(${segment.startCol} / ${daysCount.value} * 100% + 4px)`,
width: `calc(${segment.span} / ${daysCount.value} * 100% - 8px)`,
top: `${segment.lane * ALL_DAY_EVENT_HEIGHT}px`,
};
}
function getEventStyle(instance: CalendarInstance) {
const startTime = new Date(instance.start);
const start = startTime.getHours() * 60 + startTime.getMinutes();
const duration = instance.duration / (1000 * 60);
return {
top: `${(start / 1440) * 100}%`,
height: `${(duration / 1440) * 100}%`,
backgroundColor: getEventColor(instance),
};
}
function handleDayClick(event: MouseEvent, day: Date) {
const target = event.currentTarget as HTMLElement;
const rect = target.getBoundingClientRect();
const clickY = event.clientY - rect.top;
// Calculate the hour based on click position (60px per hour)
const totalMinutes = Math.floor((clickY / 60) * 60);
const hours = Math.floor(totalMinutes / 60);
const minutes = Math.round((totalMinutes % 60) / 15) * 15; // Round to nearest 15 min
// Create a new date with the clicked time
const clickedDate = new Date(day);
clickedDate.setHours(hours, minutes, 0, 0);
emit('date-click', clickedDate);
}
</script>
<template>
<div class="days-view">
<ViewControls
mode="days"
:current-date="currentDate"
:spans="DAYS_VIEW_SPANS"
:selected-span="selectedSpan"
@update:current-date="emit('update:current-date', $event)"
@update:span="setSpan"
/>
<div class="days-grid">
<!-- Fixed Headers Row -->
<div class="day-headers">
<div class="time-header-spacer"></div>
<div class="day-headers-row">
<div v-for="day in visibleDates" :key="day.toISOString() + '-header'" class="day-header-cell" :class="{ 'single-day': daysCount === 1 }">
<span class="day-name">{{ formatWeekDay(day) }}</span>
<span class="day-number" :class="{ 'today': isToday(day) }">{{ day.getDate() }}</span>
</div>
</div>
</div>
<!-- All-Day Events Section -->
<div v-if="allDaySegments.laneCount > 0" class="all-day-section">
<div class="all-day-label">
<span>All day</span>
</div>
<div class="all-day-events-container" :style="{ height: `${allDaySegments.laneCount * 24 + 4}px` }">
<div class="all-day-columns">
<div v-for="day in visibleDates" :key="day.toISOString() + '-allday'" class="all-day-column" :class="{ 'single-day': daysCount === 1 }"></div>
</div>
<div class="all-day-events-overlay">
<div
v-for="segment in allDaySegments.segments"
:key="segment.instance.key"
class="all-day-event"
:class="{
'is-start': segment.isStart,
'is-end': segment.isEnd,
}"
:style="getAllDayEventStyle(segment)"
@click="emit('event-click', segment.instance.entity)"
@mouseenter="emit('event-hover', { event: $event, entity: segment.instance.entity })"
@mouseleave="emit('event-hover-end')"
>
<span v-if="segment.isStart" class="event-label">{{ segment.instance.label || 'Untitled' }}</span>
</div>
</div>
</div>
</div>
<!-- Scrollable Content -->
<div class="days-content">
<div class="time-column">
<div v-for="hour in 24" :key="hour" class="time-slot">
{{ formatHour(hour - 1) }}
</div>
</div>
<div class="days-columns">
<div v-for="day in visibleDates" :key="day.toISOString()" class="day-column" :class="{ 'single-day': daysCount === 1 }">
<div class="day-events" @click="handleDayClick($event, day)">
<div
v-for="instance in getTimedEvents(day)"
:key="instance.key"
class="day-event"
:style="getEventStyle(instance)"
@click.stop="emit('event-click', instance.entity)"
@mouseenter="emit('event-hover', { event: $event, entity: instance.entity })"
@mouseleave="emit('event-hover-end')"
>
<template v-if="daysCount <= 3">
<div class="event-time">
{{ formatEpochTime(instance.start) }} - {{ formatEpochTime(instance.end) }}
</div>
<div class="event-title">{{ instance.label || 'Untitled' }}</div>
</template>
<template v-else>
<div class="event-title-compact">{{ instance.label || 'Untitled' }}</div>
</template>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.days-view {
display: flex;
flex-direction: column;
height: 100%;
min-height: 600px;
}
.days-grid {
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
border: 1px solid rgb(var(--v-border-color));
border-top: none;
}
/* Fixed Headers Row */
.day-headers {
display: flex;
flex-shrink: 0;
border-bottom: 2px solid rgb(var(--v-border-color));
background-color: rgb(var(--v-theme-surface-variant));
}
.time-header-spacer {
width: 60px;
min-width: 60px;
flex-shrink: 0;
border-right: 1px solid rgb(var(--v-border-color));
}
.day-headers-row {
flex: 1;
display: flex;
}
.day-header-cell {
flex: 1;
min-width: 80px;
padding: 4px;
text-align: center;
border-right: 1px solid rgb(var(--v-border-color));
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
}
.day-header-cell:last-child {
border-right: none;
}
.day-header-cell.single-day {
min-width: 100%;
}
.day-name {
font-size: 12px;
text-transform: uppercase;
opacity: 0.7;
font-weight: 500;
}
.day-number {
font-size: 12px;
font-weight: 600;
}
.day-number.today {
background-color: rgb(var(--v-theme-primary));
color: rgb(var(--v-theme-on-primary));
border-radius: 50%;
width: 20px;
height: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 11px;
}
/* All-Day Events Section */
.all-day-section {
display: flex;
flex-shrink: 0;
border-bottom: 1px solid rgb(var(--v-border-color));
background-color: rgb(var(--v-theme-surface));
min-height: 28px;
}
.all-day-label {
width: 60px;
min-width: 60px;
flex-shrink: 0;
border-right: 1px solid rgb(var(--v-border-color));
background-color: rgb(var(--v-theme-surface-variant));
display: flex;
align-items: flex-start;
justify-content: flex-end;
padding: 4px 8px;
font-size: 10px;
opacity: 0.6;
text-transform: uppercase;
}
.all-day-events-container {
flex: 1;
position: relative;
min-height: 28px;
}
.all-day-columns {
display: flex;
height: 100%;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
.all-day-column {
flex: 1;
min-width: 80px;
border-right: 1px solid rgb(var(--v-border-color), 0.3);
}
.all-day-column.single-day {
min-width: 100%;
}
.all-day-column:last-child {
border-right: none;
}
.all-day-events-overlay {
position: absolute;
top: 2px;
left: 0;
right: 0;
bottom: 2px;
pointer-events: none;
z-index: 1;
}
.all-day-event {
position: absolute;
height: 20px;
padding: 0 6px;
font-size: 12px;
color: white;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 500;
display: flex;
align-items: center;
pointer-events: auto;
box-sizing: border-box;
border-radius: 4px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.all-day-event:not(.is-start) {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
padding-left: 4px;
}
.all-day-event:not(.is-end) {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.all-day-event:hover {
filter: brightness(1.1);
z-index: 2;
}
.all-day-event .event-label {
overflow: hidden;
text-overflow: ellipsis;
}
/* Scrollable Content Area */
.days-content {
display: flex;
flex: 1;
overflow-y: auto;
overflow-x: hidden;
min-height: 0; /* Allow flex child to shrink for scrolling */
}
.time-column {
width: 60px;
min-width: 60px;
height: 1440px; /* 24 hours * 60px per hour */
border-right: 1px solid rgb(var(--v-border-color));
background-color: rgb(var(--v-theme-surface-variant));
flex-shrink: 0;
}
.time-slot {
height: 60px;
padding: 4px 8px;
font-size: 11px;
opacity: 0.6;
border-bottom: 1px solid rgb(var(--v-border-color), 0.5);
text-align: right;
}
.days-columns {
flex: 1;
display: flex;
min-height: 1440px; /* 24 hours * 60px per hour */
}
.day-column {
flex: 1;
min-width: 80px;
border-right: 1px solid rgb(var(--v-border-color));
}
.day-column.single-day {
min-width: 100%;
}
.day-column:last-child {
border-right: none;
}
.day-events {
position: relative;
height: 1440px; /* 24 hours * 60px per hour */
cursor: pointer;
}
.day-events:hover {
background-color: rgba(var(--v-theme-primary), 0.02);
}
.day-event {
position: absolute;
left: 4px;
right: 4px;
padding: 6px 8px;
border-radius: 4px;
color: white;
font-size: 12px;
cursor: pointer;
overflow: hidden;
font-weight: 500;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
transition: box-shadow 0.2s;
}
.day-event:hover {
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
filter: brightness(1.1);
}
.event-time {
font-weight: 600;
margin-bottom: 2px;
font-size: 11px;
}
.event-title {
font-size: 13px;
font-weight: 500;
}
.event-title-compact {
font-size: 11px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>