4b782ff078
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
649 lines
17 KiB
Vue
649 lines
17 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, formatTime, 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 hoveredTime = ref<{ day: string; minutes: number } | null>(null);
|
||
const timeSelection = ref<{ day: string; anchor: number; current: number } | null>(null);
|
||
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];
|
||
'date-range-select': [range: { start: Date; end: 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 pointerMinutes(event: MouseEvent, allowEndOfDay = false): number {
|
||
const target = event.currentTarget as HTMLElement;
|
||
const rect = target.getBoundingClientRect();
|
||
const pointerY = Math.min(Math.max(event.clientY - rect.top, 0), rect.height);
|
||
const minutes = Math.round(((pointerY / rect.height) * 1440) / 15) * 15;
|
||
|
||
return Math.min(minutes, allowEndOfDay ? 1440 : 1425);
|
||
}
|
||
|
||
function handleHover(event: MouseEvent, day: Date) {
|
||
hoveredTime.value = {
|
||
day: day.toISOString(),
|
||
minutes: pointerMinutes(event),
|
||
};
|
||
}
|
||
|
||
function formatHoverTime(totalMinutes: number): string {
|
||
const hours = Math.floor(totalMinutes / 60);
|
||
const minutes = totalMinutes % 60;
|
||
const date = new Date(2000, 0, 1, hours, minutes);
|
||
return formatTime(date);
|
||
}
|
||
|
||
function dateAtMinutes(day: Date, totalMinutes: number): Date {
|
||
const date = new Date(day);
|
||
date.setHours(0, totalMinutes, 0, 0);
|
||
return date;
|
||
}
|
||
|
||
function selectionBounds(selection: NonNullable<typeof timeSelection.value>) {
|
||
const start = Math.min(selection.anchor, selection.current);
|
||
const end = selection.anchor === selection.current
|
||
? Math.min(start + 15, 1440)
|
||
: Math.max(selection.anchor, selection.current);
|
||
|
||
return { start, end };
|
||
}
|
||
|
||
function handlePointerDown(event: PointerEvent, day: Date) {
|
||
if (event.pointerType !== 'mouse' || event.button !== 0) return;
|
||
|
||
event.preventDefault();
|
||
const minutes = pointerMinutes(event);
|
||
hoveredTime.value = null;
|
||
timeSelection.value = {
|
||
day: day.toISOString(),
|
||
anchor: minutes,
|
||
current: minutes,
|
||
};
|
||
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
|
||
}
|
||
|
||
function handlePointerUp(event: PointerEvent, day: Date) {
|
||
const selection = timeSelection.value;
|
||
if (!selection || selection.day !== day.toISOString()) return;
|
||
|
||
selection.current = pointerMinutes(event, true);
|
||
const { start, end } = selectionBounds(selection);
|
||
timeSelection.value = null;
|
||
|
||
if (selection.anchor === selection.current) {
|
||
emit('date-click', dateAtMinutes(day, start));
|
||
return;
|
||
}
|
||
|
||
emit('date-range-select', {
|
||
start: dateAtMinutes(day, start),
|
||
end: dateAtMinutes(day, end),
|
||
});
|
||
}
|
||
|
||
function handlePointerMove(event: PointerEvent, day: Date) {
|
||
if (timeSelection.value?.day === day.toISOString()) {
|
||
timeSelection.value.current = pointerMinutes(event, true);
|
||
return;
|
||
}
|
||
|
||
handleHover(event, day);
|
||
}
|
||
|
||
function selectionStyle(selection: NonNullable<typeof timeSelection.value>) {
|
||
const { start, end } = selectionBounds(selection);
|
||
return {
|
||
top: `${start / 1440 * 100}%`,
|
||
height: `${(end - start) / 1440 * 100}%`,
|
||
};
|
||
}
|
||
|
||
function selectionLabel(selection: NonNullable<typeof timeSelection.value>): string {
|
||
const { start, end } = selectionBounds(selection);
|
||
return `${formatHoverTime(start)} – ${formatHoverTime(end)} · ${end - start} min`;
|
||
}
|
||
|
||
</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"
|
||
@pointerdown="handlePointerDown($event, day)"
|
||
@pointermove="handlePointerMove($event, day)"
|
||
@pointerup="handlePointerUp($event, day)"
|
||
@pointercancel="timeSelection = null"
|
||
@mouseleave="hoveredTime = null"
|
||
>
|
||
<div
|
||
v-if="hoveredTime?.day === day.toISOString()"
|
||
class="hover-time-indicator"
|
||
:style="{ top: `${hoveredTime.minutes / 1440 * 100}%` }"
|
||
>
|
||
<span>{{ formatHoverTime(hoveredTime.minutes) }}</span>
|
||
</div>
|
||
<div
|
||
v-if="timeSelection?.day === day.toISOString()"
|
||
class="time-selection-preview"
|
||
:style="selectionStyle(timeSelection)"
|
||
>
|
||
<span>{{ selectionLabel(timeSelection) }}</span>
|
||
</div>
|
||
<div
|
||
v-for="instance in getTimedEvents(day)"
|
||
:key="instance.key"
|
||
class="day-event"
|
||
:style="getEventStyle(instance)"
|
||
@pointerdown.stop
|
||
@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: 0;
|
||
}
|
||
|
||
.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);
|
||
}
|
||
|
||
.hover-time-indicator {
|
||
position: absolute;
|
||
left: 0;
|
||
right: 0;
|
||
z-index: 3;
|
||
height: 1px;
|
||
background-color: rgb(var(--v-theme-primary));
|
||
pointer-events: none;
|
||
}
|
||
|
||
.hover-time-indicator span {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 4px;
|
||
padding: 1px 4px;
|
||
border-radius: 3px;
|
||
background-color: rgb(var(--v-theme-primary));
|
||
color: rgb(var(--v-theme-on-primary));
|
||
font-size: 10px;
|
||
line-height: 14px;
|
||
transform: translateY(-50%);
|
||
}
|
||
|
||
.time-selection-preview {
|
||
position: absolute;
|
||
left: 3px;
|
||
right: 3px;
|
||
z-index: 4;
|
||
min-height: 15px;
|
||
padding: 3px 5px;
|
||
border: 1px solid rgb(var(--v-theme-primary));
|
||
border-radius: 4px;
|
||
background-color: rgba(var(--v-theme-primary), 0.2);
|
||
color: rgb(var(--v-theme-primary));
|
||
pointer-events: none;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.time-selection-preview span {
|
||
font-size: 10px;
|
||
font-weight: 600;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.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>
|