Port to Vue, old plain JS implementation removed.
This commit is contained in:
parent
0b6d1a80c1
commit
5da57a5261
@ -1,6 +0,0 @@
|
|||||||
/* Calendar CSS - Main file with imports */
|
|
||||||
@import url('colors.css');
|
|
||||||
@import url('layout.css');
|
|
||||||
@import url('cells.css');
|
|
||||||
@import url('events.css');
|
|
||||||
@import url('utilities.css');
|
|
484
calendar.js
484
calendar.js
@ -1,484 +0,0 @@
|
|||||||
// calendar.js — Infinite scrolling week-by-week with overlay event rendering
|
|
||||||
import {
|
|
||||||
monthAbbr,
|
|
||||||
DAY_MS,
|
|
||||||
WEEK_MS,
|
|
||||||
isoWeekInfo,
|
|
||||||
toLocalString,
|
|
||||||
fromLocalString,
|
|
||||||
mondayIndex,
|
|
||||||
pad,
|
|
||||||
addDaysStr,
|
|
||||||
getLocalizedWeekdayNames,
|
|
||||||
getLocalizedMonthName,
|
|
||||||
formatDateRange,
|
|
||||||
lunarPhaseSymbol
|
|
||||||
} from './date-utils.js'
|
|
||||||
import { EventManager } from './event-manager.js'
|
|
||||||
import { JogwheelManager } from './jogwheel.js'
|
|
||||||
|
|
||||||
class InfiniteCalendar {
|
|
||||||
constructor(config = {}) {
|
|
||||||
this.config = {
|
|
||||||
select_days: 0,
|
|
||||||
min_year: 1900,
|
|
||||||
max_year: 2100,
|
|
||||||
...config
|
|
||||||
}
|
|
||||||
|
|
||||||
this.weekend = [true, false, false, false, false, false, true]
|
|
||||||
|
|
||||||
// Initialize event manager
|
|
||||||
this.eventManager = new EventManager(this)
|
|
||||||
|
|
||||||
this.viewport = document.getElementById('calendar-viewport')
|
|
||||||
this.content = document.getElementById('calendar-content')
|
|
||||||
this.header = document.getElementById('calendar-header')
|
|
||||||
this.selectedDateInput = document.getElementById('selected-date')
|
|
||||||
|
|
||||||
// Initialize jogwheel manager after DOM elements are available
|
|
||||||
this.jogwheelManager = new JogwheelManager(this)
|
|
||||||
|
|
||||||
this.rowHeight = this.computeRowHeight()
|
|
||||||
this.visibleWeeks = new Map()
|
|
||||||
this.baseDate = new Date(2024, 0, 1) // 2024 begins with Monday
|
|
||||||
|
|
||||||
this.init()
|
|
||||||
} init() {
|
|
||||||
this.createHeader()
|
|
||||||
this.setupScrollListener()
|
|
||||||
this.setupYearScroll()
|
|
||||||
this.setupSelectionInput()
|
|
||||||
this.setupCurrentDate()
|
|
||||||
this.setupInitialView()
|
|
||||||
}
|
|
||||||
|
|
||||||
setupInitialView() {
|
|
||||||
const minYearDate = new Date(this.config.min_year, 0, 1)
|
|
||||||
const maxYearLastDay = new Date(this.config.max_year, 11, 31)
|
|
||||||
const lastWeekMonday = new Date(maxYearLastDay)
|
|
||||||
lastWeekMonday.setDate(maxYearLastDay.getDate() - mondayIndex(maxYearLastDay))
|
|
||||||
|
|
||||||
this.minVirtualWeek = this.getWeekIndex(minYearDate)
|
|
||||||
this.maxVirtualWeek = this.getWeekIndex(lastWeekMonday)
|
|
||||||
this.totalVirtualWeeks = this.maxVirtualWeek - this.minVirtualWeek + 1
|
|
||||||
|
|
||||||
this.content.style.height = `${this.totalVirtualWeeks * this.rowHeight}px`
|
|
||||||
this.jogwheelManager.updateHeight(this.totalVirtualWeeks, this.rowHeight)
|
|
||||||
const initial = this.config.initial || this.today
|
|
||||||
this.navigateTo(fromLocalString(initial))
|
|
||||||
}
|
|
||||||
|
|
||||||
setupCurrentDate() {
|
|
||||||
const updateDate = () => {
|
|
||||||
this.now = new Date()
|
|
||||||
const today = toLocalString(this.now)
|
|
||||||
if (this.today === today) return
|
|
||||||
this.today = today
|
|
||||||
for (const cell of this.content.querySelectorAll('.cell.today')) cell.classList.remove('today')
|
|
||||||
const cell = this.content.querySelector(`.cell[data-date="${this.today}"]`)
|
|
||||||
if (cell) cell.classList.add('today')
|
|
||||||
const todayDateElement = document.getElementById('today-date')
|
|
||||||
const t = this.now.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric'}).replace(/,? /, "\n")
|
|
||||||
todayDateElement.textContent = t.charAt(0).toUpperCase() + t.slice(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const todayDateElement = document.getElementById('today-date')
|
|
||||||
todayDateElement.addEventListener('click', () => this.goToToday())
|
|
||||||
// Day selection drag functionality is handled through cell event handlers in EventManager
|
|
||||||
updateDate()
|
|
||||||
setInterval(updateDate, 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
setupYearScroll() {
|
|
||||||
let throttled = false
|
|
||||||
const handleWheel = e => {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
const currentYear = parseInt(this.yearLabel.textContent)
|
|
||||||
const topDisplayIndex = Math.floor(this.viewport.scrollTop / this.rowHeight)
|
|
||||||
const currentWeekIndex = topDisplayIndex + this.minVirtualWeek
|
|
||||||
const sensitivity = 1/3
|
|
||||||
const delta = Math.round(e.deltaY * sensitivity)
|
|
||||||
if (!delta) return
|
|
||||||
const newYear = Math.max(this.config.min_year, Math.min(this.config.max_year, currentYear + delta))
|
|
||||||
if (newYear === currentYear || throttled) return
|
|
||||||
throttled = true
|
|
||||||
this.navigateToYear(newYear, currentWeekIndex)
|
|
||||||
setTimeout(() => throttled = false, 100)
|
|
||||||
}
|
|
||||||
this.yearLabel.addEventListener('wheel', handleWheel, { passive: false })
|
|
||||||
}
|
|
||||||
|
|
||||||
navigateTo(date) {
|
|
||||||
const targetWeekIndex = this.getWeekIndex(date) - 3
|
|
||||||
return this.scrollToTarget(targetWeekIndex)
|
|
||||||
}
|
|
||||||
|
|
||||||
navigateToYear(targetYear, weekIndex) {
|
|
||||||
const monday = this.getMondayForVirtualWeek(weekIndex)
|
|
||||||
const { week } = isoWeekInfo(monday)
|
|
||||||
const jan4 = new Date(targetYear, 0, 4)
|
|
||||||
const jan4Monday = new Date(jan4)
|
|
||||||
jan4Monday.setDate(jan4.getDate() - mondayIndex(jan4))
|
|
||||||
const targetMonday = new Date(jan4Monday)
|
|
||||||
targetMonday.setDate(jan4Monday.getDate() + (week - 1) * 7)
|
|
||||||
this.scrollToTarget(targetMonday)
|
|
||||||
}
|
|
||||||
|
|
||||||
scrollToTarget(target, options = {}) {
|
|
||||||
const { smooth = false, forceUpdate = true, allowFloating = false } = options
|
|
||||||
let targetWeekIndex
|
|
||||||
if (target instanceof Date) {
|
|
||||||
if (!this.isDateInAllowedRange(target)) return false
|
|
||||||
targetWeekIndex = this.getWeekIndex(target)
|
|
||||||
} else if (typeof target === 'number') {
|
|
||||||
targetWeekIndex = target
|
|
||||||
} else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (allowFloating) {
|
|
||||||
if (targetWeekIndex < this.minVirtualWeek - 0.5 || targetWeekIndex > this.maxVirtualWeek + 0.5) return false
|
|
||||||
} else {
|
|
||||||
if (targetWeekIndex < this.minVirtualWeek || targetWeekIndex > this.maxVirtualWeek) return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetScrollTop = (targetWeekIndex - this.minVirtualWeek) * this.rowHeight
|
|
||||||
|
|
||||||
if (smooth) this.viewport.scrollTo({ top: targetScrollTop, behavior: 'smooth' })
|
|
||||||
else this.viewport.scrollTop = targetScrollTop
|
|
||||||
|
|
||||||
const mainScrollable = Math.max(0, this.content.scrollHeight - this.viewport.clientHeight)
|
|
||||||
this.jogwheelManager.scrollTo(targetScrollTop, mainScrollable, smooth)
|
|
||||||
|
|
||||||
if (forceUpdate) this.updateVisibleWeeks()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
|
||||||
return Math.round(h)
|
|
||||||
}
|
|
||||||
|
|
||||||
createHeader() {
|
|
||||||
this.yearLabel = document.createElement('div')
|
|
||||||
this.yearLabel.className = 'year-label'
|
|
||||||
this.yearLabel.textContent = isoWeekInfo(new Date()).year
|
|
||||||
this.header.appendChild(this.yearLabel)
|
|
||||||
|
|
||||||
const names = getLocalizedWeekdayNames()
|
|
||||||
names.forEach((name, i) => {
|
|
||||||
const c = document.createElement('div')
|
|
||||||
c.classList.add('dow')
|
|
||||||
const dayIdx = (i + 1) % 7
|
|
||||||
if (this.weekend[dayIdx]) c.classList.add('weekend')
|
|
||||||
c.textContent = name
|
|
||||||
this.header.appendChild(c)
|
|
||||||
})
|
|
||||||
|
|
||||||
const spacer = document.createElement('div')
|
|
||||||
spacer.className = 'overlay-header-spacer'
|
|
||||||
this.header.appendChild(spacer)
|
|
||||||
}
|
|
||||||
|
|
||||||
getWeekIndex(date) {
|
|
||||||
const monday = new Date(date)
|
|
||||||
monday.setDate(date.getDate() - mondayIndex(date))
|
|
||||||
return Math.floor((monday - this.baseDate) / WEEK_MS)
|
|
||||||
}
|
|
||||||
|
|
||||||
getMondayForVirtualWeek(virtualWeek) {
|
|
||||||
const monday = new Date(this.baseDate)
|
|
||||||
monday.setDate(monday.getDate() + virtualWeek * 7)
|
|
||||||
return monday
|
|
||||||
}
|
|
||||||
|
|
||||||
isDateInAllowedRange(date) {
|
|
||||||
const y = date.getFullYear()
|
|
||||||
return y >= this.config.min_year && y <= this.config.max_year
|
|
||||||
}
|
|
||||||
|
|
||||||
setupScrollListener() {
|
|
||||||
let t
|
|
||||||
this.viewport.addEventListener('scroll', () => {
|
|
||||||
this.updateVisibleWeeks()
|
|
||||||
clearTimeout(t)
|
|
||||||
t = setTimeout(() => this.updateVisibleWeeks(), 8)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
updateVisibleWeeks() {
|
|
||||||
const scrollTop = this.viewport.scrollTop
|
|
||||||
const viewportH = this.viewport.clientHeight
|
|
||||||
|
|
||||||
this.updateYearLabel(scrollTop)
|
|
||||||
|
|
||||||
const buffer = 10
|
|
||||||
const startIdx = Math.floor((scrollTop - buffer * this.rowHeight) / this.rowHeight)
|
|
||||||
const endIdx = Math.ceil((scrollTop + viewportH + buffer * this.rowHeight) / this.rowHeight)
|
|
||||||
|
|
||||||
const startVW = Math.max(this.minVirtualWeek, startIdx + this.minVirtualWeek)
|
|
||||||
const endVW = Math.min(this.maxVirtualWeek, endIdx + this.minVirtualWeek)
|
|
||||||
|
|
||||||
for (const [vw, el] of this.visibleWeeks) {
|
|
||||||
if (vw < startVW || vw > endVW) {
|
|
||||||
el.remove()
|
|
||||||
this.visibleWeeks.delete(vw)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let vw = startVW; vw <= endVW; vw++) {
|
|
||||||
if (this.visibleWeeks.has(vw)) continue
|
|
||||||
const weekEl = this.createWeekElement(vw)
|
|
||||||
weekEl.style.position = 'absolute'
|
|
||||||
weekEl.style.left = '0'
|
|
||||||
const displayIndex = vw - this.minVirtualWeek
|
|
||||||
weekEl.style.top = `${displayIndex * this.rowHeight}px`
|
|
||||||
weekEl.style.width = '100%'
|
|
||||||
weekEl.style.height = `${this.rowHeight}px`
|
|
||||||
this.content.appendChild(weekEl)
|
|
||||||
this.visibleWeeks.set(vw, weekEl)
|
|
||||||
this.addEventsToWeek(weekEl, vw)
|
|
||||||
}
|
|
||||||
|
|
||||||
this.eventManager.applySelectionToVisible()
|
|
||||||
}
|
|
||||||
|
|
||||||
updateYearLabel(scrollTop) {
|
|
||||||
const topDisplayIndex = Math.floor(scrollTop / this.rowHeight)
|
|
||||||
const topVW = topDisplayIndex + this.minVirtualWeek
|
|
||||||
const monday = this.getMondayForVirtualWeek(topVW)
|
|
||||||
const { year } = isoWeekInfo(monday)
|
|
||||||
if (this.yearLabel.textContent !== String(year)) this.yearLabel.textContent = year
|
|
||||||
}
|
|
||||||
|
|
||||||
createWeekElement(virtualWeek) {
|
|
||||||
const weekDiv = document.createElement('div')
|
|
||||||
weekDiv.className = 'week-row'
|
|
||||||
const monday = this.getMondayForVirtualWeek(virtualWeek)
|
|
||||||
|
|
||||||
const wkLabel = document.createElement('div')
|
|
||||||
wkLabel.className = 'week-label'
|
|
||||||
wkLabel.textContent = `W${pad(isoWeekInfo(monday).week)}`
|
|
||||||
weekDiv.appendChild(wkLabel)
|
|
||||||
|
|
||||||
// days grid container to host cells and overlay
|
|
||||||
const daysGrid = document.createElement('div')
|
|
||||||
daysGrid.className = 'days-grid'
|
|
||||||
daysGrid.style.position = 'relative'
|
|
||||||
daysGrid.style.display = 'grid'
|
|
||||||
daysGrid.style.gridTemplateColumns = 'repeat(7, 1fr)'
|
|
||||||
daysGrid.style.gridAutoRows = '1fr'
|
|
||||||
daysGrid.style.height = '100%'
|
|
||||||
daysGrid.style.width = '100%'
|
|
||||||
weekDiv.appendChild(daysGrid)
|
|
||||||
|
|
||||||
// overlay positioned above cells, same 7-col grid
|
|
||||||
const overlay = document.createElement('div')
|
|
||||||
overlay.className = 'week-overlay'
|
|
||||||
overlay.style.position = 'absolute'
|
|
||||||
overlay.style.inset = '0'
|
|
||||||
overlay.style.pointerEvents = 'none'
|
|
||||||
overlay.style.display = 'grid'
|
|
||||||
overlay.style.gridTemplateColumns = 'repeat(7, 1fr)'
|
|
||||||
overlay.style.gridAutoRows = '1fr'
|
|
||||||
overlay.style.zIndex = '15'
|
|
||||||
daysGrid.appendChild(overlay)
|
|
||||||
weekDiv._overlay = overlay
|
|
||||||
weekDiv._daysGrid = daysGrid
|
|
||||||
|
|
||||||
const cur = new Date(monday)
|
|
||||||
let hasFirst = false
|
|
||||||
let monthToLabel = null
|
|
||||||
let labelYear = null
|
|
||||||
|
|
||||||
for (let i = 0; i < 7; i++) {
|
|
||||||
const cell = document.createElement('div')
|
|
||||||
cell.className = 'cell'
|
|
||||||
const dateStr = toLocalString(cur)
|
|
||||||
cell.setAttribute('data-date', dateStr)
|
|
||||||
|
|
||||||
const dow = cur.getDay()
|
|
||||||
if (this.weekend[dow]) cell.classList.add('weekend')
|
|
||||||
|
|
||||||
const m = cur.getMonth()
|
|
||||||
cell.classList.add(monthAbbr[m])
|
|
||||||
|
|
||||||
const isFirst = cur.getDate() === 1
|
|
||||||
if (isFirst) {
|
|
||||||
hasFirst = true
|
|
||||||
monthToLabel = m
|
|
||||||
labelYear = cur.getFullYear()
|
|
||||||
}
|
|
||||||
|
|
||||||
const day = document.createElement('h1')
|
|
||||||
day.textContent = String(cur.getDate())
|
|
||||||
|
|
||||||
const date = toLocalString(cur)
|
|
||||||
cell.dataset.date = date
|
|
||||||
if (this.today && date === this.today) cell.classList.add('today')
|
|
||||||
|
|
||||||
if (this.config.select_days > 0) {
|
|
||||||
cell.addEventListener('mousedown', e => {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
this.eventManager.startDrag(dateStr)
|
|
||||||
})
|
|
||||||
cell.addEventListener('touchstart', e => {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
this.eventManager.startDrag(dateStr)
|
|
||||||
})
|
|
||||||
cell.addEventListener('mouseenter', () => {
|
|
||||||
if (this.eventManager.isDragging) this.eventManager.updateDrag(dateStr)
|
|
||||||
})
|
|
||||||
cell.addEventListener('mouseup', e => {
|
|
||||||
e.stopPropagation()
|
|
||||||
if (this.eventManager.isDragging) this.eventManager.endDrag(dateStr)
|
|
||||||
})
|
|
||||||
cell.addEventListener('touchmove', e => {
|
|
||||||
if (this.eventManager.isDragging) {
|
|
||||||
e.preventDefault()
|
|
||||||
const touch = e.touches[0]
|
|
||||||
const elementBelow = document.elementFromPoint(touch.clientX, touch.clientY)
|
|
||||||
if (elementBelow && elementBelow.closest('.cell[data-date]')) {
|
|
||||||
const cellBelow = elementBelow.closest('.cell[data-date]')
|
|
||||||
const touchDateStr = cellBelow.dataset.date
|
|
||||||
if (touchDateStr) this.eventManager.updateDrag(touchDateStr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
cell.addEventListener('touchend', e => {
|
|
||||||
e.stopPropagation()
|
|
||||||
if (this.eventManager.isDragging) this.eventManager.endDrag(dateStr)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isFirst) {
|
|
||||||
cell.classList.add('firstday')
|
|
||||||
day.textContent = cur.getMonth() ? monthAbbr[m].slice(0,3).toUpperCase() : cur.getFullYear()
|
|
||||||
}
|
|
||||||
|
|
||||||
cell.appendChild(day)
|
|
||||||
const luna = lunarPhaseSymbol(cur)
|
|
||||||
if (luna) {
|
|
||||||
const moon = document.createElement('span')
|
|
||||||
moon.className = 'lunar-phase'
|
|
||||||
moon.textContent = luna
|
|
||||||
cell.appendChild(moon)
|
|
||||||
}
|
|
||||||
daysGrid.appendChild(cell)
|
|
||||||
cur.setDate(cur.getDate() + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasFirst && monthToLabel !== null) {
|
|
||||||
if (labelYear && labelYear > this.config.max_year) return weekDiv
|
|
||||||
const overlayCell = document.createElement('div')
|
|
||||||
overlayCell.className = 'month-name-label'
|
|
||||||
|
|
||||||
let weeksSpan = 0
|
|
||||||
const d = new Date(cur)
|
|
||||||
d.setDate(cur.getDate() - 1)
|
|
||||||
for (let i = 0; i < 6; i++) {
|
|
||||||
d.setDate(cur.getDate() - 1 + i * 7)
|
|
||||||
if (d.getMonth() === monthToLabel) weeksSpan++
|
|
||||||
}
|
|
||||||
|
|
||||||
const remainingWeeks = Math.max(1, this.maxVirtualWeek - virtualWeek + 1)
|
|
||||||
weeksSpan = Math.max(1, Math.min(weeksSpan, remainingWeeks))
|
|
||||||
|
|
||||||
overlayCell.style.height = `${weeksSpan * this.rowHeight}px`
|
|
||||||
overlayCell.style.display = 'flex'
|
|
||||||
overlayCell.style.alignItems = 'center'
|
|
||||||
overlayCell.style.justifyContent = 'center'
|
|
||||||
overlayCell.style.zIndex = '15'
|
|
||||||
overlayCell.style.pointerEvents = 'none'
|
|
||||||
|
|
||||||
const label = document.createElement('span')
|
|
||||||
const year = String((labelYear ?? monday.getFullYear())).slice(-2)
|
|
||||||
label.textContent = `${getLocalizedMonthName(monthToLabel)} '${year}`
|
|
||||||
overlayCell.appendChild(label)
|
|
||||||
weekDiv.appendChild(overlayCell)
|
|
||||||
weekDiv.style.zIndex = '18'
|
|
||||||
}
|
|
||||||
|
|
||||||
return weekDiv
|
|
||||||
}
|
|
||||||
|
|
||||||
setupSelectionInput() {
|
|
||||||
if (this.config.select_days === 0) {
|
|
||||||
this.selectedDateInput.style.display = 'none'
|
|
||||||
} else {
|
|
||||||
this.selectedDateInput.style.display = 'block'
|
|
||||||
this.selectedDateInput.classList.add('clean-input')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
goToToday() {
|
|
||||||
const top = new Date(this.now)
|
|
||||||
top.setDate(top.getDate() - 21)
|
|
||||||
this.scrollToTarget(top, { smooth: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------- Event Rendering (overlay-based) --------
|
|
||||||
|
|
||||||
refreshEvents() {
|
|
||||||
for (const [, weekEl] of this.visibleWeeks) {
|
|
||||||
this.addEventsToWeek(weekEl)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
forceUpdateVisibleWeeks() {
|
|
||||||
// Force complete re-render of all visible weeks by clearing overlays first
|
|
||||||
for (const [, weekEl] of this.visibleWeeks) {
|
|
||||||
const overlay = weekEl._overlay || weekEl.querySelector('.week-overlay')
|
|
||||||
if (overlay) {
|
|
||||||
while (overlay.firstChild) overlay.removeChild(overlay.firstChild)
|
|
||||||
}
|
|
||||||
this.addEventsToWeek(weekEl)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
addEventsToWeek(weekEl) {
|
|
||||||
this.eventManager.addEventsToWeek(weekEl)
|
|
||||||
}
|
|
||||||
|
|
||||||
getDateUnderPointer(clientX, clientY) {
|
|
||||||
const el = document.elementFromPoint(clientX, clientY)
|
|
||||||
if (!el) return null
|
|
||||||
// Fast path: directly find the cell under the pointer
|
|
||||||
const directCell = el.closest && el.closest('.cell[data-date]')
|
|
||||||
if (directCell) {
|
|
||||||
const weekEl = directCell.closest('.week-row')
|
|
||||||
return weekEl ? { weekEl, overlay: (weekEl._overlay || weekEl.querySelector('.week-overlay')), col: -1, date: directCell.dataset.date } : null
|
|
||||||
}
|
|
||||||
const weekEl = el.closest && el.closest('.week-row')
|
|
||||||
if (!weekEl) return null
|
|
||||||
const overlay = weekEl._overlay || weekEl.querySelector('.week-overlay')
|
|
||||||
const daysGrid = weekEl._daysGrid || weekEl.querySelector('.days-grid')
|
|
||||||
if (!overlay || !daysGrid) return null
|
|
||||||
const rect = overlay.getBoundingClientRect()
|
|
||||||
if (rect.width <= 0) return null
|
|
||||||
const colWidth = rect.width / 7
|
|
||||||
let col = Math.floor((clientX - rect.left) / colWidth)
|
|
||||||
if (clientX < rect.left) col = 0
|
|
||||||
if (clientX > rect.right) col = 6
|
|
||||||
col = Math.max(0, Math.min(6, col))
|
|
||||||
const cells = Array.from(daysGrid.querySelectorAll('.cell[data-date]'))
|
|
||||||
const cell = cells[col]
|
|
||||||
return cell ? { weekEl, overlay, col, date: cell.dataset.date } : null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
new InfiniteCalendar({
|
|
||||||
select_days: 1000
|
|
||||||
})
|
|
||||||
})
|
|
38
cells.css
38
cells.css
@ -1,38 +0,0 @@
|
|||||||
/* Day cells */
|
|
||||||
.dow { text-transform: uppercase }
|
|
||||||
.cell {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: flex-start;
|
|
||||||
padding: .25em;
|
|
||||||
overflow: hidden;
|
|
||||||
width: 100%;
|
|
||||||
height: var(--cell-h);
|
|
||||||
font-weight: 700;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color .15s ease;
|
|
||||||
}
|
|
||||||
.cell h1 {
|
|
||||||
top: .25em;
|
|
||||||
right: .25em;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
min-width: 1.5em;
|
|
||||||
transition: background-color .15s ease;
|
|
||||||
font-size: 1em;
|
|
||||||
}
|
|
||||||
.cell.today h1 {
|
|
||||||
border-radius: 2em;
|
|
||||||
background: var(--today);
|
|
||||||
border: .2em solid var(--today);
|
|
||||||
margin: -.2em;
|
|
||||||
}
|
|
||||||
.cell:hover h1 { text-shadow: 0 0 .2em var(--shadow); }
|
|
||||||
|
|
||||||
/* Fixed heights for cells and labels */
|
|
||||||
.week-row .cell, .week-row .week-label { height: var(--cell-h) }
|
|
||||||
|
|
||||||
.weekend { color: var(--weekend) }
|
|
||||||
.firstday { color: var(--firstday); text-shadow: 0 0 .1em var(--strong); }
|
|
1030
event-manager.js
1030
event-manager.js
File diff suppressed because it is too large
Load Diff
69
events.css
69
events.css
@ -1,69 +0,0 @@
|
|||||||
.event-span {
|
|
||||||
font-size: clamp(.45em, 1.8vh, .75em);
|
|
||||||
padding: 0 .5em;
|
|
||||||
border-radius: .4em;
|
|
||||||
color: var(--strong);
|
|
||||||
font-weight: 600;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
line-height: 1; /* let the track height define the visual height */
|
|
||||||
height: 100%; /* fill the grid row height */
|
|
||||||
align-self: stretch; /* fill row vertically */
|
|
||||||
justify-self: stretch; /* stretch across chosen grid columns */
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
pointer-events: auto; /* clickable despite overlay having none */
|
|
||||||
z-index: 1;
|
|
||||||
position: relative;
|
|
||||||
cursor: grab;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Selection styles */
|
|
||||||
.cell.selected {
|
|
||||||
background: var(--select);
|
|
||||||
box-shadow: 0 0 .1em var(--muted) inset;
|
|
||||||
}
|
|
||||||
.cell.selected .event { opacity: .7 }
|
|
||||||
|
|
||||||
/* Dragging state */
|
|
||||||
.event-span.dragging {
|
|
||||||
opacity: .9;
|
|
||||||
cursor: grabbing;
|
|
||||||
z-index: 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Resize handles */
|
|
||||||
.event-span .resize-handle {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
width: 6px;
|
|
||||||
background: transparent;
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
.event-span .resize-handle.left {
|
|
||||||
left: 0;
|
|
||||||
cursor: ew-resize;
|
|
||||||
}
|
|
||||||
.event-span .resize-handle.right {
|
|
||||||
right: 0;
|
|
||||||
cursor: ew-resize;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Live preview ghost while dragging */
|
|
||||||
.event-preview {
|
|
||||||
pointer-events: none;
|
|
||||||
opacity: .6;
|
|
||||||
outline: 2px dashed currentColor;
|
|
||||||
outline-offset: -2px;
|
|
||||||
border-radius: .4em;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: clamp(.45em, 1.8vh, .75em);
|
|
||||||
line-height: 1;
|
|
||||||
height: 100%;
|
|
||||||
z-index: 3;
|
|
||||||
}
|
|
23
index.html
23
index.html
@ -4,26 +4,9 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>Calendar</title>
|
<title>Calendar</title>
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<link rel="stylesheet" href="calendar.css">
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="wrap">
|
<div id="app"></div>
|
||||||
<header>
|
<script type="module" src="/src/main.js"></script>
|
||||||
<h1 id="title">Calendar</h1>
|
|
||||||
<div class="header-controls">
|
|
||||||
<input type="text" id="selected-date" class="date-input" readonly>
|
|
||||||
<div id="today-date" class="today-date"></div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<div class="calendar-header" id="calendar-header"></div>
|
|
||||||
<div class="calendar-container" id="calendar-container">
|
|
||||||
<div class="calendar-viewport" id="calendar-viewport">
|
|
||||||
<div class="calendar-content" id="calendar-content"></div>
|
|
||||||
</div>
|
|
||||||
<div class="jogwheel-viewport" id="jogwheel-viewport">
|
|
||||||
<div class="jogwheel-content" id="jogwheel-content"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<script type="module" src="calendar.js"></script>
|
|
||||||
</div>
|
|
||||||
</body>
|
</body>
|
||||||
|
</html>
|
||||||
|
79
jogwheel.js
79
jogwheel.js
@ -1,79 +0,0 @@
|
|||||||
// jogwheel.js — Jogwheel synchronization for calendar scrolling
|
|
||||||
|
|
||||||
export class JogwheelManager {
|
|
||||||
constructor(calendar) {
|
|
||||||
this.calendar = calendar
|
|
||||||
this.viewport = null
|
|
||||||
this.content = null
|
|
||||||
this.syncLock = null
|
|
||||||
this.init()
|
|
||||||
}
|
|
||||||
|
|
||||||
init() {
|
|
||||||
this.viewport = document.getElementById('jogwheel-viewport')
|
|
||||||
this.content = document.getElementById('jogwheel-content')
|
|
||||||
|
|
||||||
if (!this.viewport || !this.content) {
|
|
||||||
console.warn('Jogwheel elements not found - jogwheel functionality disabled')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
this.setupScrollSync()
|
|
||||||
}
|
|
||||||
|
|
||||||
setupScrollSync() {
|
|
||||||
// Check if calendar viewport is available
|
|
||||||
if (!this.calendar.viewport || !this.calendar.content) {
|
|
||||||
console.warn('Calendar viewport not available - jogwheel sync disabled')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bind sync function to maintain proper context
|
|
||||||
const sync = this.sync.bind(this)
|
|
||||||
|
|
||||||
this.viewport.addEventListener('scroll', () =>
|
|
||||||
sync(this.viewport, this.calendar.viewport, this.content, this.calendar.content)
|
|
||||||
)
|
|
||||||
|
|
||||||
this.calendar.viewport.addEventListener('scroll', () =>
|
|
||||||
sync(this.calendar.viewport, this.viewport, this.calendar.content, this.content)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
sync(fromEl, toEl, fromContent, toContent) {
|
|
||||||
if (this.syncLock === toEl) return
|
|
||||||
this.syncLock = fromEl
|
|
||||||
|
|
||||||
const fromScrollable = Math.max(0, fromContent.scrollHeight - fromEl.clientHeight)
|
|
||||||
const toScrollable = Math.max(0, toContent.scrollHeight - toEl.clientHeight)
|
|
||||||
const ratio = fromScrollable > 0 ? fromEl.scrollTop / fromScrollable : 0
|
|
||||||
toEl.scrollTop = ratio * toScrollable
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
if (this.syncLock === fromEl) this.syncLock = null
|
|
||||||
}, 50)
|
|
||||||
}
|
|
||||||
|
|
||||||
updateHeight(totalVirtualWeeks, rowHeight) {
|
|
||||||
if (this.content) {
|
|
||||||
this.content.style.height = `${(totalVirtualWeeks * rowHeight) / 10}px`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
scrollTo(targetScrollTop, mainScrollable, smooth = false) {
|
|
||||||
if (!this.viewport || !this.content) return
|
|
||||||
|
|
||||||
const jogScrollable = Math.max(0, this.content.scrollHeight - this.viewport.clientHeight)
|
|
||||||
const jogwheelTarget = mainScrollable > 0 ? (targetScrollTop / mainScrollable) * jogScrollable : 0
|
|
||||||
|
|
||||||
if (smooth) {
|
|
||||||
this.viewport.scrollTo({ top: jogwheelTarget, behavior: 'smooth' })
|
|
||||||
} else {
|
|
||||||
this.viewport.scrollTop = jogwheelTarget
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
isAvailable() {
|
|
||||||
return !!(this.viewport && this.content)
|
|
||||||
}
|
|
||||||
}
|
|
10
src/App.vue
10
src/App.vue
@ -1,11 +1,9 @@
|
|||||||
<script setup></script>
|
<script setup>
|
||||||
|
import CalendarView from './components/CalendarView.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<h1>You did it!</h1>
|
<CalendarView />
|
||||||
<p>
|
|
||||||
Visit <a href="https://vuejs.org/" target="_blank" rel="noopener">vuejs.org</a> to read the
|
|
||||||
documentation
|
|
||||||
</p>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped></style>
|
||||||
|
3
src/assets/calendar.css
Normal file
3
src/assets/calendar.css
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
/* Calendar CSS - Main file with imports */
|
||||||
|
@import url('./colors.css');
|
||||||
|
@import url('./layout.css');
|
@ -11,6 +11,10 @@
|
|||||||
--shadow: #fff;
|
--shadow: #fff;
|
||||||
--label-bg: #fafbfe;
|
--label-bg: #fafbfe;
|
||||||
--label-bg-rgb: 250, 251, 254;
|
--label-bg-rgb: 250, 251, 254;
|
||||||
|
|
||||||
|
/* Vue component color mappings */
|
||||||
|
--bg: var(--panel);
|
||||||
|
--border-color: #ddd;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Month tints (light) */
|
/* Month tints (light) */
|
||||||
@ -40,7 +44,7 @@
|
|||||||
/* Color tokens (dark) */
|
/* Color tokens (dark) */
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root {
|
:root {
|
||||||
--panel: #111318;
|
--panel: #000;
|
||||||
--today: #f83;
|
--today: #f83;
|
||||||
--ink: #ddd;
|
--ink: #ddd;
|
||||||
--strong: #fff;
|
--strong: #fff;
|
||||||
@ -51,6 +55,10 @@
|
|||||||
--shadow: #888;
|
--shadow: #888;
|
||||||
--label-bg: #1a1d25;
|
--label-bg: #1a1d25;
|
||||||
--label-bg-rgb: 26, 29, 37;
|
--label-bg-rgb: 26, 29, 37;
|
||||||
|
|
||||||
|
/* Vue component color mappings (dark) */
|
||||||
|
--bg: var(--panel);
|
||||||
|
--border-color: #333;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dec { background: hsl(220 50% 8%) }
|
.dec { background: hsl(220 50% 8%) }
|
@ -18,17 +18,6 @@ body {
|
|||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
.wrap {
|
|
||||||
width: 100%;
|
|
||||||
margin: 0;
|
|
||||||
background: var(--panel);
|
|
||||||
height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 1rem;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
header {
|
header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
@ -129,19 +118,6 @@ header {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Overlay sitting above the day cells, same 7-col grid */
|
|
||||||
.week-row > .days-grid > .week-overlay {
|
|
||||||
margin-top: 1.5em;
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(7, 1fr);
|
|
||||||
grid-auto-rows: 1fr;
|
|
||||||
row-gap: 0; /* eliminate gaps so space is fully usable */
|
|
||||||
z-index: 15;
|
|
||||||
}
|
|
||||||
|
|
||||||
.month-name-label {
|
.month-name-label {
|
||||||
grid-column: -2 / -1;
|
grid-column: -2 / -1;
|
||||||
font-size: 2em;
|
font-size: 2em;
|
35
src/components/Calendar.vue
Normal file
35
src/components/Calendar.vue
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
<template>
|
||||||
|
<div class="wrap">
|
||||||
|
<AppHeader />
|
||||||
|
<div class="calendar-container" ref="containerEl">
|
||||||
|
<CalendarGrid />
|
||||||
|
<Jogwheel />
|
||||||
|
</div>
|
||||||
|
<EventDialog />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||||
|
import AppHeader from './AppHeader.vue'
|
||||||
|
import CalendarGrid from './CalendarGrid.vue'
|
||||||
|
import Jogwheel from './Jogwheel.vue'
|
||||||
|
import EventDialog from './EventDialog.vue'
|
||||||
|
import { useCalendarStore } from '@/stores/CalendarStore'
|
||||||
|
|
||||||
|
const calendarStore = useCalendarStore()
|
||||||
|
const containerEl = ref(null)
|
||||||
|
|
||||||
|
let intervalId
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
calendarStore.setToday()
|
||||||
|
intervalId = setInterval(() => {
|
||||||
|
calendarStore.setToday()
|
||||||
|
}, 1000)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
clearInterval(intervalId)
|
||||||
|
})
|
||||||
|
</script>
|
131
src/components/CalendarDay.vue
Normal file
131
src/components/CalendarDay.vue
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
day: Object
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['event-click'])
|
||||||
|
|
||||||
|
const handleEventClick = (eventId) => {
|
||||||
|
emit('event-click', eventId)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="cell"
|
||||||
|
:class="[
|
||||||
|
props.day.monthClass,
|
||||||
|
{
|
||||||
|
today: props.day.isToday,
|
||||||
|
weekend: props.day.isWeekend,
|
||||||
|
firstday: props.day.isFirstDay,
|
||||||
|
selected: props.day.isSelected
|
||||||
|
}
|
||||||
|
]"
|
||||||
|
:data-date="props.day.date"
|
||||||
|
>
|
||||||
|
<h1>{{ props.day.displayText }}</h1>
|
||||||
|
<span v-if="props.day.lunarPhase" class="lunar-phase">{{ props.day.lunarPhase }}</span>
|
||||||
|
|
||||||
|
<!-- Simple event display for now -->
|
||||||
|
<div v-if="props.day.events && props.day.events.length > 0" class="day-events">
|
||||||
|
<div
|
||||||
|
v-for="event in props.day.events.slice(0, 3)"
|
||||||
|
:key="event.id"
|
||||||
|
class="event-dot"
|
||||||
|
:class="`event-color-${event.colorId}`"
|
||||||
|
:title="event.title"
|
||||||
|
@click.stop="handleEventClick(event.id)"
|
||||||
|
></div>
|
||||||
|
<div v-if="props.day.events.length > 3" class="event-more">+{{ props.day.events.length - 3 }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.cell {
|
||||||
|
position: relative;
|
||||||
|
border-right: 1px solid var(--border-color);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
user-select: none;
|
||||||
|
touch-action: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding: 0.25em;
|
||||||
|
overflow: hidden;
|
||||||
|
width: 100%;
|
||||||
|
height: var(--cell-h);
|
||||||
|
font-weight: 700;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell h1 {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
min-width: 1.5em;
|
||||||
|
font-size: 1em;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ink);
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell.today h1 {
|
||||||
|
border-radius: 2em;
|
||||||
|
background: var(--today);
|
||||||
|
border: 0.2em solid var(--today);
|
||||||
|
margin: -0.2em;
|
||||||
|
color: white;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell:hover h1 {
|
||||||
|
text-shadow: 0 0 0.2em var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell.weekend h1 {
|
||||||
|
color: var(--weekend);
|
||||||
|
}
|
||||||
|
.cell.firstday h1 {
|
||||||
|
color: var(--firstday);
|
||||||
|
text-shadow: 0 0 0.1em var(--strong);
|
||||||
|
}
|
||||||
|
.cell.selected {
|
||||||
|
filter: brightness(0.8);
|
||||||
|
}
|
||||||
|
.cell.selected h1 {
|
||||||
|
color: var(--strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lunar-phase {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
right: 2px;
|
||||||
|
font-size: 10px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-events {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 2px;
|
||||||
|
left: 2px;
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-color-0 { background: var(--event-color-0); }
|
||||||
|
.event-color-1 { background: var(--event-color-1); }
|
||||||
|
.event-color-2 { background: var(--event-color-2); }
|
||||||
|
.event-color-3 { background: var(--event-color-3); }
|
||||||
|
.event-color-4 { background: var(--event-color-4); }
|
||||||
|
.event-color-5 { background: var(--event-color-5); }
|
||||||
|
.event-color-6 { background: var(--event-color-6); }
|
||||||
|
.event-color-7 { background: var(--event-color-7); }
|
||||||
|
|
||||||
|
.event-more {
|
||||||
|
font-size: 8px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
</style>
|
169
src/components/CalendarGrid.vue
Normal file
169
src/components/CalendarGrid.vue
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
<template>
|
||||||
|
<div class="calendar-header">
|
||||||
|
<div class="year-label" @wheel.prevent="handleWheel">{{ calendarStore.viewYear }}</div>
|
||||||
|
<div v-for="day in weekdayNames" :key="day" class="dow" :class="{ weekend: isWeekend(day) }">
|
||||||
|
{{ day }}
|
||||||
|
</div>
|
||||||
|
<div class="overlay-header-spacer"></div>
|
||||||
|
</div>
|
||||||
|
<div class="calendar-viewport" ref="viewportEl" @scroll="handleScroll">
|
||||||
|
<div class="calendar-content" :style="{ height: `${totalVirtualWeeks * rowHeight}px` }">
|
||||||
|
<WeekRow v.for="week in visibleWeeks" :key="week.virtualWeek" :week="week" :style="{ top: `${(week.virtualWeek - minVirtualWeek) * rowHeight}px` }" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, onBeforeUnmount, computed } from 'vue'
|
||||||
|
import { useCalendarStore } from '@/stores/CalendarStore'
|
||||||
|
import { getLocalizedWeekdayNames, isoWeekInfo, fromLocalString, toLocalString, mondayIndex } from '@/utils/date'
|
||||||
|
import WeekRow from './WeekRow.vue'
|
||||||
|
|
||||||
|
const calendarStore = useCalendarStore()
|
||||||
|
const viewportEl = ref(null)
|
||||||
|
const rowHeight = ref(64) // Default value, will be computed
|
||||||
|
const totalVirtualWeeks = ref(0)
|
||||||
|
const minVirtualWeek = ref(0)
|
||||||
|
const visibleWeeks = ref([])
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
min_year: 1900,
|
||||||
|
max_year: 2100,
|
||||||
|
weekend: [true, false, false, false, false, false, true] // Sun, Mon, ..., Sat
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseDate = new Date(2024, 0, 1) // 2024 begins with Monday
|
||||||
|
const WEEK_MS = 7 * 86400000
|
||||||
|
|
||||||
|
const weekdayNames = getLocalizedWeekdayNames()
|
||||||
|
|
||||||
|
const isWeekend = (day) => {
|
||||||
|
const dayIndex = weekdayNames.indexOf(day)
|
||||||
|
return config.weekend[(dayIndex + 1) % 7]
|
||||||
|
}
|
||||||
|
|
||||||
|
const getWeekIndex = (date) => {
|
||||||
|
const monday = new Date(date)
|
||||||
|
monday.setDate(date.getDate() - mondayIndex(date))
|
||||||
|
return Math.floor((monday - baseDate) / WEEK_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getMondayForVirtualWeek = (virtualWeek) => {
|
||||||
|
const monday = new Date(baseDate)
|
||||||
|
monday.setDate(monday.getDate() + virtualWeek * 7)
|
||||||
|
return monday
|
||||||
|
}
|
||||||
|
|
||||||
|
const 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()
|
||||||
|
return Math.round(h)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateVisibleWeeks = () => {
|
||||||
|
if (!viewportEl.value) return
|
||||||
|
|
||||||
|
const scrollTop = viewportEl.value.scrollTop
|
||||||
|
const viewportH = viewportEl.value.clientHeight
|
||||||
|
|
||||||
|
const topDisplayIndex = Math.floor(scrollTop / rowHeight.value)
|
||||||
|
const topVW = topDisplayIndex + minVirtualWeek.value
|
||||||
|
const monday = getMondayForVirtualWeek(topVW)
|
||||||
|
const { year } = isoWeekInfo(monday)
|
||||||
|
if (calendarStore.viewYear !== year) {
|
||||||
|
calendarStore.setViewYear(year)
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = 10
|
||||||
|
const startIdx = Math.floor((scrollTop - buffer * rowHeight.value) / rowHeight.value)
|
||||||
|
const endIdx = Math.ceil((scrollTop + viewportH + buffer * rowHeight.value) / rowHeight.value)
|
||||||
|
|
||||||
|
const startVW = Math.max(minVirtualWeek.value, startIdx + minVirtualWeek.value)
|
||||||
|
const endVW = Math.min(totalVirtualWeeks.value + minVirtualWeek.value - 1, endIdx + minVirtualWeek.value)
|
||||||
|
|
||||||
|
const newVisibleWeeks = []
|
||||||
|
for (let vw = startVW; vw <= endVW; vw++) {
|
||||||
|
newVisibleWeeks.push({
|
||||||
|
virtualWeek: vw,
|
||||||
|
monday: getMondayForVirtualWeek(vw)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
visibleWeeks.value = newVisibleWeeks
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
requestAnimationFrame(updateVisibleWeeks)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleWheel = (e) => {
|
||||||
|
const currentYear = calendarStore.viewYear
|
||||||
|
const delta = Math.round(e.deltaY * (1/3))
|
||||||
|
if (!delta) return
|
||||||
|
const newYear = Math.max(config.min_year, Math.min(config.max_year, currentYear + delta))
|
||||||
|
if (newYear === currentYear) return
|
||||||
|
|
||||||
|
const topDisplayIndex = Math.floor(viewportEl.value.scrollTop / rowHeight.value)
|
||||||
|
const currentWeekIndex = topDisplayIndex + minVirtualWeek.value
|
||||||
|
|
||||||
|
navigateToYear(newYear, currentWeekIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
const navigateToYear = (targetYear, weekIndex) => {
|
||||||
|
const monday = getMondayForVirtualWeek(weekIndex)
|
||||||
|
const { week } = isoWeekInfo(monday)
|
||||||
|
const jan4 = new Date(targetYear, 0, 4)
|
||||||
|
const jan4Monday = new Date(jan4)
|
||||||
|
jan4Monday.setDate(jan4.getDate() - mondayIndex(jan4))
|
||||||
|
const targetMonday = new Date(jan4Monday)
|
||||||
|
targetMonday.setDate(jan4Monday.getDate() + (week - 1) * 7)
|
||||||
|
scrollToTarget(targetMonday)
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollToTarget = (target) => {
|
||||||
|
let targetWeekIndex
|
||||||
|
if (target instanceof Date) {
|
||||||
|
targetWeekIndex = getWeekIndex(target)
|
||||||
|
} else {
|
||||||
|
targetWeekIndex = target
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetScrollTop = (targetWeekIndex - minVirtualWeek.value) * rowHeight.value
|
||||||
|
viewportEl.value.scrollTop = targetScrollTop
|
||||||
|
updateVisibleWeeks()
|
||||||
|
}
|
||||||
|
|
||||||
|
const goToTodayHandler = () => {
|
||||||
|
const today = new Date()
|
||||||
|
const top = new Date(today)
|
||||||
|
top.setDate(top.getDate() - 21)
|
||||||
|
scrollToTarget(top)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
rowHeight.value = computeRowHeight()
|
||||||
|
|
||||||
|
const minYearDate = new Date(config.min_year, 0, 1)
|
||||||
|
const maxYearLastDay = new Date(config.max_year, 11, 31)
|
||||||
|
const lastWeekMonday = new Date(maxYearLastDay)
|
||||||
|
lastWeekMonday.setDate(maxYearLastDay.getDate() - mondayIndex(maxYearLastDay))
|
||||||
|
|
||||||
|
minVirtualWeek.value = getWeekIndex(minYearDate)
|
||||||
|
const maxVirtualWeek = getWeekIndex(lastWeekMonday)
|
||||||
|
totalVirtualWeeks.value = maxVirtualWeek - minVirtualWeek.value + 1
|
||||||
|
|
||||||
|
const initialDate = fromLocalString(calendarStore.today)
|
||||||
|
scrollToTarget(initialDate)
|
||||||
|
|
||||||
|
document.addEventListener('goToToday', goToTodayHandler)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.removeEventListener('goToToday', goToTodayHandler)
|
||||||
|
})
|
||||||
|
|
||||||
|
</script>
|
81
src/components/CalendarHeader.vue
Normal file
81
src/components/CalendarHeader.vue
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useCalendarStore } from '@/stores/CalendarStore'
|
||||||
|
import { getLocalizedWeekdayNames, isoWeekInfo, mondayIndex } from '@/utils/date'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
scrollTop: { type: Number, default: 0 },
|
||||||
|
rowHeight: { type: Number, default: 64 },
|
||||||
|
minVirtualWeek: { type: Number, default: 0 }
|
||||||
|
})
|
||||||
|
|
||||||
|
const calendarStore = useCalendarStore()
|
||||||
|
|
||||||
|
const yearLabel = computed(() => {
|
||||||
|
const topDisplayIndex = Math.floor(props.scrollTop / props.rowHeight)
|
||||||
|
const topVW = topDisplayIndex + props.minVirtualWeek
|
||||||
|
const baseDate = new Date(2024, 0, 1) // Monday
|
||||||
|
const monday = new Date(baseDate)
|
||||||
|
monday.setDate(monday.getDate() + topVW * 7)
|
||||||
|
return isoWeekInfo(monday).year
|
||||||
|
})
|
||||||
|
|
||||||
|
const weekdayNames = computed(() => {
|
||||||
|
const names = getLocalizedWeekdayNames()
|
||||||
|
return names.map((name, i) => ({
|
||||||
|
name,
|
||||||
|
isWeekend: calendarStore.weekend[(i + 1) % 7]
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="calendar-header">
|
||||||
|
<div class="year-label">{{ yearLabel }}</div>
|
||||||
|
<div v-for="day in weekdayNames" :key="day.name" class="dow" :class="{ weekend: day.isWeekend }">{{ day.name }}</div>
|
||||||
|
<div class="overlay-header-spacer"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.calendar-header {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: var(--label-w) repeat(7, 1fr) 3rem;
|
||||||
|
border-bottom: 2px solid var(--muted);
|
||||||
|
align-items: last baseline;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 100%;
|
||||||
|
/* Prevent text selection */
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-touch-callout: none;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.year-label {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 100%;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 1.2em;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dow {
|
||||||
|
text-transform: uppercase;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dow.weekend {
|
||||||
|
color: var(--weekend);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-header-spacer {
|
||||||
|
/* Empty spacer for the month label column */
|
||||||
|
}
|
||||||
|
</style>
|
465
src/components/CalendarView.vue
Normal file
465
src/components/CalendarView.vue
Normal file
@ -0,0 +1,465 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, onBeforeUnmount, computed } 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 EventDialog from '@/components/EventDialog.vue'
|
||||||
|
import { isoWeekInfo, getLocalizedMonthName, monthAbbr, lunarPhaseSymbol, pad, mondayIndex, daysInclusive, addDaysStr, formatDateRange } from '@/utils/date'
|
||||||
|
import { toLocalString, fromLocalString } from '@/utils/date'
|
||||||
|
|
||||||
|
const calendarStore = useCalendarStore()
|
||||||
|
const viewport = ref(null)
|
||||||
|
const eventDialog = ref(null)
|
||||||
|
|
||||||
|
// UI state moved from store
|
||||||
|
const scrollTop = ref(0)
|
||||||
|
const viewportHeight = ref(600)
|
||||||
|
const rowHeight = ref(64)
|
||||||
|
const baseDate = new Date(2024, 0, 1) // Monday
|
||||||
|
|
||||||
|
// Selection state moved from store
|
||||||
|
const selection = ref({ start: null, end: null })
|
||||||
|
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 monday = new Date(date)
|
||||||
|
monday.setDate(date.getDate() - ((date.getDay() + 6) % 7))
|
||||||
|
return Math.floor((monday - baseDate) / WEEK_MS)
|
||||||
|
})
|
||||||
|
|
||||||
|
const maxVirtualWeek = computed(() => {
|
||||||
|
const date = new Date(calendarStore.maxYear, 11, 31)
|
||||||
|
const monday = new Date(date)
|
||||||
|
monday.setDate(date.getDate() - ((date.getDay() + 6) % 7))
|
||||||
|
return Math.floor((monday - baseDate) / WEEK_MS)
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalVirtualWeeks = computed(() => {
|
||||||
|
return maxVirtualWeek.value - minVirtualWeek.value + 1
|
||||||
|
})
|
||||||
|
|
||||||
|
const initialScrollTop = computed(() => {
|
||||||
|
const targetWeekIndex = getWeekIndex(calendarStore.now) - 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 t = calendarStore.now.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
|
||||||
|
})
|
||||||
|
|
||||||
|
// Functions moved from store
|
||||||
|
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 monday = new Date(date)
|
||||||
|
monday.setDate(date.getDate() - mondayIndex(date))
|
||||||
|
return Math.floor((monday - baseDate) / WEEK_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMondayForVirtualWeek(virtualWeek) {
|
||||||
|
const monday = new Date(baseDate)
|
||||||
|
monday.setDate(monday.getDate() + virtualWeek * 7)
|
||||||
|
return monday
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWeek(virtualWeek) {
|
||||||
|
const monday = getMondayForVirtualWeek(virtualWeek)
|
||||||
|
const weekNumber = isoWeekInfo(monday).week
|
||||||
|
const days = []
|
||||||
|
const cur = new Date(monday)
|
||||||
|
let hasFirst = false
|
||||||
|
let monthToLabel = null
|
||||||
|
let labelYear = null
|
||||||
|
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
const dateStr = toLocalString(cur)
|
||||||
|
const eventsForDay = calendarStore.events.get(dateStr) || []
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
isSelected: selection.value.start && selection.value.end && dateStr >= selection.value.start && dateStr <= selection.value.end,
|
||||||
|
events: eventsForDay
|
||||||
|
})
|
||||||
|
cur.setDate(cur.getDate() + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
let monthLabel = null
|
||||||
|
if (hasFirst && monthToLabel !== null) {
|
||||||
|
if (labelYear && labelYear <= calendarStore.config.max_year) {
|
||||||
|
// Calculate how many weeks this month spans
|
||||||
|
let weeksSpan = 0
|
||||||
|
const d = new Date(cur)
|
||||||
|
d.setDate(cur.getDate() - 1) // Go back to last day of the week we just processed
|
||||||
|
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
d.setDate(cur.getDate() - 1 + i * 7)
|
||||||
|
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 = new Date(calendarStore.now)
|
||||||
|
top.setDate(top.getDate() - 21)
|
||||||
|
const targetWeekIndex = getWeekIndex(top)
|
||||||
|
scrollTop.value = (targetWeekIndex - minVirtualWeek.value) * rowHeight.value
|
||||||
|
if (viewport.value) {
|
||||||
|
viewport.value.scrollTop = scrollTop.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSelection() {
|
||||||
|
selection.value = { start: null, end: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
function startDrag(dateStr) {
|
||||||
|
if (calendarStore.config.select_days === 0) return
|
||||||
|
isDragging.value = true
|
||||||
|
dragAnchor.value = dateStr
|
||||||
|
selection.value = { start: dateStr, end: dateStr }
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDrag(dateStr) {
|
||||||
|
if (!isDragging.value) return
|
||||||
|
const [start, end] = clampRange(dragAnchor.value, dateStr)
|
||||||
|
selection.value = { start, end }
|
||||||
|
}
|
||||||
|
|
||||||
|
function endDrag(dateStr) {
|
||||||
|
if (!isDragging.value) return
|
||||||
|
isDragging.value = false
|
||||||
|
const [start, end] = clampRange(dragAnchor.value, dateStr)
|
||||||
|
selection.value = { start, end }
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampRange(anchorStr, otherStr) {
|
||||||
|
const limit = calendarStore.config.select_days
|
||||||
|
const forward = fromLocalString(otherStr) >= fromLocalString(anchorStr)
|
||||||
|
const span = daysInclusive(anchorStr, otherStr)
|
||||||
|
if (span <= limit) {
|
||||||
|
const a = [anchorStr, otherStr].sort()
|
||||||
|
return [a[0], a[1]]
|
||||||
|
}
|
||||||
|
if (forward) return [anchorStr, addDaysStr(anchorStr, limit - 1)]
|
||||||
|
return [addDaysStr(anchorStr, -(limit - 1)), anchorStr]
|
||||||
|
}
|
||||||
|
|
||||||
|
const onScroll = () => {
|
||||||
|
if (viewport.value) {
|
||||||
|
scrollTop.value = viewport.value.scrollTop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleJogwheelScrollTo = (newScrollTop) => {
|
||||||
|
if (viewport.value) {
|
||||||
|
viewport.value.scrollTop = newScrollTop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// Compute row height and initialize
|
||||||
|
computeRowHeight()
|
||||||
|
calendarStore.updateCurrentDate()
|
||||||
|
|
||||||
|
if (viewport.value) {
|
||||||
|
viewportHeight.value = viewport.value.clientHeight
|
||||||
|
viewport.value.scrollTop = initialScrollTop.value
|
||||||
|
viewport.value.addEventListener('scroll', onScroll)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update time periodically
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
calendarStore.updateCurrentDate()
|
||||||
|
}, 60000) // Update every minute
|
||||||
|
|
||||||
|
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)
|
||||||
|
// Show event dialog if we have a selection
|
||||||
|
if (selection.value.start && selection.value.end && eventDialog.value) {
|
||||||
|
setTimeout(() => eventDialog.value.openCreateDialog(), 50)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Touch event handlers
|
||||||
|
const handleDayTouchStart = (dateStr) => {
|
||||||
|
startDrag(dateStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDayTouchMove = (dateStr) => {
|
||||||
|
if (isDragging.value) {
|
||||||
|
updateDrag(dateStr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDayTouchEnd = (dateStr) => {
|
||||||
|
if (isDragging.value) {
|
||||||
|
endDrag(dateStr)
|
||||||
|
// Show event dialog if we have a selection
|
||||||
|
if (selection.value.start && selection.value.end && eventDialog.value) {
|
||||||
|
setTimeout(() => eventDialog.value.openCreateDialog(), 50)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEventClick = (eventId) => {
|
||||||
|
if (eventDialog.value) {
|
||||||
|
eventDialog.value.openEditDialog(eventId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="wrap">
|
||||||
|
<header>
|
||||||
|
<h1>Calendar</h1>
|
||||||
|
<div class="header-controls">
|
||||||
|
<div class="today-date" @click="goToToday">{{ todayString }}</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<CalendarHeader
|
||||||
|
:scroll-top="scrollTop"
|
||||||
|
:row-height="rowHeight"
|
||||||
|
:min-virtual-week="minVirtualWeek"
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
|
<EventDialog
|
||||||
|
ref="eventDialog"
|
||||||
|
:selection="selection"
|
||||||
|
@clear-selection="clearSelection"
|
||||||
|
/>
|
||||||
|
</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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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>
|
105
src/components/CalendarWeek.vue
Normal file
105
src/components/CalendarWeek.vue
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
<script setup>
|
||||||
|
import CalendarDay from './CalendarDay.vue'
|
||||||
|
import EventOverlay from './EventOverlay.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
week: Object
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['day-mousedown', 'day-mouseenter', 'day-mouseup', 'day-touchstart', 'day-touchmove', 'day-touchend', 'event-click'])
|
||||||
|
|
||||||
|
const handleDayMouseDown = (dateStr) => {
|
||||||
|
emit('day-mousedown', dateStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDayMouseEnter = (dateStr) => {
|
||||||
|
emit('day-mouseenter', dateStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDayMouseUp = (dateStr) => {
|
||||||
|
emit('day-mouseup', dateStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDayTouchStart = (dateStr) => {
|
||||||
|
emit('day-touchstart', dateStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDayTouchMove = (dateStr) => {
|
||||||
|
emit('day-touchmove', dateStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDayTouchEnd = (dateStr) => {
|
||||||
|
emit('day-touchend', dateStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEventClick = (eventId) => {
|
||||||
|
emit('event-click', eventId)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="week-row"
|
||||||
|
:style="{ top: `${props.week.top}px` }"
|
||||||
|
>
|
||||||
|
<div class="week-label">W{{ props.week.weekNumber }}</div>
|
||||||
|
<div class="days-grid">
|
||||||
|
<CalendarDay
|
||||||
|
v-for="day in props.week.days"
|
||||||
|
:key="day.date"
|
||||||
|
:day="day"
|
||||||
|
@mousedown="handleDayMouseDown(day.date)"
|
||||||
|
@mouseenter="handleDayMouseEnter(day.date)"
|
||||||
|
@mouseup="handleDayMouseUp(day.date)"
|
||||||
|
@touchstart="handleDayTouchStart(day.date)"
|
||||||
|
@touchmove="handleDayTouchMove(day.date)"
|
||||||
|
@touchend="handleDayTouchEnd(day.date)"
|
||||||
|
@event-click="handleEventClick"
|
||||||
|
/>
|
||||||
|
<EventOverlay
|
||||||
|
:week="props.week"
|
||||||
|
@event-click="handleEventClick"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.week-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: var(--label-w) repeat(7, 1fr) 3rem;
|
||||||
|
position: absolute;
|
||||||
|
height: var(--cell-h);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-label {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 100%;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 1.2em;
|
||||||
|
font-weight: 500;
|
||||||
|
/* Prevent text selection */
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-touch-callout: none;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.days-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
position: relative;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fixed heights for cells and labels (from cells.css) */
|
||||||
|
.week-row :deep(.cell),
|
||||||
|
.week-label {
|
||||||
|
height: var(--cell-h);
|
||||||
|
}
|
||||||
|
</style>
|
27
src/components/DayCell.vue
Normal file
27
src/components/DayCell.vue
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
<template>
|
||||||
|
<div class="cell" :class="cellClasses" :data-date="day.date">
|
||||||
|
<h1>{{ day.displayText }}</h1>
|
||||||
|
<span v-if="day.lunarPhase" class="lunar-phase">{{ day.lunarPhase }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
day: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const cellClasses = computed(() => {
|
||||||
|
return {
|
||||||
|
[props.day.monthClass]: true,
|
||||||
|
today: props.day.isToday,
|
||||||
|
selected: props.day.isSelected,
|
||||||
|
weekend: props.day.isWeekend,
|
||||||
|
firstday: props.day.isFirstDay
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
342
src/components/EventDialog.vue
Normal file
342
src/components/EventDialog.vue
Normal file
@ -0,0 +1,342 @@
|
|||||||
|
<script setup>
|
||||||
|
import { useCalendarStore } from '@/stores/CalendarStore'
|
||||||
|
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
selection: { type: Object, default: () => ({ start: null, end: null }) }
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['clear-selection'])
|
||||||
|
|
||||||
|
const calendarStore = useCalendarStore()
|
||||||
|
|
||||||
|
const showDialog = ref(false)
|
||||||
|
const dialogMode = ref('create') // 'create' or 'edit'
|
||||||
|
const editingEventId = ref(null)
|
||||||
|
const title = ref('')
|
||||||
|
const repeat = ref('none')
|
||||||
|
const colorId = ref(0)
|
||||||
|
const eventSaved = ref(false)
|
||||||
|
|
||||||
|
const selectedColor = computed({
|
||||||
|
get: () => colorId.value,
|
||||||
|
set: (val) => {
|
||||||
|
colorId.value = parseInt(val)
|
||||||
|
// Update the event immediately when color changes
|
||||||
|
if (editingEventId.value) {
|
||||||
|
updateEventInStore()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function openCreateDialog() {
|
||||||
|
dialogMode.value = 'create'
|
||||||
|
title.value = ''
|
||||||
|
repeat.value = 'none'
|
||||||
|
colorId.value = calendarStore.selectEventColorId(props.selection.start, props.selection.end)
|
||||||
|
eventSaved.value = false
|
||||||
|
|
||||||
|
// Create the event immediately in the store
|
||||||
|
editingEventId.value = calendarStore.createEvent({
|
||||||
|
title: '',
|
||||||
|
startDate: props.selection.start,
|
||||||
|
endDate: props.selection.end,
|
||||||
|
colorId: colorId.value,
|
||||||
|
repeat: repeat.value
|
||||||
|
})
|
||||||
|
|
||||||
|
showDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditDialog(eventId) {
|
||||||
|
const event = calendarStore.getEventById(eventId)
|
||||||
|
if (!event) return
|
||||||
|
dialogMode.value = 'edit'
|
||||||
|
editingEventId.value = eventId
|
||||||
|
title.value = event.title
|
||||||
|
repeat.value = event.repeat
|
||||||
|
colorId.value = event.colorId
|
||||||
|
eventSaved.value = false
|
||||||
|
showDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDialog() {
|
||||||
|
showDialog.value = false
|
||||||
|
// If we were creating a new event and user cancels (didn't save), delete it
|
||||||
|
if (dialogMode.value === 'create' && editingEventId.value && !eventSaved.value) {
|
||||||
|
calendarStore.deleteEvent(editingEventId.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateEventInStore() {
|
||||||
|
if (!editingEventId.value) return
|
||||||
|
|
||||||
|
// For simple property updates (title, color, repeat), update all instances directly
|
||||||
|
// This avoids the expensive remove/re-add cycle
|
||||||
|
for (const [, eventList] of calendarStore.events) {
|
||||||
|
for (const event of eventList) {
|
||||||
|
if (event.id === editingEventId.value) {
|
||||||
|
event.title = title.value
|
||||||
|
event.colorId = colorId.value
|
||||||
|
event.repeat = repeat.value
|
||||||
|
// Update repeat status
|
||||||
|
event.isRepeating = (repeat.value && repeat.value !== 'none')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveEvent() {
|
||||||
|
if (editingEventId.value) {
|
||||||
|
updateEventInStore()
|
||||||
|
}
|
||||||
|
|
||||||
|
eventSaved.value = true
|
||||||
|
|
||||||
|
if (dialogMode.value === 'create') {
|
||||||
|
emit('clear-selection')
|
||||||
|
}
|
||||||
|
|
||||||
|
closeDialog()
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteEvent() {
|
||||||
|
if (editingEventId.value) {
|
||||||
|
calendarStore.deleteEvent(editingEventId.value)
|
||||||
|
}
|
||||||
|
closeDialog()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch for title changes and update the event immediately
|
||||||
|
watch(title, (newTitle) => {
|
||||||
|
if (editingEventId.value && showDialog.value) {
|
||||||
|
updateEventInStore()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Watch for repeat changes and update the event immediately
|
||||||
|
watch(repeat, (newRepeat) => {
|
||||||
|
if (editingEventId.value && showDialog.value) {
|
||||||
|
updateEventInStore()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle Esc key to close dialog
|
||||||
|
function handleKeydown(event) {
|
||||||
|
if (event.key === 'Escape' && showDialog.value) {
|
||||||
|
closeDialog()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('keydown', handleKeydown)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('keydown', handleKeydown)
|
||||||
|
})
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
openCreateDialog,
|
||||||
|
openEditDialog
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="ec-modal-backdrop" v-if="showDialog" @click.self="closeDialog">
|
||||||
|
<div class="ec-modal">
|
||||||
|
<form class="ec-form" @submit.prevent="saveEvent">
|
||||||
|
<header class="ec-header">
|
||||||
|
<h2 id="ec-modal-title">{{ dialogMode === 'create' ? 'Create Event' : 'Edit Event' }}</h2>
|
||||||
|
</header>
|
||||||
|
<div class="ec-body">
|
||||||
|
<label class="ec-field">
|
||||||
|
<span>Title</span>
|
||||||
|
<input type="text" v-model="title" autocomplete="off" />
|
||||||
|
</label>
|
||||||
|
<label class="ec-field">
|
||||||
|
<span>Repeat</span>
|
||||||
|
<select v-model="repeat">
|
||||||
|
<option value="none">No repeat</option>
|
||||||
|
<option value="daily">Daily</option>
|
||||||
|
<option value="weekly">Weekly</option>
|
||||||
|
<option value="biweekly">Every 2 weeks</option>
|
||||||
|
<option value="monthly">Monthly</option>
|
||||||
|
<option value="yearly">Yearly</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div class="ec-color-swatches">
|
||||||
|
<label v-for="i in 8" :key="i-1" class="swatch-label">
|
||||||
|
<input
|
||||||
|
class="swatch"
|
||||||
|
:class="'event-color-' + (i-1)"
|
||||||
|
type="radio"
|
||||||
|
name="colorId"
|
||||||
|
:value="i-1"
|
||||||
|
v-model="selectedColor"
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<footer class="ec-footer">
|
||||||
|
<!-- Create mode: Delete and Save buttons -->
|
||||||
|
<template v-if="dialogMode === 'create'">
|
||||||
|
<button type="button" class="ec-btn delete-btn" @click="deleteEvent">Delete</button>
|
||||||
|
<button type="submit" class="ec-btn save-btn">Save</button>
|
||||||
|
</template>
|
||||||
|
<!-- Edit mode: Delete and Close buttons -->
|
||||||
|
<template v-else>
|
||||||
|
<button type="button" class="ec-btn delete-btn" @click="deleteEvent">Delete</button>
|
||||||
|
<button type="button" class="ec-btn close-btn" @click="closeDialog">Close</button>
|
||||||
|
</template>
|
||||||
|
</footer>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Modal dialog */
|
||||||
|
.ec-modal-backdrop[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: color-mix(in srgb, var(--strong) 30%, transparent);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-modal {
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--ink);
|
||||||
|
border-radius: 0.6rem;
|
||||||
|
min-width: 320px;
|
||||||
|
max-width: min(520px, 90vw);
|
||||||
|
box-shadow: 0 10px 30px rgba(0,0,0,0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-form {
|
||||||
|
padding: 1rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-body {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-field {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-field > span {
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-field input[type="text"],
|
||||||
|
.ec-field input[type="time"],
|
||||||
|
.ec-field input[type="number"],
|
||||||
|
.ec-field select {
|
||||||
|
border: 1px solid var(--muted);
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
padding: 0.5rem 0.6rem;
|
||||||
|
width: 100%;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-color-swatches {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 0.3rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-color-swatches .swatch {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
padding: 0.25rem;
|
||||||
|
outline: 2px solid transparent;
|
||||||
|
outline-offset: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
appearance: none;
|
||||||
|
width: 3em;
|
||||||
|
height: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-color-swatches .swatch:checked {
|
||||||
|
outline-color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-btn {
|
||||||
|
border: 1px solid var(--muted);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ink);
|
||||||
|
padding: 0.5rem 0.8rem;
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-btn:hover {
|
||||||
|
background: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-btn.save-btn {
|
||||||
|
background: var(--today);
|
||||||
|
color: #000;
|
||||||
|
border-color: transparent;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-btn.save-btn:hover {
|
||||||
|
background: color-mix(in srgb, var(--today) 90%, black);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-btn.close-btn {
|
||||||
|
background: var(--panel);
|
||||||
|
border-color: var(--muted);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-btn.close-btn:hover {
|
||||||
|
background: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-btn.delete-btn {
|
||||||
|
background: hsl(0, 70%, 50%);
|
||||||
|
color: white;
|
||||||
|
border-color: transparent;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-btn.delete-btn:hover {
|
||||||
|
background: hsl(0, 70%, 45%);
|
||||||
|
}
|
||||||
|
</style>
|
561
src/components/EventOverlay.vue
Normal file
561
src/components/EventOverlay.vue
Normal file
@ -0,0 +1,561 @@
|
|||||||
|
<template>
|
||||||
|
<div class="week-overlay">
|
||||||
|
<div
|
||||||
|
v-for="span in eventSpans"
|
||||||
|
:key="span.id"
|
||||||
|
class="event-span"
|
||||||
|
:class="[`event-color-${span.colorId}`]"
|
||||||
|
:style="{
|
||||||
|
gridColumn: `${span.startIdx + 1} / ${span.endIdx + 2}`,
|
||||||
|
gridRow: `${span.row}`
|
||||||
|
}"
|
||||||
|
@click="handleEventClick(span)"
|
||||||
|
@pointerdown="handleEventPointerDown(span, $event)"
|
||||||
|
>
|
||||||
|
<span class="event-title">{{ span.title }}</span>
|
||||||
|
<div
|
||||||
|
class="resize-handle left"
|
||||||
|
@pointerdown="handleResizePointerDown(span, 'resize-left', $event)"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class="resize-handle right"
|
||||||
|
@pointerdown="handleResizePointerDown(span, 'resize-right', $event)"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { useCalendarStore } from '@/stores/CalendarStore'
|
||||||
|
import { toLocalString, fromLocalString, daysInclusive, addDaysStr } from '@/utils/date'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
week: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['event-click'])
|
||||||
|
const store = useCalendarStore()
|
||||||
|
|
||||||
|
// Local drag state
|
||||||
|
const dragState = ref(null)
|
||||||
|
const justDragged = ref(false)
|
||||||
|
|
||||||
|
// Generate repeat occurrences for a specific date
|
||||||
|
function generateRepeatOccurrencesForDate(targetDateStr) {
|
||||||
|
const occurrences = []
|
||||||
|
|
||||||
|
// Get all events from the store and check for repeating ones
|
||||||
|
for (const [, eventList] of store.events) {
|
||||||
|
for (const baseEvent of eventList) {
|
||||||
|
if (!baseEvent.isRepeating || baseEvent.repeat === 'none') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetDate = new Date(fromLocalString(targetDateStr))
|
||||||
|
const baseStartDate = new Date(fromLocalString(baseEvent.startDate))
|
||||||
|
const baseEndDate = new Date(fromLocalString(baseEvent.endDate))
|
||||||
|
const spanDays = Math.floor((baseEndDate - baseStartDate) / (24 * 60 * 60 * 1000))
|
||||||
|
|
||||||
|
// Calculate how many intervals have passed since the base event
|
||||||
|
let intervalsPassed = 0
|
||||||
|
const timeDiff = targetDate - baseStartDate
|
||||||
|
|
||||||
|
switch (baseEvent.repeat) {
|
||||||
|
case 'daily':
|
||||||
|
intervalsPassed = Math.floor(timeDiff / (24 * 60 * 60 * 1000))
|
||||||
|
break
|
||||||
|
case 'weekly':
|
||||||
|
intervalsPassed = Math.floor(timeDiff / (7 * 24 * 60 * 60 * 1000))
|
||||||
|
break
|
||||||
|
case 'biweekly':
|
||||||
|
intervalsPassed = Math.floor(timeDiff / (14 * 24 * 60 * 60 * 1000))
|
||||||
|
break
|
||||||
|
case 'monthly':
|
||||||
|
intervalsPassed = Math.floor((targetDate.getFullYear() - baseStartDate.getFullYear()) * 12 +
|
||||||
|
(targetDate.getMonth() - baseStartDate.getMonth()))
|
||||||
|
break
|
||||||
|
case 'yearly':
|
||||||
|
intervalsPassed = targetDate.getFullYear() - baseStartDate.getFullYear()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check a few occurrences around the target date
|
||||||
|
for (let i = Math.max(0, intervalsPassed - 2); i <= intervalsPassed + 2; i++) {
|
||||||
|
const maxOccurrences = baseEvent.repeatCount === 'unlimited' ? Infinity : parseInt(baseEvent.repeatCount, 10)
|
||||||
|
if (i >= maxOccurrences) break
|
||||||
|
|
||||||
|
const currentStart = new Date(baseStartDate)
|
||||||
|
|
||||||
|
switch (baseEvent.repeat) {
|
||||||
|
case 'daily':
|
||||||
|
currentStart.setDate(baseStartDate.getDate() + i)
|
||||||
|
break
|
||||||
|
case 'weekly':
|
||||||
|
currentStart.setDate(baseStartDate.getDate() + i * 7)
|
||||||
|
break
|
||||||
|
case 'biweekly':
|
||||||
|
currentStart.setDate(baseStartDate.getDate() + i * 14)
|
||||||
|
break
|
||||||
|
case 'monthly':
|
||||||
|
currentStart.setMonth(baseStartDate.getMonth() + i)
|
||||||
|
break
|
||||||
|
case 'yearly':
|
||||||
|
currentStart.setFullYear(baseStartDate.getFullYear() + i)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentEnd = new Date(currentStart)
|
||||||
|
currentEnd.setDate(currentStart.getDate() + spanDays)
|
||||||
|
|
||||||
|
// Check if this occurrence intersects with the target date
|
||||||
|
const currentStartStr = toLocalString(currentStart)
|
||||||
|
const currentEndStr = toLocalString(currentEnd)
|
||||||
|
|
||||||
|
if (currentStartStr <= targetDateStr && targetDateStr <= currentEndStr) {
|
||||||
|
// Skip the original occurrence (i === 0) since it's already in the base events
|
||||||
|
if (i === 0) continue
|
||||||
|
|
||||||
|
occurrences.push({
|
||||||
|
...baseEvent,
|
||||||
|
id: `${baseEvent.id}_repeat_${i}`,
|
||||||
|
startDate: currentStartStr,
|
||||||
|
endDate: currentEndStr,
|
||||||
|
isRepeatOccurrence: true,
|
||||||
|
repeatIndex: i
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return occurrences
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract original event ID from repeat occurrence ID
|
||||||
|
function getOriginalEventId(eventId) {
|
||||||
|
if (typeof eventId === 'string' && eventId.includes('_repeat_')) {
|
||||||
|
return eventId.split('_repeat_')[0]
|
||||||
|
}
|
||||||
|
return eventId
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle event click
|
||||||
|
function handleEventClick(span) {
|
||||||
|
// Only emit click if we didn't just finish dragging
|
||||||
|
if (justDragged.value) return
|
||||||
|
emit('event-click', getOriginalEventId(span.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle event pointer down for dragging
|
||||||
|
function handleEventPointerDown(span, event) {
|
||||||
|
// Don't start drag if clicking on resize handle
|
||||||
|
if (event.target.classList.contains('resize-handle')) return
|
||||||
|
|
||||||
|
event.stopPropagation()
|
||||||
|
// Do not preventDefault here to allow click unless drag threshold is passed
|
||||||
|
|
||||||
|
// Get the date under the pointer
|
||||||
|
const hit = getDateUnderPointer(event.clientX, event.clientY, event.currentTarget)
|
||||||
|
const anchorDate = hit ? hit.date : span.startDate
|
||||||
|
|
||||||
|
startLocalDrag({
|
||||||
|
id: span.id,
|
||||||
|
mode: 'move',
|
||||||
|
pointerStartX: event.clientX,
|
||||||
|
pointerStartY: event.clientY,
|
||||||
|
anchorDate,
|
||||||
|
startDate: span.startDate,
|
||||||
|
endDate: span.endDate
|
||||||
|
}, event)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle resize handle pointer down
|
||||||
|
function handleResizePointerDown(span, mode, event) {
|
||||||
|
event.stopPropagation()
|
||||||
|
// Start drag from the current edge; anchorDate not needed for resize
|
||||||
|
startLocalDrag({
|
||||||
|
id: span.id,
|
||||||
|
mode,
|
||||||
|
pointerStartX: event.clientX,
|
||||||
|
pointerStartY: event.clientY,
|
||||||
|
anchorDate: null,
|
||||||
|
startDate: span.startDate,
|
||||||
|
endDate: span.endDate
|
||||||
|
}, event)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get date under pointer coordinates
|
||||||
|
function getDateUnderPointer(clientX, clientY, targetEl) {
|
||||||
|
// First try to find a day cell directly under the pointer
|
||||||
|
let element = document.elementFromPoint(clientX, clientY)
|
||||||
|
|
||||||
|
// If we hit an event element, temporarily hide it and try again
|
||||||
|
const hiddenElements = []
|
||||||
|
while (element && element.classList.contains('event-span')) {
|
||||||
|
element.style.pointerEvents = 'none'
|
||||||
|
hiddenElements.push(element)
|
||||||
|
element = document.elementFromPoint(clientX, clientY)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore pointer events for hidden elements
|
||||||
|
hiddenElements.forEach(el => el.style.pointerEvents = 'auto')
|
||||||
|
|
||||||
|
if (element) {
|
||||||
|
// Look for a day cell with data-date attribute
|
||||||
|
const dayElement = element.closest('[data-date]')
|
||||||
|
if (dayElement && dayElement.dataset.date) {
|
||||||
|
return { date: dayElement.dataset.date }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check if we're over a week element and can calculate position
|
||||||
|
const weekElement = element.closest('.week-row')
|
||||||
|
if (weekElement) {
|
||||||
|
const rect = weekElement.getBoundingClientRect()
|
||||||
|
const relativeX = clientX - rect.left
|
||||||
|
const dayWidth = rect.width / 7
|
||||||
|
const dayIndex = Math.floor(Math.max(0, Math.min(6, relativeX / dayWidth)))
|
||||||
|
|
||||||
|
const daysGrid = weekElement.querySelector('.days-grid')
|
||||||
|
if (daysGrid && daysGrid.children[dayIndex]) {
|
||||||
|
const dayEl = daysGrid.children[dayIndex]
|
||||||
|
const date = dayEl?.dataset?.date
|
||||||
|
if (date) return { date }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: try to find the week overlay and calculate position
|
||||||
|
const overlayEl = targetEl?.closest('.week-overlay')
|
||||||
|
const weekElement = overlayEl ? overlayEl.parentElement : null
|
||||||
|
if (!weekElement) {
|
||||||
|
// If we're outside this week, try to find any week element under the pointer
|
||||||
|
const allWeekElements = document.querySelectorAll('.week-row')
|
||||||
|
let bestWeek = null
|
||||||
|
let bestDistance = Infinity
|
||||||
|
|
||||||
|
for (const week of allWeekElements) {
|
||||||
|
const rect = week.getBoundingClientRect()
|
||||||
|
if (clientY >= rect.top && clientY <= rect.bottom) {
|
||||||
|
const distance = Math.abs(clientY - (rect.top + rect.height / 2))
|
||||||
|
if (distance < bestDistance) {
|
||||||
|
bestDistance = distance
|
||||||
|
bestWeek = week
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bestWeek) {
|
||||||
|
const rect = bestWeek.getBoundingClientRect()
|
||||||
|
const relativeX = clientX - rect.left
|
||||||
|
const dayWidth = rect.width / 7
|
||||||
|
const dayIndex = Math.floor(Math.max(0, Math.min(6, relativeX / dayWidth)))
|
||||||
|
|
||||||
|
const daysGrid = bestWeek.querySelector('.days-grid')
|
||||||
|
if (daysGrid && daysGrid.children[dayIndex]) {
|
||||||
|
const dayEl = daysGrid.children[dayIndex]
|
||||||
|
const date = dayEl?.dataset?.date
|
||||||
|
if (date) return { date }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = weekElement.getBoundingClientRect()
|
||||||
|
const relativeX = clientX - rect.left
|
||||||
|
const dayWidth = rect.width / 7
|
||||||
|
const dayIndex = Math.floor(Math.max(0, Math.min(6, relativeX / dayWidth)))
|
||||||
|
|
||||||
|
if (props.week.days[dayIndex]) {
|
||||||
|
return { date: props.week.days[dayIndex].date }
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local drag handling
|
||||||
|
function startLocalDrag(init, evt) {
|
||||||
|
const spanDays = daysInclusive(init.startDate, init.endDate)
|
||||||
|
let anchorOffset = 0
|
||||||
|
if (init.mode === 'move' && init.anchorDate) {
|
||||||
|
if (init.anchorDate < init.startDate) anchorOffset = 0
|
||||||
|
else if (init.anchorDate > init.endDate) anchorOffset = spanDays - 1
|
||||||
|
else anchorOffset = daysInclusive(init.startDate, init.anchorDate) - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
dragState.value = {
|
||||||
|
...init,
|
||||||
|
anchorOffset,
|
||||||
|
originSpanDays: spanDays,
|
||||||
|
eventMoved: false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture pointer events globally
|
||||||
|
if (evt.currentTarget && evt.pointerId !== undefined) {
|
||||||
|
try {
|
||||||
|
evt.currentTarget.setPointerCapture(evt.pointerId)
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Could not set pointer capture:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent default to avoid text selection and other interference
|
||||||
|
evt.preventDefault()
|
||||||
|
|
||||||
|
window.addEventListener('pointermove', onDragPointerMove, { passive: false })
|
||||||
|
window.addEventListener('pointerup', onDragPointerUp, { passive: false })
|
||||||
|
window.addEventListener('pointercancel', onDragPointerUp, { passive: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragPointerMove(e) {
|
||||||
|
const st = dragState.value
|
||||||
|
if (!st) return
|
||||||
|
const dx = e.clientX - st.pointerStartX
|
||||||
|
const dy = e.clientY - st.pointerStartY
|
||||||
|
const distance = Math.sqrt(dx * dx + dy * dy)
|
||||||
|
if (!st.eventMoved && distance < 5) return
|
||||||
|
st.eventMoved = true
|
||||||
|
|
||||||
|
const hitEl = document.elementFromPoint(e.clientX, e.clientY)
|
||||||
|
const hit = getDateUnderPointer(e.clientX, e.clientY, hitEl)
|
||||||
|
|
||||||
|
// If we can't find a date, don't update the range but keep the drag active
|
||||||
|
if (!hit || !hit.date) return
|
||||||
|
|
||||||
|
const [ns, ne] = computeTentativeRangeFromPointer(st, hit.date)
|
||||||
|
if (!ns || !ne) return
|
||||||
|
applyRangeDuringDrag(st, ns, ne)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragPointerUp(e) {
|
||||||
|
const st = dragState.value
|
||||||
|
if (!st) return
|
||||||
|
|
||||||
|
// Release pointer capture if it was set
|
||||||
|
if (e.target && e.pointerId !== undefined) {
|
||||||
|
try {
|
||||||
|
e.target.releasePointerCapture(e.pointerId)
|
||||||
|
} catch (err) {
|
||||||
|
// Ignore errors - capture might not have been set
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const moved = !!st.eventMoved
|
||||||
|
dragState.value = null
|
||||||
|
|
||||||
|
window.removeEventListener('pointermove', onDragPointerMove)
|
||||||
|
window.removeEventListener('pointerup', onDragPointerUp)
|
||||||
|
window.removeEventListener('pointercancel', onDragPointerUp)
|
||||||
|
|
||||||
|
if (moved) {
|
||||||
|
justDragged.value = true
|
||||||
|
setTimeout(() => { justDragged.value = false }, 120)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeTentativeRangeFromPointer(st, dropDateStr) {
|
||||||
|
const anchorOffset = st.anchorOffset || 0
|
||||||
|
const spanDays = st.originSpanDays || daysInclusive(st.startDate, st.endDate)
|
||||||
|
let startStr = st.startDate
|
||||||
|
let endStr = st.endDate
|
||||||
|
if (st.mode === 'move') {
|
||||||
|
startStr = addDaysStr(dropDateStr, -anchorOffset)
|
||||||
|
endStr = addDaysStr(startStr, spanDays - 1)
|
||||||
|
} else if (st.mode === 'resize-left') {
|
||||||
|
startStr = dropDateStr
|
||||||
|
endStr = st.endDate
|
||||||
|
} else if (st.mode === 'resize-right') {
|
||||||
|
startStr = st.startDate
|
||||||
|
endStr = dropDateStr
|
||||||
|
}
|
||||||
|
return normalizeDateOrder(startStr, endStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDateOrder(aStr, bStr) {
|
||||||
|
if (!aStr) return [bStr, bStr]
|
||||||
|
if (!bStr) return [aStr, aStr]
|
||||||
|
return aStr <= bStr ? [aStr, bStr] : [bStr, aStr]
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyRangeDuringDrag(st, startDate, endDate) {
|
||||||
|
const ev = store.getEventById(st.id)
|
||||||
|
if (!ev) return
|
||||||
|
if (ev.isRepeatOccurrence) {
|
||||||
|
const [baseId, idxStr] = String(st.id).split('_repeat_')
|
||||||
|
const repeatIndex = parseInt(idxStr, 10) || 0
|
||||||
|
if (repeatIndex === 0) {
|
||||||
|
store.setEventRange(baseId, startDate, endDate)
|
||||||
|
} else {
|
||||||
|
if (!st.splitNewBaseId) {
|
||||||
|
const newId = store.splitRepeatSeries(baseId, repeatIndex, startDate, endDate)
|
||||||
|
if (newId) {
|
||||||
|
st.splitNewBaseId = newId
|
||||||
|
st.id = newId
|
||||||
|
st.startDate = startDate
|
||||||
|
st.endDate = endDate
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
store.setEventRange(st.splitNewBaseId, startDate, endDate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
store.setEventRange(st.id, startDate, endDate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate event spans for this week
|
||||||
|
const eventSpans = computed(() => {
|
||||||
|
const spans = []
|
||||||
|
const weekEvents = new Map()
|
||||||
|
|
||||||
|
// Collect events from all days in this week, including repeat occurrences
|
||||||
|
props.week.days.forEach((day, dayIndex) => {
|
||||||
|
// Get base events for this day
|
||||||
|
day.events.forEach(event => {
|
||||||
|
if (!weekEvents.has(event.id)) {
|
||||||
|
weekEvents.set(event.id, {
|
||||||
|
...event,
|
||||||
|
startIdx: dayIndex,
|
||||||
|
endIdx: dayIndex
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const existing = weekEvents.get(event.id)
|
||||||
|
existing.endIdx = dayIndex
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Generate repeat occurrences for this day
|
||||||
|
const repeatOccurrences = generateRepeatOccurrencesForDate(day.date)
|
||||||
|
repeatOccurrences.forEach(event => {
|
||||||
|
if (!weekEvents.has(event.id)) {
|
||||||
|
weekEvents.set(event.id, {
|
||||||
|
...event,
|
||||||
|
startIdx: dayIndex,
|
||||||
|
endIdx: dayIndex
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const existing = weekEvents.get(event.id)
|
||||||
|
existing.endIdx = dayIndex
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Convert to array and sort
|
||||||
|
const eventArray = Array.from(weekEvents.values())
|
||||||
|
eventArray.sort((a, b) => {
|
||||||
|
// Sort by span length (longer first)
|
||||||
|
const spanA = a.endIdx - a.startIdx
|
||||||
|
const spanB = b.endIdx - b.startIdx
|
||||||
|
if (spanA !== spanB) return spanB - spanA
|
||||||
|
|
||||||
|
// Then by start position
|
||||||
|
if (a.startIdx !== b.startIdx) return a.startIdx - b.startIdx
|
||||||
|
|
||||||
|
// Then by start time if available
|
||||||
|
const timeA = a.startTime ? timeToMinutes(a.startTime) : 0
|
||||||
|
const timeB = b.startTime ? timeToMinutes(b.startTime) : 0
|
||||||
|
if (timeA !== timeB) return timeA - timeB
|
||||||
|
|
||||||
|
// Fallback to ID
|
||||||
|
return String(a.id).localeCompare(String(b.id))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Assign rows to avoid overlaps
|
||||||
|
const rowsLastEnd = []
|
||||||
|
eventArray.forEach(event => {
|
||||||
|
let placedRow = 0
|
||||||
|
while (placedRow < rowsLastEnd.length && !(event.startIdx > rowsLastEnd[placedRow])) {
|
||||||
|
placedRow++
|
||||||
|
}
|
||||||
|
if (placedRow === rowsLastEnd.length) {
|
||||||
|
rowsLastEnd.push(-1)
|
||||||
|
}
|
||||||
|
rowsLastEnd[placedRow] = event.endIdx
|
||||||
|
event.row = placedRow + 1
|
||||||
|
})
|
||||||
|
|
||||||
|
return eventArray
|
||||||
|
})
|
||||||
|
|
||||||
|
function timeToMinutes(timeStr) {
|
||||||
|
if (!timeStr) return 0
|
||||||
|
const [hours, minutes] = timeStr.split(':').map(Number)
|
||||||
|
return hours * 60 + minutes
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.week-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 15;
|
||||||
|
display: grid;
|
||||||
|
/* Prevent content from expanding tracks beyond container width */
|
||||||
|
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||||
|
grid-auto-rows: minmax(0, 1.5em);
|
||||||
|
|
||||||
|
row-gap: 0.05em;
|
||||||
|
margin-top: 1.8em;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-span {
|
||||||
|
padding: 0.1em 0.3em;
|
||||||
|
border-radius: 0.2em;
|
||||||
|
font-size: clamp(0.45em, 1.8vh, 0.75em);
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: grab;
|
||||||
|
pointer-events: auto;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
line-height: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
user-select: none;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
z-index: 1;
|
||||||
|
text-align: center;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inner title wrapper ensures proper ellipsis within flex/grid constraints */
|
||||||
|
.event-title {
|
||||||
|
display: block;
|
||||||
|
flex: 1 1 0%;
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-align: center;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Resize handles */
|
||||||
|
.event-span .resize-handle {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 6px;
|
||||||
|
background: transparent;
|
||||||
|
z-index: 2;
|
||||||
|
cursor: ew-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-span .resize-handle.left {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-span .resize-handle.right {
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
</style>
|
112
src/components/Jogwheel.vue
Normal file
112
src/components/Jogwheel.vue
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
<template>
|
||||||
|
<div class="jogwheel-viewport" ref="jogwheelViewport" @scroll="handleJogwheelScroll">
|
||||||
|
<div class="jogwheel-content" ref="jogwheelContent" :style="{ height: jogwheelHeight + 'px' }"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
totalVirtualWeeks: { type: Number, required: true },
|
||||||
|
rowHeight: { type: Number, required: true },
|
||||||
|
viewportHeight: { type: Number, required: true },
|
||||||
|
scrollTop: { type: Number, required: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['scroll-to'])
|
||||||
|
|
||||||
|
const jogwheelViewport = ref(null)
|
||||||
|
const jogwheelContent = ref(null)
|
||||||
|
const syncLock = ref(null)
|
||||||
|
|
||||||
|
// Jogwheel content height is 1/10th of main calendar
|
||||||
|
const jogwheelHeight = computed(() => {
|
||||||
|
return (props.totalVirtualWeeks * props.rowHeight) / 10
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleJogwheelScroll = () => {
|
||||||
|
if (syncLock.value === 'jogwheel') return
|
||||||
|
syncFromJogwheel()
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncFromJogwheel = () => {
|
||||||
|
if (!jogwheelViewport.value || !jogwheelContent.value) return
|
||||||
|
|
||||||
|
syncLock.value = 'main'
|
||||||
|
|
||||||
|
const jogScrollable = Math.max(0, jogwheelContent.value.scrollHeight - jogwheelViewport.value.clientHeight)
|
||||||
|
const mainScrollable = Math.max(0, props.totalVirtualWeeks * props.rowHeight - props.viewportHeight)
|
||||||
|
|
||||||
|
if (jogScrollable > 0) {
|
||||||
|
const ratio = jogwheelViewport.value.scrollTop / jogScrollable
|
||||||
|
|
||||||
|
// Emit scroll event to parent to update main viewport
|
||||||
|
emit('scroll-to', ratio * mainScrollable)
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (syncLock.value === 'main') syncLock.value = null
|
||||||
|
}, 50)
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncFromMain = (mainScrollTop) => {
|
||||||
|
if (!jogwheelViewport.value || !jogwheelContent.value) return
|
||||||
|
if (syncLock.value === 'main') return
|
||||||
|
|
||||||
|
syncLock.value = 'jogwheel'
|
||||||
|
|
||||||
|
const mainScrollable = Math.max(0, props.totalVirtualWeeks * props.rowHeight - props.viewportHeight)
|
||||||
|
const jogScrollable = Math.max(0, jogwheelContent.value.scrollHeight - jogwheelViewport.value.clientHeight)
|
||||||
|
|
||||||
|
if (mainScrollable > 0) {
|
||||||
|
const ratio = mainScrollTop / mainScrollable
|
||||||
|
jogwheelViewport.value.scrollTop = ratio * jogScrollable
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (syncLock.value === 'jogwheel') syncLock.value = null
|
||||||
|
}, 50)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch for main calendar scroll changes
|
||||||
|
watch(() => props.scrollTop, (newScrollTop) => {
|
||||||
|
syncFromMain(newScrollTop)
|
||||||
|
})
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
syncFromMain
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.jogwheel-viewport {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 3rem; /* Use fixed width since minmax() doesn't work for absolute positioning */
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
scrollbar-width: none;
|
||||||
|
z-index: 20;
|
||||||
|
cursor: ns-resize;
|
||||||
|
background: rgba(0, 0, 0, 0.05); /* Temporary background to see the area */
|
||||||
|
/* Prevent text selection */
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-touch-callout: none;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jogwheel-viewport::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jogwheel-content {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
66
src/components/WeekRow.vue
Normal file
66
src/components/WeekRow.vue
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
<template>
|
||||||
|
<div class="week-row">
|
||||||
|
<div class="week-label">W{{ weekNumber }}</div>
|
||||||
|
<div class="days-grid">
|
||||||
|
<DayCell v-for="day in days" :key="day.dateStr" :day="day" />
|
||||||
|
<div class="week-overlay">
|
||||||
|
<!-- Event spans will be rendered here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="monthLabel" class="month-name-label" :style="{ height: `${monthLabel.weeksSpan * 64}px` }">
|
||||||
|
<span>{{ monthLabel.name }} '{{ monthLabel.year }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import DayCell from './DayCell.vue'
|
||||||
|
import { isoWeekInfo, toLocalString, getLocalizedMonthName, monthAbbr } from '@/utils/date'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
week: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const weekNumber = computed(() => {
|
||||||
|
return isoWeekInfo(props.week.monday).week
|
||||||
|
})
|
||||||
|
|
||||||
|
const days = computed(() => {
|
||||||
|
const d = new Date(props.week.monday)
|
||||||
|
const result = []
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
const dateStr = toLocalString(d)
|
||||||
|
result.push({
|
||||||
|
date: new Date(d),
|
||||||
|
dateStr,
|
||||||
|
dayOfMonth: d.getDate(),
|
||||||
|
month: d.getMonth(),
|
||||||
|
isFirstDayOfMonth: d.getDate() === 1,
|
||||||
|
monthClass: monthAbbr[d.getMonth()]
|
||||||
|
})
|
||||||
|
d.setDate(d.getDate() + 1)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
const monthLabel = computed(() => {
|
||||||
|
const firstDayOfMonth = days.value.find(d => d.isFirstDayOfMonth)
|
||||||
|
if (!firstDayOfMonth) return null
|
||||||
|
|
||||||
|
const month = firstDayOfMonth.month
|
||||||
|
const year = firstDayOfMonth.date.getFullYear()
|
||||||
|
|
||||||
|
// This is a simplified calculation for weeksSpan
|
||||||
|
const weeksSpan = 4
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: getLocalizedMonthName(month),
|
||||||
|
year: String(year).slice(-2),
|
||||||
|
weeksSpan
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
@ -1,5 +1,8 @@
|
|||||||
|
import './assets/calendar.css'
|
||||||
|
|
||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
|
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
@ -7,3 +10,4 @@ const app = createApp(App)
|
|||||||
app.use(createPinia())
|
app.use(createPinia())
|
||||||
|
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
|
|
||||||
|
325
src/stores/CalendarStore.js
Normal file
325
src/stores/CalendarStore.js
Normal file
@ -0,0 +1,325 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { toLocalString, fromLocalString } from '@/utils/date'
|
||||||
|
|
||||||
|
const MIN_YEAR = 1900
|
||||||
|
const MAX_YEAR = 2100
|
||||||
|
|
||||||
|
export const useCalendarStore = defineStore('calendar', {
|
||||||
|
state: () => ({
|
||||||
|
today: toLocalString(new Date()),
|
||||||
|
now: new Date(),
|
||||||
|
events: new Map(), // Map of date strings to arrays of events
|
||||||
|
weekend: [true, false, false, false, false, false, true], // Sunday to Saturday
|
||||||
|
config: {
|
||||||
|
select_days: 1000,
|
||||||
|
min_year: MIN_YEAR,
|
||||||
|
max_year: MAX_YEAR
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
getters: {
|
||||||
|
// Basic configuration getters
|
||||||
|
minYear: () => MIN_YEAR,
|
||||||
|
maxYear: () => MAX_YEAR
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
updateCurrentDate() {
|
||||||
|
this.now = new Date()
|
||||||
|
const today = toLocalString(this.now)
|
||||||
|
if (this.today !== today) {
|
||||||
|
this.today = today
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Event management
|
||||||
|
generateId() {
|
||||||
|
try {
|
||||||
|
if (window.crypto && typeof window.crypto.randomUUID === 'function') {
|
||||||
|
return window.crypto.randomUUID()
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return 'e-' + Math.random().toString(36).slice(2, 10) + '-' + Date.now().toString(36)
|
||||||
|
},
|
||||||
|
|
||||||
|
createEvent(eventData) {
|
||||||
|
const singleDay = eventData.startDate === eventData.endDate
|
||||||
|
const event = {
|
||||||
|
id: this.generateId(),
|
||||||
|
title: eventData.title,
|
||||||
|
startDate: eventData.startDate,
|
||||||
|
endDate: eventData.endDate,
|
||||||
|
colorId: eventData.colorId ?? this.selectEventColorId(eventData.startDate, eventData.endDate),
|
||||||
|
startTime: singleDay ? (eventData.startTime || '09:00') : null,
|
||||||
|
durationMinutes: singleDay ? (eventData.durationMinutes || 60) : null,
|
||||||
|
repeat: eventData.repeat || 'none',
|
||||||
|
repeatCount: eventData.repeatCount || 'unlimited',
|
||||||
|
isRepeating: (eventData.repeat && eventData.repeat !== 'none')
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDate = new Date(fromLocalString(event.startDate))
|
||||||
|
const endDate = new Date(fromLocalString(event.endDate))
|
||||||
|
|
||||||
|
for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) {
|
||||||
|
const dateStr = toLocalString(d)
|
||||||
|
if (!this.events.has(dateStr)) {
|
||||||
|
this.events.set(dateStr, [])
|
||||||
|
}
|
||||||
|
this.events.get(dateStr).push({ ...event, isSpanning: startDate < endDate })
|
||||||
|
}
|
||||||
|
return event.id
|
||||||
|
},
|
||||||
|
|
||||||
|
getEventById(id) {
|
||||||
|
for (const [, list] of this.events) {
|
||||||
|
const found = list.find(e => e.id === id)
|
||||||
|
if (found) return found
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
|
||||||
|
selectEventColorId(startDateStr, endDateStr) {
|
||||||
|
const colorCounts = [0, 0, 0, 0, 0, 0, 0, 0]
|
||||||
|
const startDate = new Date(fromLocalString(startDateStr))
|
||||||
|
const endDate = new Date(fromLocalString(endDateStr))
|
||||||
|
|
||||||
|
for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) {
|
||||||
|
const dateStr = toLocalString(d)
|
||||||
|
const dayEvents = this.events.get(dateStr) || []
|
||||||
|
for (const event of dayEvents) {
|
||||||
|
if (event.colorId >= 0 && event.colorId < 8) {
|
||||||
|
colorCounts[event.colorId]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let minCount = colorCounts[0]
|
||||||
|
let selectedColor = 0
|
||||||
|
|
||||||
|
for (let colorId = 1; colorId < 8; colorId++) {
|
||||||
|
if (colorCounts[colorId] < minCount) {
|
||||||
|
minCount = colorCounts[colorId]
|
||||||
|
selectedColor = colorId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return selectedColor
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteEvent(eventId) {
|
||||||
|
const datesToCleanup = []
|
||||||
|
for (const [dateStr, eventList] of this.events) {
|
||||||
|
const eventIndex = eventList.findIndex(event => event.id === eventId)
|
||||||
|
if (eventIndex !== -1) {
|
||||||
|
eventList.splice(eventIndex, 1)
|
||||||
|
if (eventList.length === 0) {
|
||||||
|
datesToCleanup.push(dateStr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
datesToCleanup.forEach(dateStr => this.events.delete(dateStr))
|
||||||
|
},
|
||||||
|
|
||||||
|
updateEvent(eventId, updates) {
|
||||||
|
// Remove event from current dates
|
||||||
|
for (const [dateStr, eventList] of this.events) {
|
||||||
|
const index = eventList.findIndex(e => e.id === eventId)
|
||||||
|
if (index !== -1) {
|
||||||
|
const event = eventList[index]
|
||||||
|
eventList.splice(index, 1)
|
||||||
|
if (eventList.length === 0) {
|
||||||
|
this.events.delete(dateStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create updated event and add to new date range
|
||||||
|
const updatedEvent = { ...event, ...updates }
|
||||||
|
this._addEventToDateRange(updatedEvent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Minimal public API for component-driven drag
|
||||||
|
setEventRange(eventId, startDate, endDate) {
|
||||||
|
const snapshot = this._snapshotBaseEvent(eventId)
|
||||||
|
if (!snapshot) return
|
||||||
|
this._removeEventFromAllDatesById(eventId)
|
||||||
|
this._addEventToDateRangeWithId(eventId, snapshot, startDate, endDate)
|
||||||
|
},
|
||||||
|
|
||||||
|
splitRepeatSeries(baseId, index, startDate, endDate) {
|
||||||
|
const base = this.getEventById(baseId)
|
||||||
|
if (!base) return null
|
||||||
|
|
||||||
|
const originalRepeatCount = base.repeatCount
|
||||||
|
|
||||||
|
this._terminateRepeatSeriesAtIndex(baseId, index)
|
||||||
|
|
||||||
|
let newRepeatCount = 'unlimited'
|
||||||
|
if (originalRepeatCount !== 'unlimited') {
|
||||||
|
const originalCount = parseInt(originalRepeatCount, 10)
|
||||||
|
const remaining = originalCount - index
|
||||||
|
newRepeatCount = remaining > 0 ? String(remaining) : '0'
|
||||||
|
}
|
||||||
|
|
||||||
|
const newId = this.createEvent({
|
||||||
|
title: base.title,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
colorId: base.colorId,
|
||||||
|
repeat: base.repeat,
|
||||||
|
repeatCount: newRepeatCount
|
||||||
|
})
|
||||||
|
return newId
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
_snapshotBaseEvent(eventId) {
|
||||||
|
// Return a shallow snapshot of any instance for metadata
|
||||||
|
for (const [, eventList] of this.events) {
|
||||||
|
const e = eventList.find(x => x.id === eventId)
|
||||||
|
if (e) return { ...e }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
|
||||||
|
_removeEventFromAllDatesById(eventId) {
|
||||||
|
for (const [dateStr, list] of this.events) {
|
||||||
|
for (let i = list.length - 1; i >= 0; i--) {
|
||||||
|
if (list[i].id === eventId) {
|
||||||
|
list.splice(i, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (list.length === 0) this.events.delete(dateStr)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_addEventToDateRangeWithId(eventId, baseData, startDate, endDate) {
|
||||||
|
const s = fromLocalString(startDate)
|
||||||
|
const e = fromLocalString(endDate)
|
||||||
|
const multi = startDate < endDate
|
||||||
|
const payload = {
|
||||||
|
...baseData,
|
||||||
|
id: eventId,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
isSpanning: multi
|
||||||
|
}
|
||||||
|
// Normalize single-day time fields
|
||||||
|
if (!multi) {
|
||||||
|
if (!payload.startTime) payload.startTime = '09:00'
|
||||||
|
if (!payload.durationMinutes) payload.durationMinutes = 60
|
||||||
|
} else {
|
||||||
|
payload.startTime = null
|
||||||
|
payload.durationMinutes = null
|
||||||
|
}
|
||||||
|
const cur = new Date(s)
|
||||||
|
while (cur <= e) {
|
||||||
|
const dateStr = toLocalString(cur)
|
||||||
|
if (!this.events.has(dateStr)) this.events.set(dateStr, [])
|
||||||
|
this.events.get(dateStr).push({ ...payload })
|
||||||
|
cur.setDate(cur.getDate() + 1)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_reindexBaseEvent(eventId, snapshot, startDate, endDate) {
|
||||||
|
if (!snapshot) return
|
||||||
|
this._removeEventFromAllDatesById(eventId)
|
||||||
|
this._addEventToDateRangeWithId(eventId, snapshot, startDate, endDate)
|
||||||
|
},
|
||||||
|
|
||||||
|
_terminateRepeatSeriesAtIndex(baseId, index) {
|
||||||
|
// Reduce repeatCount of base series to the given index
|
||||||
|
for (const [, list] of this.events) {
|
||||||
|
for (const ev of list) {
|
||||||
|
if (ev.id === baseId && ev.isRepeating) {
|
||||||
|
const rc = ev.repeatCount === 'unlimited' ? Infinity : parseInt(ev.repeatCount, 10)
|
||||||
|
const newCount = Math.min(isFinite(rc) ? rc : index, index)
|
||||||
|
ev.repeatCount = String(newCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_findEventInAnyList(eventId) {
|
||||||
|
for (const [, eventList] of this.events) {
|
||||||
|
const found = eventList.find(e => e.id === eventId)
|
||||||
|
if (found) return found
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
|
||||||
|
_addEventToDateRange(event) {
|
||||||
|
const startDate = fromLocalString(event.startDate)
|
||||||
|
const endDate = fromLocalString(event.endDate)
|
||||||
|
const cur = new Date(startDate)
|
||||||
|
|
||||||
|
while (cur <= endDate) {
|
||||||
|
const dateStr = toLocalString(cur)
|
||||||
|
if (!this.events.has(dateStr)) {
|
||||||
|
this.events.set(dateStr, [])
|
||||||
|
}
|
||||||
|
this.events.get(dateStr).push({ ...event, isSpanning: event.startDate < event.endDate })
|
||||||
|
cur.setDate(cur.getDate() + 1)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getEventById(id) {
|
||||||
|
// Check for base events first
|
||||||
|
for (const [, list] of this.events) {
|
||||||
|
const found = list.find(e => e.id === id)
|
||||||
|
if (found) return found
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a repeat occurrence ID
|
||||||
|
if (typeof id === 'string' && id.includes('_repeat_')) {
|
||||||
|
const parts = id.split('_repeat_')
|
||||||
|
const baseId = parts[0]
|
||||||
|
const repeatIndex = parseInt(parts[1], 10)
|
||||||
|
|
||||||
|
if (isNaN(repeatIndex)) return null
|
||||||
|
|
||||||
|
const baseEvent = this.getEventById(baseId)
|
||||||
|
if (baseEvent && baseEvent.isRepeating) {
|
||||||
|
// Generate the specific occurrence
|
||||||
|
const baseStartDate = new Date(fromLocalString(baseEvent.startDate))
|
||||||
|
const baseEndDate = new Date(fromLocalString(baseEvent.endDate))
|
||||||
|
const spanDays = Math.floor((baseEndDate - baseStartDate) / (24 * 60 * 60 * 1000))
|
||||||
|
|
||||||
|
const currentStart = new Date(baseStartDate)
|
||||||
|
switch (baseEvent.repeat) {
|
||||||
|
case 'daily':
|
||||||
|
currentStart.setDate(baseStartDate.getDate() + repeatIndex)
|
||||||
|
break
|
||||||
|
case 'weekly':
|
||||||
|
currentStart.setDate(baseStartDate.getDate() + repeatIndex * 7)
|
||||||
|
break
|
||||||
|
case 'biweekly':
|
||||||
|
currentStart.setDate(baseStartDate.getDate() + repeatIndex * 14)
|
||||||
|
break
|
||||||
|
case 'monthly':
|
||||||
|
currentStart.setMonth(baseStartDate.getMonth() + repeatIndex)
|
||||||
|
break
|
||||||
|
case 'yearly':
|
||||||
|
currentStart.setFullYear(baseStartDate.getFullYear() + repeatIndex)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentEnd = new Date(currentStart)
|
||||||
|
currentEnd.setDate(currentStart.getDate() + spanDays)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...baseEvent,
|
||||||
|
id: id,
|
||||||
|
startDate: toLocalString(currentStart),
|
||||||
|
endDate: toLocalString(currentEnd),
|
||||||
|
isRepeatOccurrence: true,
|
||||||
|
repeatIndex: repeatIndex
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
@ -1,12 +0,0 @@
|
|||||||
import { ref, computed } from 'vue'
|
|
||||||
import { defineStore } from 'pinia'
|
|
||||||
|
|
||||||
export const useCounterStore = defineStore('counter', () => {
|
|
||||||
const count = ref(0)
|
|
||||||
const doubleCount = computed(() => count.value * 2)
|
|
||||||
function increment() {
|
|
||||||
count.value++
|
|
||||||
}
|
|
||||||
|
|
||||||
return { count, doubleCount, increment }
|
|
||||||
})
|
|
@ -164,6 +164,6 @@ export {
|
|||||||
addDaysStr,
|
addDaysStr,
|
||||||
getLocalizedWeekdayNames,
|
getLocalizedWeekdayNames,
|
||||||
getLocalizedMonthName,
|
getLocalizedMonthName,
|
||||||
formatDateRange
|
formatDateRange,
|
||||||
,lunarPhaseSymbol
|
lunarPhaseSymbol
|
||||||
}
|
}
|
@ -1,59 +0,0 @@
|
|||||||
/* Prevent text selection in calendar */
|
|
||||||
#calendar-viewport, #calendar-content, .week-row, .cell,
|
|
||||||
.calendar-header, .week-label, .month-name-label,
|
|
||||||
.calendar-container, .jogwheel-viewport, .jogwheel-content {
|
|
||||||
-webkit-user-select: none;
|
|
||||||
-moz-user-select: none;
|
|
||||||
-ms-user-select: none;
|
|
||||||
user-select: none;
|
|
||||||
-webkit-touch-callout: none;
|
|
||||||
-webkit-tap-highlight-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
input {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: var(--ink);
|
|
||||||
width: 11em;
|
|
||||||
}
|
|
||||||
label:has(input[value]) { display: block }
|
|
||||||
|
|
||||||
/* Modal dialog */
|
|
||||||
.ec-modal-backdrop[hidden] { display: none }
|
|
||||||
.ec-modal-backdrop {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: color-mix(in srgb, var(--strong) 30%, transparent);
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
z-index: 1000;
|
|
||||||
}
|
|
||||||
.ec-modal {
|
|
||||||
background: var(--panel);
|
|
||||||
color: var(--ink);
|
|
||||||
border-radius: .6rem;
|
|
||||||
min-width: 320px;
|
|
||||||
max-width: min(520px, 90vw);
|
|
||||||
box-shadow: 0 10px 30px rgba(0,0,0,.35);
|
|
||||||
}
|
|
||||||
.ec-form { padding: 1rem; display: grid; gap: .75rem }
|
|
||||||
.ec-header h2 { margin: 0; font-size: 1.1rem }
|
|
||||||
.ec-body { display: grid; gap: .75rem }
|
|
||||||
.ec-row { display: grid; grid-template-columns: 1fr 1fr; gap: .75rem }
|
|
||||||
.ec-field { display: grid; gap: .25rem }
|
|
||||||
.ec-field > span { font-size: .85em; color: var(--muted) }
|
|
||||||
.ec-field input[type="text"],
|
|
||||||
.ec-field input[type="time"],
|
|
||||||
.ec-field input[type="number"] {
|
|
||||||
border: 1px solid var(--muted);
|
|
||||||
border-radius: .4rem;
|
|
||||||
padding: .5rem .6rem;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.ec-color-swatches { display: grid; grid-template-columns: repeat(4, 1fr); gap: .3rem }
|
|
||||||
.ec-color-swatches .swatch { display: grid; place-items: center; border-radius: .4rem; padding: .25rem; outline: 2px solid transparent; outline-offset: 2px; cursor: pointer }
|
|
||||||
.ec-color-swatches .swatch { appearance: none; width: 3em; height: 1em; }
|
|
||||||
.ec-color-swatches .swatch:checked { outline-color: var(--ink) }
|
|
||||||
.ec-footer { display: flex; justify-content: end; gap: .5rem }
|
|
||||||
.ec-btn { border: 1px solid var(--muted); background: transparent; color: var(--ink); padding: .5rem .8rem; border-radius: .4rem; cursor: pointer }
|
|
||||||
.ec-btn.primary { background: var(--today); color: #000; border-color: transparent }
|
|
Loading…
x
Reference in New Issue
Block a user