calendar/src/components/CalendarView.vue
2025-08-23 21:26:22 -06:00

636 lines
18 KiB
Vue

<script setup>
import { ref, onMounted, onBeforeUnmount, computed, watch } from 'vue'
import { useCalendarStore } from '@/stores/CalendarStore'
import CalendarHeader from '@/components/CalendarHeader.vue'
import CalendarWeek from '@/components/CalendarWeek.vue'
import Jogwheel from '@/components/Jogwheel.vue'
import SettingsDialog from '@/components/SettingsDialog.vue'
import {
getLocalizedMonthName,
monthAbbr,
lunarPhaseSymbol,
pad,
daysInclusive,
addDaysStr,
formatDateRange,
getMondayOfISOWeek,
getOccurrenceIndex,
getVirtualOccurrenceEndDate,
getISOWeek,
} from '@/utils/date'
import { toLocalString, fromLocalString, DEFAULT_TZ } from '@/utils/date'
import { addDays, differenceInCalendarDays } from 'date-fns'
const calendarStore = useCalendarStore()
const viewport = ref(null)
const settingsDialog = ref(null)
const emit = defineEmits(['create-event', 'edit-event'])
function createEventFromSelection() {
if (!selection.value.startDate || selection.value.dayCount === 0) return null
return {
startDate: selection.value.startDate,
dayCount: selection.value.dayCount,
endDate: addDaysStr(selection.value.startDate, selection.value.dayCount - 1),
}
}
const scrollTop = ref(0)
const viewportHeight = ref(600)
const rowHeight = ref(64)
const baseDate = computed(() => new Date(1970, 0, 4 + calendarStore.config.first_day))
const selection = ref({ startDate: null, dayCount: 0 })
const isDragging = ref(false)
const dragAnchor = ref(null)
const WEEK_MS = 7 * 24 * 60 * 60 * 1000
const minVirtualWeek = computed(() => {
const date = new Date(calendarStore.minYear, 0, 1)
const dayOffset = (date.getDay() - calendarStore.config.first_day + 7) % 7
const firstDayOfWeek = addDays(date, -dayOffset)
return Math.floor((firstDayOfWeek.getTime() - baseDate.value.getTime()) / WEEK_MS)
})
const maxVirtualWeek = computed(() => {
const date = new Date(calendarStore.maxYear, 11, 31)
const dayOffset = (date.getDay() - calendarStore.config.first_day + 7) % 7
const firstDayOfWeek = addDays(date, -dayOffset)
return Math.floor((firstDayOfWeek.getTime() - baseDate.value.getTime()) / WEEK_MS)
})
const totalVirtualWeeks = computed(() => {
return maxVirtualWeek.value - minVirtualWeek.value + 1
})
const initialScrollTop = computed(() => {
const nowDate = new Date(calendarStore.now)
const targetWeekIndex = getWeekIndex(nowDate) - 3
return (targetWeekIndex - minVirtualWeek.value) * rowHeight.value
})
const selectedDateRange = computed(() => {
if (!selection.value.start || !selection.value.end) return ''
return formatDateRange(
fromLocalString(selection.value.start),
fromLocalString(selection.value.end),
)
})
const todayString = computed(() => {
const d = new Date(calendarStore.now)
const t = d
.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' })
.replace(/,? /, '\n')
return t.charAt(0).toUpperCase() + t.slice(1)
})
const visibleWeeks = computed(() => {
const buffer = 10
const startIdx = Math.floor((scrollTop.value - buffer * rowHeight.value) / rowHeight.value)
const endIdx = Math.ceil(
(scrollTop.value + viewportHeight.value + buffer * rowHeight.value) / rowHeight.value,
)
const startVW = Math.max(minVirtualWeek.value, startIdx + minVirtualWeek.value)
const endVW = Math.min(maxVirtualWeek.value, endIdx + minVirtualWeek.value)
const weeks = []
for (let vw = startVW; vw <= endVW; vw++) {
weeks.push(createWeek(vw))
}
return weeks
})
const contentHeight = computed(() => {
return totalVirtualWeeks.value * rowHeight.value
})
function computeRowHeight() {
const el = document.createElement('div')
el.style.position = 'absolute'
el.style.visibility = 'hidden'
el.style.height = 'var(--cell-h)'
document.body.appendChild(el)
const h = el.getBoundingClientRect().height || 64
el.remove()
rowHeight.value = Math.round(h)
return rowHeight.value
}
function getWeekIndex(date) {
const dayOffset = (date.getDay() - calendarStore.config.first_day + 7) % 7
const firstDayOfWeek = addDays(date, -dayOffset)
return Math.floor((firstDayOfWeek.getTime() - baseDate.value.getTime()) / WEEK_MS)
}
function getFirstDayForVirtualWeek(virtualWeek) {
return addDays(baseDate.value, virtualWeek * 7)
}
function createWeek(virtualWeek) {
const firstDay = getFirstDayForVirtualWeek(virtualWeek)
const isoAnchor = addDays(firstDay, (4 - firstDay.getDay() + 7) % 7)
const weekNumber = getISOWeek(isoAnchor)
const days = []
let cur = new Date(firstDay)
let hasFirst = false
let monthToLabel = null
let labelYear = null
const repeatingBases = []
if (calendarStore.events) {
for (const ev of calendarStore.events.values()) {
if (ev.isRepeating) repeatingBases.push(ev)
}
}
for (let i = 0; i < 7; i++) {
const dateStr = toLocalString(cur, DEFAULT_TZ)
const storedEvents = []
// Find all non-repeating events that occur on this date
for (const ev of calendarStore.events.values()) {
if (!ev.isRepeating && dateStr >= ev.startDate && dateStr <= ev.endDate) {
storedEvents.push(ev)
}
}
// Build day events starting with stored (base/spanning) then virtual occurrences
const dayEvents = [...storedEvents]
for (const base of repeatingBases) {
// If the current date falls within the base event's original span, include the base
// event itself as occurrence index 0. Previously this was skipped which caused the
// first (n=0) occurrence of repeating events to be missing from the calendar.
if (dateStr >= base.startDate && dateStr <= base.endDate) {
dayEvents.push({
...base,
// Mark explicit recurrence index for consistency with virtual occurrences
_recurrenceIndex: 0,
_baseId: base.id,
})
continue
}
// Check if any virtual occurrence spans this date
const baseStart = fromLocalString(base.startDate, DEFAULT_TZ)
const baseEnd = fromLocalString(base.endDate, DEFAULT_TZ)
const spanDays = Math.max(0, differenceInCalendarDays(baseEnd, baseStart))
const currentDate = fromLocalString(dateStr, DEFAULT_TZ)
let occurrenceFound = false
// Walk backwards within span to find occurrence start
for (let offset = 0; offset <= spanDays && !occurrenceFound; offset++) {
const candidateStart = addDays(currentDate, -offset)
const candidateStartStr = toLocalString(candidateStart, DEFAULT_TZ)
const occurrenceIndex = getOccurrenceIndex(base, candidateStartStr, DEFAULT_TZ)
if (occurrenceIndex !== null) {
// Calculate the end date of this occurrence
const virtualEndDate = getVirtualOccurrenceEndDate(base, candidateStartStr, DEFAULT_TZ)
// Check if this occurrence spans through the current date
if (dateStr >= candidateStartStr && dateStr <= virtualEndDate) {
// Create virtual occurrence (if not already created)
const virtualId = base.id + '_v_' + candidateStartStr
const alreadyExists = dayEvents.some((ev) => ev.id === virtualId)
if (!alreadyExists) {
dayEvents.push({
...base,
id: virtualId,
startDate: candidateStartStr,
endDate: virtualEndDate,
_recurrenceIndex: occurrenceIndex,
_baseId: base.id,
})
}
occurrenceFound = true
}
}
}
}
const dow = cur.getDay()
const isFirst = cur.getDate() === 1
if (isFirst) {
hasFirst = true
monthToLabel = cur.getMonth()
labelYear = cur.getFullYear()
}
let displayText = String(cur.getDate())
if (isFirst) {
if (cur.getMonth() === 0) {
displayText = cur.getFullYear()
} else {
displayText = monthAbbr[cur.getMonth()].slice(0, 3).toUpperCase()
}
}
// Get holiday info once per day
const holiday = calendarStore.getHolidayForDate(dateStr)
days.push({
date: dateStr,
dayOfMonth: cur.getDate(),
displayText,
monthClass: monthAbbr[cur.getMonth()],
isToday: dateStr === calendarStore.today,
isWeekend: calendarStore.weekend[dow],
isFirstDay: isFirst,
lunarPhase: lunarPhaseSymbol(cur),
holiday: holiday,
isHoliday: holiday !== null,
isSelected:
selection.value.startDate &&
selection.value.dayCount > 0 &&
dateStr >= selection.value.startDate &&
dateStr <= addDaysStr(selection.value.startDate, selection.value.dayCount - 1),
events: dayEvents,
})
cur = addDays(cur, 1)
}
let monthLabel = null
if (hasFirst && monthToLabel !== null) {
if (labelYear && labelYear <= calendarStore.config.max_year) {
let weeksSpan = 0
const d = addDays(cur, -1)
for (let i = 0; i < 6; i++) {
const probe = addDays(cur, -1 + i * 7)
d.setTime(probe.getTime())
if (d.getMonth() === monthToLabel) weeksSpan++
}
const remainingWeeks = Math.max(1, maxVirtualWeek.value - virtualWeek + 1)
weeksSpan = Math.max(1, Math.min(weeksSpan, remainingWeeks))
const year = String(labelYear).slice(-2)
monthLabel = {
text: `${getLocalizedMonthName(monthToLabel)} '${year}`,
month: monthToLabel,
weeksSpan: weeksSpan,
height: weeksSpan * rowHeight.value,
}
}
}
return {
virtualWeek,
weekNumber: pad(weekNumber),
days,
monthLabel,
top: (virtualWeek - minVirtualWeek.value) * rowHeight.value,
}
}
function goToToday() {
const top = addDays(new Date(calendarStore.now), -21)
const targetWeekIndex = getWeekIndex(top)
scrollTop.value = (targetWeekIndex - minVirtualWeek.value) * rowHeight.value
if (viewport.value) {
viewport.value.scrollTop = scrollTop.value
}
}
function clearSelection() {
selection.value = { startDate: null, dayCount: 0 }
}
function startDrag(dateStr) {
if (calendarStore.config.select_days === 0) return
isDragging.value = true
dragAnchor.value = dateStr
selection.value = { startDate: dateStr, dayCount: 1 }
}
function updateDrag(dateStr) {
if (!isDragging.value) return
const { startDate, dayCount } = calculateSelection(dragAnchor.value, dateStr)
selection.value = { startDate, dayCount }
}
function endDrag(dateStr) {
if (!isDragging.value) return
isDragging.value = false
const { startDate, dayCount } = calculateSelection(dragAnchor.value, dateStr)
selection.value = { startDate, dayCount }
}
function calculateSelection(anchorStr, otherStr) {
const limit = calendarStore.config.select_days
const anchorDate = fromLocalString(anchorStr, DEFAULT_TZ)
const otherDate = fromLocalString(otherStr, DEFAULT_TZ)
const forward = otherDate >= anchorDate
const span = daysInclusive(anchorStr, otherStr)
if (span <= limit) {
const startDate = forward ? anchorStr : otherStr
return { startDate, dayCount: span }
}
if (forward) {
return { startDate: anchorStr, dayCount: limit }
} else {
const startDate = addDaysStr(anchorStr, -(limit - 1), DEFAULT_TZ)
return { startDate, dayCount: limit }
}
}
const onScroll = () => {
if (viewport.value) {
scrollTop.value = viewport.value.scrollTop
}
}
const handleJogwheelScrollTo = (newScrollTop) => {
if (viewport.value) {
viewport.value.scrollTop = newScrollTop
}
}
onMounted(() => {
computeRowHeight()
calendarStore.updateCurrentDate()
if (viewport.value) {
viewportHeight.value = viewport.value.clientHeight
viewport.value.scrollTop = initialScrollTop.value
viewport.value.addEventListener('scroll', onScroll)
}
const timer = setInterval(() => {
calendarStore.updateCurrentDate()
}, 60000)
onBeforeUnmount(() => {
clearInterval(timer)
})
})
onBeforeUnmount(() => {
if (viewport.value) {
viewport.value.removeEventListener('scroll', onScroll)
}
})
const handleDayMouseDown = (dateStr) => {
startDrag(dateStr)
}
const handleDayMouseEnter = (dateStr) => {
if (isDragging.value) {
updateDrag(dateStr)
}
}
const handleDayMouseUp = (dateStr) => {
if (isDragging.value) {
endDrag(dateStr)
const eventData = createEventFromSelection()
if (eventData) {
clearSelection()
emit('create-event', eventData)
}
}
}
const handleDayTouchStart = (dateStr) => {
startDrag(dateStr)
}
const handleDayTouchMove = (dateStr) => {
if (isDragging.value) {
updateDrag(dateStr)
}
}
const handleDayTouchEnd = (dateStr) => {
if (isDragging.value) {
endDrag(dateStr)
const eventData = createEventFromSelection()
if (eventData) {
clearSelection()
emit('create-event', eventData)
}
}
}
const handleEventClick = (payload) => {
emit('edit-event', payload)
}
// Handle year change emitted from CalendarHeader: scroll to computed target position
const handleHeaderYearChange = ({ scrollTop: st }) => {
const maxScroll = contentHeight.value - viewportHeight.value
const clamped = Math.max(0, Math.min(st, isFinite(maxScroll) ? maxScroll : st))
scrollTop.value = clamped
viewport.value && (viewport.value.scrollTop = clamped)
}
function openSettings() {
settingsDialog.value?.open()
}
// Preserve approximate top visible date when first_day changes
watch(
() => calendarStore.config.first_day,
() => {
const currentTopVW = Math.floor(scrollTop.value / rowHeight.value) + minVirtualWeek.value
const currentTopDate = getFirstDayForVirtualWeek(currentTopVW)
requestAnimationFrame(() => {
const newTopWeekIndex = getWeekIndex(currentTopDate)
const newScroll = (newTopWeekIndex - minVirtualWeek.value) * rowHeight.value
scrollTop.value = newScroll
if (viewport.value) viewport.value.scrollTop = newScroll
})
},
)
</script>
<template>
<div class="wrap">
<header>
<h1>Calendar</h1>
<div class="header-controls">
<div class="today-date" @click="goToToday">{{ todayString }}</div>
<button
type="button"
class="settings-btn"
@click="openSettings"
aria-label="Open settings"
title="Settings"
>
</button>
</div>
</header>
<CalendarHeader
:scroll-top="scrollTop"
:row-height="rowHeight"
:min-virtual-week="minVirtualWeek"
@year-change="handleHeaderYearChange"
/>
<div class="calendar-container">
<div class="calendar-viewport" ref="viewport">
<div class="calendar-content" :style="{ height: contentHeight + 'px' }">
<CalendarWeek
v-for="week in visibleWeeks"
:key="week.virtualWeek"
:week="week"
:style="{ top: week.top + 'px' }"
@day-mousedown="handleDayMouseDown"
@day-mouseenter="handleDayMouseEnter"
@day-mouseup="handleDayMouseUp"
@day-touchstart="handleDayTouchStart"
@day-touchmove="handleDayTouchMove"
@day-touchend="handleDayTouchEnd"
@event-click="handleEventClick"
/>
<!-- Month labels positioned absolutely -->
<div
v-for="week in visibleWeeks"
:key="`month-${week.virtualWeek}`"
v-show="week.monthLabel"
class="month-name-label"
:style="{
top: week.top + 'px',
height: week.monthLabel?.height + 'px',
}"
>
<span>{{ week.monthLabel?.text }}</span>
</div>
</div>
</div>
<!-- Jogwheel as sibling to calendar-viewport -->
<Jogwheel
:total-virtual-weeks="totalVirtualWeeks"
:row-height="rowHeight"
:viewport-height="viewportHeight"
:scroll-top="scrollTop"
@scroll-to="handleJogwheelScrollTo"
/>
</div>
<SettingsDialog ref="settingsDialog" />
</div>
</template>
<style scoped>
.wrap {
height: 100vh;
display: flex;
flex-direction: column;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
}
header h1 {
margin: 0;
padding: 0;
}
.header-controls {
display: flex;
gap: 1rem;
align-items: center;
}
.settings-btn {
background: transparent;
border: none;
color: var(--muted);
padding: 0;
margin: 0;
margin-right: 0.6rem;
cursor: pointer;
font-size: 1.5rem;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
outline: none;
}
.settings-btn:hover {
color: var(--strong);
}
.settings-btn:focus-visible {
/* Keep visual accessibility without background */
outline: 2px solid var(--selected);
outline-offset: 2px;
}
.settings-btn:active {
transform: scale(0.88);
}
.today-date {
cursor: pointer;
padding: 0.5rem;
background: var(--today-btn-bg);
color: var(--today-btn-text);
border-radius: 4px;
white-space: pre-line;
text-align: center;
font-size: 0.9rem;
}
.today-date:hover {
background: var(--today-btn-hover-bg);
}
.calendar-container {
flex: 1;
display: flex;
position: relative;
/* Prevent text selection in calendar */
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
-webkit-touch-callout: none;
-webkit-tap-highlight-color: transparent;
}
.calendar-viewport {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
position: relative;
}
.calendar-content {
position: relative;
width: 100%;
}
.month-name-label {
position: absolute;
right: 0;
width: 3rem; /* Match jogwheel width */
font-size: 2em;
font-weight: 700;
color: var(--muted);
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
z-index: 15;
overflow: visible;
}
.month-name-label > span {
display: inline-block;
white-space: nowrap;
writing-mode: vertical-rl;
text-orientation: mixed;
transform: rotate(180deg);
transform-origin: center;
}
</style>