611 lines
16 KiB
Vue
611 lines
16 KiB
Vue
<template>
|
|
<div class="days-view">
|
|
<div class="view-controls">
|
|
<div class="view-navigation">
|
|
<v-btn size="x-small" variant="text" icon="mdi-chevron-left" @click="previousPeriod" />
|
|
<v-btn size="x-small" variant="tonal" @click="goToToday">Today</v-btn>
|
|
<v-btn size="x-small" variant="text" icon="mdi-chevron-right" @click="nextPeriod" />
|
|
<span class="current-period">{{ dateRangeLabel }}</span>
|
|
</div>
|
|
<v-spacer />
|
|
<div class="view-selector">
|
|
<v-btn
|
|
v-for="span in DAYS_VIEW_SPANS"
|
|
:key="span"
|
|
size="x-small"
|
|
variant="text"
|
|
@click="setSpan(span)"
|
|
:color="selectedSpan === span ? 'primary' : undefined"
|
|
>
|
|
{{ span.toUpperCase() }}
|
|
</v-btn>
|
|
</div>
|
|
</div>
|
|
<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.entity.identifier"
|
|
class="all-day-event"
|
|
:class="{
|
|
'is-start': segment.isStart,
|
|
'is-end': segment.isEnd,
|
|
}"
|
|
:style="getAllDayEventStyle(segment)"
|
|
@click="emit('event-click', segment.entity)"
|
|
@mouseenter="emit('event-hover', { event: $event, entity: segment.entity })"
|
|
@mouseleave="emit('event-hover-end')"
|
|
>
|
|
<span v-if="segment.isStart" class="event-label">{{ segment.entity.properties?.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="entity in getTimedEvents(day)"
|
|
:key="entity.identifier"
|
|
class="day-event"
|
|
:style="getEventStyle(entity)"
|
|
@click.stop="emit('event-click', entity)"
|
|
@mouseenter="emit('event-hover', { event: $event, entity })"
|
|
@mouseleave="emit('event-hover-end')"
|
|
>
|
|
<template v-if="daysCount <= 3">
|
|
<div class="event-time">
|
|
{{ formatEventDateTime(getEventProperties(entity).startsOn) }} - {{ formatEventDateTime(getEventProperties(entity).endsOn) }}
|
|
</div>
|
|
<div class="event-title">{{ getEventProperties(entity).label || 'Untitled' }}</div>
|
|
</template>
|
|
<template v-else>
|
|
<div class="event-title-compact">{{ getEventProperties(entity).label || 'Untitled' }}</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, watch } from 'vue';
|
|
import {
|
|
startOfDay,
|
|
getMultiDaySegments,
|
|
getTimedEventsForDate,
|
|
type MultiDaySegment,
|
|
} from '@/utils/calendarHelpers';
|
|
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 { EventObject } from '@ChronoManager/models/event';
|
|
|
|
const ALL_DAY_EVENT_HEIGHT = 24;
|
|
|
|
type CalendarEntity = EntityObject;
|
|
type CalendarCollection = CollectionObject;
|
|
|
|
const props = defineProps<{
|
|
currentDate: Date;
|
|
events: CalendarEntity[];
|
|
calendars: CalendarCollection[];
|
|
initialSpan?: DaysViewSpan;
|
|
}>();
|
|
|
|
const selectedSpan = ref<DaysViewSpan>(props.initialSpan ?? '7d');
|
|
const daysCount = computed(() => spanToDays(selectedSpan.value));
|
|
const localDate = ref(new Date(props.currentDate));
|
|
|
|
const emit = defineEmits<{
|
|
'event-click': [event: CalendarEntity];
|
|
'event-hover': [data: { event: MouseEvent; entity: CalendarEntity }];
|
|
'event-hover-end': [];
|
|
'date-click': [date: Date];
|
|
'update:span': [span: DaysViewSpan];
|
|
}>();
|
|
|
|
// Watch for external date changes (e.g., from mini calendar)
|
|
watch(() => props.currentDate, (newDate) => {
|
|
localDate.value = new Date(newDate);
|
|
});
|
|
|
|
watch(() => props.initialSpan, (newSpan) => {
|
|
if (newSpan && newSpan !== selectedSpan.value) {
|
|
selectedSpan.value = newSpan;
|
|
}
|
|
});
|
|
|
|
function setSpan(span: DaysViewSpan) {
|
|
selectedSpan.value = span;
|
|
emit('update:span', span);
|
|
}
|
|
|
|
function previousPeriod() {
|
|
const date = new Date(localDate.value);
|
|
date.setDate(date.getDate() - daysCount.value);
|
|
localDate.value = date;
|
|
}
|
|
|
|
function nextPeriod() {
|
|
const date = new Date(localDate.value);
|
|
date.setDate(date.getDate() + daysCount.value);
|
|
localDate.value = date;
|
|
}
|
|
|
|
function goToToday() {
|
|
localDate.value = new Date();
|
|
}
|
|
|
|
const dateRangeLabel = computed(() => {
|
|
if (visibleDates.value.length === 0) return '';
|
|
|
|
const first = visibleDates.value[0];
|
|
const last = visibleDates.value[visibleDates.value.length - 1];
|
|
|
|
const formatOptions: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric' };
|
|
|
|
if (daysCount.value === 1) {
|
|
return first.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
|
|
}
|
|
|
|
if (first.getMonth() === last.getMonth()) {
|
|
return `${first.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} - ${last.getDate()}, ${first.getFullYear()}`;
|
|
}
|
|
|
|
return `${first.toLocaleDateString('en-US', formatOptions)} - ${last.toLocaleDateString('en-US', formatOptions)}, ${last.getFullYear()}`;
|
|
});
|
|
|
|
const visibleDates = computed(() => {
|
|
const dates: Date[] = [];
|
|
const start = new Date(localDate.value);
|
|
|
|
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 = startOfDay(visibleDates.value[0]);
|
|
const rangeEnd = startOfDay(visibleDates.value[visibleDates.value.length - 1]);
|
|
|
|
const getColumnIndex = (date: Date): number => {
|
|
const targetDay = startOfDay(date);
|
|
return visibleDates.value.findIndex(d => startOfDay(d).getTime() === targetDay.getTime());
|
|
};
|
|
|
|
return getMultiDaySegments(props.events, rangeStart, rangeEnd, daysCount.value, getColumnIndex);
|
|
});
|
|
|
|
function isToday(date: Date): boolean {
|
|
const today = new Date();
|
|
return date.toDateString() === today.toDateString();
|
|
}
|
|
|
|
function getTimedEvents(date: Date): CalendarEntity[] {
|
|
return getTimedEventsForDate(props.events, date) as CalendarEntity[];
|
|
}
|
|
|
|
function getEventProperties(entity: CalendarEntity): EventObject {
|
|
return entity.properties as EventObject;
|
|
}
|
|
|
|
function getEventColor(entity: CalendarEntity): string {
|
|
const event = getEventProperties(entity);
|
|
if (event.color) return event.color;
|
|
const calendar = props.calendars.find(cal => cal.identifier === entity.collection);
|
|
return calendar?.properties?.color || '#1976D2';
|
|
}
|
|
|
|
function getAllDayEventStyle(segment: MultiDaySegment) {
|
|
return {
|
|
backgroundColor: getEventColor(segment.entity),
|
|
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(entity: CalendarEntity) {
|
|
const event = getEventProperties(entity);
|
|
|
|
if (!event.startsOn || !event.endsOn) {
|
|
return { display: 'none' };
|
|
}
|
|
const startTime = new Date(event.startsOn);
|
|
const endTime = new Date(event.endsOn);
|
|
const start = startTime.getHours() * 60 + startTime.getMinutes();
|
|
const duration = (endTime.getTime() - startTime.getTime()) / (1000 * 60);
|
|
|
|
return {
|
|
top: `${(start / 1440) * 100}%`,
|
|
height: `${(duration / 1440) * 100}%`,
|
|
backgroundColor: getEventColor(entity),
|
|
};
|
|
}
|
|
|
|
function formatHour(hour: number): string {
|
|
const ampm = hour >= 12 ? 'PM' : 'AM';
|
|
const displayHour = hour % 12 || 12;
|
|
return `${displayHour} ${ampm}`;
|
|
}
|
|
|
|
function formatTime(date: Date): string {
|
|
return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
|
|
}
|
|
|
|
function formatEventDateTime(value: string | null): string {
|
|
return value ? formatTime(new Date(value)) : '';
|
|
}
|
|
|
|
function formatWeekDay(date: Date): string {
|
|
return date.toLocaleDateString('en-US', { weekday: 'short' });
|
|
}
|
|
|
|
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>
|
|
|
|
<style scoped>
|
|
.days-view {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100%;
|
|
min-height: 600px;
|
|
}
|
|
|
|
.view-controls {
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 4px 8px;
|
|
border-bottom: 1px solid rgb(var(--v-border-color));
|
|
background-color: rgb(var(--v-theme-surface));
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.view-navigation {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
}
|
|
|
|
.current-period {
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
margin-left: 8px;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.view-selector {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 2px;
|
|
}
|
|
|
|
.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>
|