Files
chrono/src/components/DaysView.vue
T
2026-07-09 21:20:30 -04:00

649 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>