Port to Vue. Also implements plenty of new functionality.
This commit is contained in:
2025-08-22 23:34:33 +01:00
parent a92ef0479a
commit 018b9ecc55
35 changed files with 4137 additions and 1717 deletions

View 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>

View File

@@ -0,0 +1,110 @@
<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: hue-rotate(180deg);
}
.cell.selected h1 {
color: var(--strong);
}
.lunar-phase {
position: absolute;
top: 0.1em;
right: 0.1em;
font-size: 0.8em;
opacity: 0.7;
}
</style>

View File

@@ -0,0 +1,184 @@
<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,
getLocaleWeekendDays,
getLocaleFirstDay,
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: getLocaleWeekendDays(),
}
const baseDate = new Date(1970, 0, 4 + getLocaleFirstDay())
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>

View File

@@ -0,0 +1,92 @@
<script setup>
import { computed } from 'vue'
import { useCalendarStore } from '@/stores/CalendarStore'
import { getLocalizedWeekdayNames, reorderByFirstDay, isoWeekInfo } 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(1970, 0, 4 + calendarStore.config.first_day)
const firstDay = new Date(baseDate)
firstDay.setDate(firstDay.getDate() + topVW * 7)
return isoWeekInfo(firstDay).year
})
const weekdayNames = computed(() => {
// Get Monday-first names, then reorder by first day, then add weekend info
const mondayFirstNames = getLocalizedWeekdayNames()
const sundayFirstNames = [mondayFirstNames[6], ...mondayFirstNames.slice(0, 6)]
const reorderedNames = reorderByFirstDay(sundayFirstNames, calendarStore.config.first_day)
const reorderedWeekend = reorderByFirstDay(calendarStore.weekend, calendarStore.config.first_day)
return reorderedNames.map((name, i) => ({
name,
isWeekend: reorderedWeekend[i],
}))
})
</script>
<template>
<div class="calendar-header">
<div class="year-label">{{ yearLabel }}</div>
<div
v-for="day in weekdayNames"
:key="day.name"
class="dow"
:class="{ workday: !day.isWeekend, 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;
}
.dow.weekend {
color: var(--weekend);
}
.dow.workday {
color: var(--workday);
}
.overlay-header-spacer {
grid-area: auto;
}
</style>

View File

@@ -0,0 +1,494 @@
<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 {
isoWeekInfo,
getLocalizedMonthName,
monthAbbr,
lunarPhaseSymbol,
pad,
daysInclusive,
addDaysStr,
formatDateRange,
} from '@/utils/date'
import { toLocalString, fromLocalString } from '@/utils/date'
const calendarStore = useCalendarStore()
const viewport = ref(null)
const emit = defineEmits(['create-event', 'edit-event'])
function createEventFromSelection() {
if (!selection.value.startDate || selection.value.dayCount === 0) return null
return {
startDate: selection.value.startDate,
dayCount: selection.value.dayCount,
endDate: addDaysStr(selection.value.startDate, selection.value.dayCount - 1),
}
}
const scrollTop = ref(0)
const viewportHeight = ref(600)
const rowHeight = ref(64)
const baseDate = new Date(1970, 0, 4 + calendarStore.config.first_day)
const selection = ref({ startDate: null, dayCount: 0 })
const isDragging = ref(false)
const dragAnchor = ref(null)
const WEEK_MS = 7 * 24 * 60 * 60 * 1000
const minVirtualWeek = computed(() => {
const date = new Date(calendarStore.minYear, 0, 1)
const firstDayOfWeek = new Date(date)
const dayOffset = (date.getDay() - calendarStore.config.first_day + 7) % 7
firstDayOfWeek.setDate(date.getDate() - dayOffset)
return Math.floor((firstDayOfWeek - baseDate) / WEEK_MS)
})
const maxVirtualWeek = computed(() => {
const date = new Date(calendarStore.maxYear, 11, 31)
const firstDayOfWeek = new Date(date)
const dayOffset = (date.getDay() - calendarStore.config.first_day + 7) % 7
firstDayOfWeek.setDate(date.getDate() - dayOffset)
return Math.floor((firstDayOfWeek - 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
})
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 firstDayOfWeek = new Date(date)
const dayOffset = (date.getDay() - calendarStore.config.first_day + 7) % 7
firstDayOfWeek.setDate(date.getDate() - dayOffset)
return Math.floor((firstDayOfWeek - baseDate) / WEEK_MS)
}
function getFirstDayForVirtualWeek(virtualWeek) {
const firstDay = new Date(baseDate)
firstDay.setDate(firstDay.getDate() + virtualWeek * 7)
return firstDay
}
function createWeek(virtualWeek) {
const firstDay = getFirstDayForVirtualWeek(virtualWeek)
const weekNumber = isoWeekInfo(firstDay).week
const days = []
const cur = new Date(firstDay)
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.startDate &&
selection.value.dayCount > 0 &&
dateStr >= selection.value.startDate &&
dateStr <= addDaysStr(selection.value.startDate, selection.value.dayCount - 1),
events: eventsForDay,
})
cur.setDate(cur.getDate() + 1)
}
let monthLabel = null
if (hasFirst && monthToLabel !== null) {
if (labelYear && labelYear <= calendarStore.config.max_year) {
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, 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 = { startDate: null, dayCount: 0 }
}
function startDrag(dateStr) {
if (calendarStore.config.select_days === 0) return
isDragging.value = true
dragAnchor.value = dateStr
selection.value = { startDate: dateStr, dayCount: 1 }
}
function updateDrag(dateStr) {
if (!isDragging.value) return
const { startDate, dayCount } = calculateSelection(dragAnchor.value, dateStr)
selection.value = { startDate, dayCount }
}
function endDrag(dateStr) {
if (!isDragging.value) return
isDragging.value = false
const { startDate, dayCount } = calculateSelection(dragAnchor.value, dateStr)
selection.value = { startDate, dayCount }
}
function calculateSelection(anchorStr, otherStr) {
const limit = calendarStore.config.select_days
const anchorDate = fromLocalString(anchorStr)
const otherDate = fromLocalString(otherStr)
const forward = otherDate >= anchorDate
const span = daysInclusive(anchorStr, otherStr)
if (span <= limit) {
const startDate = forward ? anchorStr : otherStr
return { startDate, dayCount: span }
}
if (forward) {
return { startDate: anchorStr, dayCount: limit }
} else {
const startDate = addDaysStr(anchorStr, -(limit - 1))
return { startDate, dayCount: limit }
}
}
const onScroll = () => {
if (viewport.value) {
scrollTop.value = viewport.value.scrollTop
}
}
const handleJogwheelScrollTo = (newScrollTop) => {
if (viewport.value) {
viewport.value.scrollTop = newScrollTop
}
}
onMounted(() => {
computeRowHeight()
calendarStore.updateCurrentDate()
if (viewport.value) {
viewportHeight.value = viewport.value.clientHeight
viewport.value.scrollTop = initialScrollTop.value
viewport.value.addEventListener('scroll', onScroll)
}
const timer = setInterval(() => {
calendarStore.updateCurrentDate()
}, 60000)
onBeforeUnmount(() => {
clearInterval(timer)
})
})
onBeforeUnmount(() => {
if (viewport.value) {
viewport.value.removeEventListener('scroll', onScroll)
}
})
const handleDayMouseDown = (dateStr) => {
startDrag(dateStr)
}
const handleDayMouseEnter = (dateStr) => {
if (isDragging.value) {
updateDrag(dateStr)
}
}
const handleDayMouseUp = (dateStr) => {
if (isDragging.value) {
endDrag(dateStr)
const eventData = createEventFromSelection()
if (eventData) {
clearSelection()
emit('create-event', eventData)
}
}
}
const handleDayTouchStart = (dateStr) => {
startDrag(dateStr)
}
const handleDayTouchMove = (dateStr) => {
if (isDragging.value) {
updateDrag(dateStr)
}
}
const handleDayTouchEnd = (dateStr) => {
if (isDragging.value) {
endDrag(dateStr)
const eventData = createEventFromSelection()
if (eventData) {
clearSelection()
emit('create-event', eventData)
}
}
}
const handleEventClick = (eventInstanceId) => {
emit('edit-event', eventInstanceId)
}
</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>
</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>

View 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>

View 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>

View File

@@ -0,0 +1,933 @@
<script setup>
import { useCalendarStore } from '@/stores/CalendarStore'
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import WeekdaySelector from './WeekdaySelector.vue'
import Numeric from './Numeric.vue'
import { addDaysStr } from '@/utils/date'
const props = defineProps({
selection: { type: Object, default: () => ({ startDate: null, dayCount: 0 }) },
})
const emit = defineEmits(['clear-selection'])
const calendarStore = useCalendarStore()
const showDialog = ref(false)
const dialogMode = ref('create') // 'create' or 'edit'
const editingEventId = ref(null) // base event id if repeating occurrence clicked
const occurrenceContext = ref(null) // { baseId, occurrenceIndex, weekday, occurrenceDate }
const title = ref('')
const recurrenceEnabled = ref(false)
const recurrenceInterval = ref(1) // N in "Every N weeks/months"
const recurrenceFrequency = ref('weeks') // 'weeks' | 'months'
const recurrenceWeekdays = ref([false, false, false, false, false, false, false])
const recurrenceOccurrences = ref(0) // 0 = unlimited
const colorId = ref(0)
const eventSaved = ref(false)
const titleInput = ref(null)
// Helper to get starting weekday (Sunday-first index)
function getStartingWeekday(selectionData = null) {
const currentSelection = selectionData || props.selection
if (!currentSelection.start) return 0 // Default to Sunday
const date = new Date(currentSelection.start + 'T00:00:00')
const dayOfWeek = date.getDay() // 0=Sunday, 1=Monday, ...
return dayOfWeek // Keep Sunday-first (0=Sunday, 6=Saturday)
}
// Computed property for fallback weekdays - true for the initial day of the event, false for others
const fallbackWeekdays = computed(() => {
const startingDay = getStartingWeekday()
const fallback = [false, false, false, false, false, false, false]
fallback[startingDay] = true
return fallback
})
// Repeat mapping uses 'weeks' | 'months' | 'none' directly (legacy 'weekly'/'monthly' accepted on load)
const repeat = computed({
get() {
if (!recurrenceEnabled.value) return 'none'
return recurrenceFrequency.value // 'weeks' | 'months'
},
set(val) {
if (val === 'none') {
recurrenceEnabled.value = false
return
}
recurrenceEnabled.value = true
if (val === 'weeks' || val === 'weekly') recurrenceFrequency.value = 'weeks'
else if (val === 'months' || val === 'monthly') recurrenceFrequency.value = 'months'
},
})
// Convert Sunday-first recurrenceWeekdays to Sunday-first pattern for store
function buildStoreWeekdayPattern() {
// store expects Sun..Sat; we have Sun..Sat
// Direct mapping: recurrenceWeekdays indices 0..6 (Sun..Sat) -> store array [Sun,Mon,Tue,Wed,Thu,Fri,Sat]
let sunFirst = [...recurrenceWeekdays.value]
// Ensure at least one day is selected - fallback to starting day
if (!sunFirst.some(Boolean)) {
const startingDay = getStartingWeekday()
sunFirst[startingDay] = true
}
return sunFirst
}
function loadWeekdayPatternFromStore(storePattern) {
if (!Array.isArray(storePattern) || storePattern.length !== 7) return
// store: Sun..Sat -> keep as Sun..Sat
recurrenceWeekdays.value = [...storePattern]
}
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(selectionData = null) {
const currentSelection = selectionData || props.selection
// Convert new format to start/end for compatibility with existing logic
let start, end
if (currentSelection.startDate && currentSelection.dayCount) {
start = currentSelection.startDate
end = addDaysStr(currentSelection.startDate, currentSelection.dayCount - 1)
} else if (currentSelection.start && currentSelection.end) {
// Fallback for old format
start = currentSelection.start
end = currentSelection.end
} else {
start = null
end = null
}
occurrenceContext.value = null
dialogMode.value = 'create'
title.value = ''
recurrenceEnabled.value = false
recurrenceInterval.value = 1
recurrenceFrequency.value = 'weeks'
recurrenceWeekdays.value = [false, false, false, false, false, false, false]
recurrenceOccurrences.value = 0
colorId.value = calendarStore.selectEventColorId(start, end)
eventSaved.value = false
const startingDay = getStartingWeekday({ start, end })
recurrenceWeekdays.value[startingDay] = true
editingEventId.value = calendarStore.createEvent({
title: '',
startDate: start,
endDate: end,
colorId: colorId.value,
repeat: repeat.value,
repeatInterval: recurrenceInterval.value,
repeatCount:
recurrenceOccurrences.value === 0 ? 'for now' : String(recurrenceOccurrences.value),
repeatWeekdays: buildStoreWeekdayPattern(),
})
showDialog.value = true
// Focus and select text after dialog is shown
nextTick(() => {
if (titleInput.value) {
titleInput.value.focus()
if (title.value) {
titleInput.value.select()
}
}
})
}
function openEditDialog(eventInstanceId) {
occurrenceContext.value = null
let baseId = eventInstanceId
let occurrenceIndex = 0
let weekday = null
let occurrenceDate = null
if (typeof eventInstanceId === 'string' && eventInstanceId.includes('_repeat_')) {
const [bid, suffix] = eventInstanceId.split('_repeat_')
baseId = bid
const parts = suffix.split('_')
occurrenceIndex = parseInt(parts[0], 10) || 0
if (parts.length > 1) weekday = parseInt(parts[1], 10)
}
const event = calendarStore.getEventById(baseId)
if (!event) return
// Derive occurrence date if weekly occurrence
if (weekday != null) {
// Recompute occurrence date: iterate days accumulating selected weekdays
const repeatWeekdaysLocal = event.repeatWeekdays
let idx = 0
let cur = new Date(event.startDate + 'T00:00:00')
while (idx < occurrenceIndex && idx < 10000) {
// safety bound
cur.setDate(cur.getDate() + 1)
if (repeatWeekdaysLocal[cur.getDay()]) idx++
}
occurrenceDate = cur
}
dialogMode.value = 'edit'
editingEventId.value = baseId
title.value = event.title
loadWeekdayPatternFromStore(event.repeatWeekdays)
repeat.value = event.repeat // triggers setter mapping into recurrence state
if (event.repeatInterval) recurrenceInterval.value = event.repeatInterval
// Map repeatCount
const rc = event.repeatCount ?? 'unlimited'
recurrenceOccurrences.value = rc === 'unlimited' ? 0 : parseInt(rc, 10) || 0
colorId.value = event.colorId
eventSaved.value = false
if (event.isRepeating && occurrenceIndex >= 0 && weekday != null) {
occurrenceContext.value = { baseId, occurrenceIndex, weekday, occurrenceDate }
}
showDialog.value = true
// Focus and select text after dialog is shown
nextTick(() => {
if (titleInput.value) {
titleInput.value.focus()
if (title.value) {
titleInput.value.select()
}
}
})
}
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
event.repeatInterval = recurrenceInterval.value
event.repeatWeekdays = buildStoreWeekdayPattern()
event.repeatCount =
recurrenceOccurrences.value === 0 ? 'unlimited' : String(recurrenceOccurrences.value)
event.isRepeating = recurrenceEnabled.value && repeat.value !== 'none'
}
}
}
}
function saveEvent() {
if (editingEventId.value) {
updateEventInStore()
}
eventSaved.value = true
if (dialogMode.value === 'create') {
emit('clear-selection')
}
closeDialog()
}
function deleteEventAll() {
if (editingEventId.value) calendarStore.deleteEvent(editingEventId.value)
closeDialog()
}
function deleteEventOne() {
if (occurrenceContext.value) {
calendarStore.deleteSingleOccurrence(occurrenceContext.value)
} else if (isRepeatingBaseEdit.value && editingEventId.value) {
calendarStore.deleteFirstOccurrence(editingEventId.value)
}
closeDialog()
}
function deleteEventFrom() {
if (!occurrenceContext.value) return
calendarStore.deleteFromOccurrence(occurrenceContext.value)
closeDialog()
}
function toggleWeekday(index) {
recurrenceWeekdays.value[index] = !recurrenceWeekdays.value[index]
}
// Watch for title changes and update the event immediately
watch(title, (newTitle) => {
if (editingEventId.value && showDialog.value) {
updateEventInStore()
}
})
watch([recurrenceEnabled, recurrenceInterval, recurrenceFrequency], () => {
if (editingEventId.value && showDialog.value) updateEventInStore()
})
watch(
recurrenceWeekdays,
() => {
if (editingEventId.value && showDialog.value && repeat.value === 'weeks') updateEventInStore()
},
{ deep: true },
)
watch(recurrenceOccurrences, () => {
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,
})
// Computed helpers for delete UI
const isRepeatingEdit = computed(
() => dialogMode.value === 'edit' && recurrenceEnabled.value && repeat.value !== 'none',
)
const showDeleteVariants = computed(() => isRepeatingEdit.value && occurrenceContext.value)
const isRepeatingBaseEdit = computed(() => isRepeatingEdit.value && !occurrenceContext.value)
const formattedOccurrenceShort = computed(() => {
if (occurrenceContext.value?.occurrenceDate) {
try {
return occurrenceContext.value.occurrenceDate
.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
.replace(/, /, ' ')
} catch {
/* noop */
}
}
if (isRepeatingBaseEdit.value && editingEventId.value) {
const ev = calendarStore.getEventById(editingEventId.value)
if (ev?.startDate) {
try {
return new Date(ev.startDate + 'T00:00:00')
.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
.replace(/, /, ' ')
} catch {
/* noop */
}
}
}
return ''
})
const finalOccurrenceDate = computed(() => {
if (!recurrenceEnabled.value) return null
const count = recurrenceOccurrences.value
if (!count || count < 1) return null // unlimited or invalid
// Need start date
const base = editingEventId.value ? calendarStore.getEventById(editingEventId.value) : null
if (!base) return null
const start = new Date(base.startDate + 'T00:00:00')
if (recurrenceFrequency.value === 'weeks') {
// iterate days until we count 'count-1' additional occurrences (first is base if selected weekday)
const pattern = buildStoreWeekdayPattern() // Sun..Sat
// Build Monday-first pattern again for selection clarity
const monFirst = recurrenceWeekdays.value
const selectedCount = monFirst.some(Boolean)
if (!selectedCount) return null
let occs = 0
// Determine if the start day counts
const startWeekdaySun = start.getDay()
// Convert to Monday-first index
// We'll just check store pattern
if (pattern[startWeekdaySun]) occs = 1
let cursor = new Date(start)
while (occs < count && occs < 10000) {
cursor.setDate(cursor.getDate() + 1)
if (pattern[cursor.getDay()]) occs++
}
if (occs === count) return cursor
return null
} else if (recurrenceFrequency.value === 'months') {
const monthsToAdd = recurrenceInterval.value * (count - 1)
const d = new Date(start)
d.setMonth(d.getMonth() + monthsToAdd)
return d
}
})
const formattedFinalOccurrence = computed(() => {
const d = finalOccurrenceDate.value
if (!d) return ''
const now = new Date()
const includeYear =
d.getFullYear() !== now.getFullYear() ||
d.getTime() - now.getTime() >= 1000 * 60 * 60 * 24 * 365
const opts = {
weekday: 'short',
month: 'short',
day: 'numeric',
...(includeYear ? { year: 'numeric' } : {}),
}
try {
return d.toLocaleDateString(undefined, opts)
} catch {
return d.toDateString()
}
})
const recurrenceSummary = computed(() => {
if (!recurrenceEnabled.value) return 'Does not recur'
if (recurrenceFrequency.value === 'weeks') {
return recurrenceInterval.value === 1 ? 'Weekly' : `Every ${recurrenceInterval.value} weeks`
}
// months frequency
if (recurrenceInterval.value % 12 === 0) {
const years = recurrenceInterval.value / 12
return years === 1 ? 'Annually' : `Every ${years} years`
}
return recurrenceInterval.value === 1 ? 'Monthly' : `Every ${recurrenceInterval.value} months`
})
</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" ref="titleInput" />
</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 class="recurrence-block">
<div class="recurrence-header">
<label class="switch">
<input type="checkbox" v-model="recurrenceEnabled" />
<span>Repeat</span>
</label>
<span class="recurrence-summary" v-if="recurrenceEnabled">
{{ recurrenceSummary }}
<template v-if="recurrenceOccurrences > 0">
until {{ formattedFinalOccurrence }}</template
>
</span>
<span class="recurrence-summary muted" v-else>Does not recur</span>
</div>
<div v-if="recurrenceEnabled" class="recurrence-form">
<div class="line compact">
<Numeric
v-model="recurrenceInterval"
:prefix-values="[{ value: 1, display: 'Every' }]"
:min="2"
number-prefix="Every "
aria-label="Interval"
/>
<select v-model="recurrenceFrequency" class="freq-select">
<option value="weeks">{{ recurrenceInterval === 1 ? 'week' : 'weeks' }}</option>
<option value="months">
{{ recurrenceInterval === 1 ? 'month' : 'months' }}
</option>
</select>
<Numeric
class="occ-stepper"
v-model="recurrenceOccurrences"
:min="2"
:prefix-values="[{ value: 0, display: '' }]"
number-postfix=" times"
aria-label="Occurrences (0 = no end)"
extra-class="occ"
/>
</div>
<div v-if="recurrenceFrequency === 'weeks'" @click.stop>
<WeekdaySelector v-model="recurrenceWeekdays" :fallback="fallbackWeekdays" />
</div>
</div>
</div>
</div>
<footer class="ec-footer">
<template v-if="dialogMode === 'create'">
<button type="button" class="ec-btn delete-btn" @click="deleteEventAll">Delete</button>
<button type="submit" class="ec-btn save-btn">Save</button>
</template>
<template v-else>
<template v-if="showDeleteVariants">
<div class="ec-delete-group">
<button type="button" class="ec-btn delete-btn" @click="deleteEventOne">
Delete {{ formattedOccurrenceShort }}
</button>
<button type="button" class="ec-btn delete-btn" @click="deleteEventFrom">
Rest
</button>
<button type="button" class="ec-btn delete-btn" @click="deleteEventAll">All</button>
</div>
</template>
<template v-else-if="isRepeatingBaseEdit">
<div class="ec-delete-group">
<button type="button" class="ec-btn delete-btn" @click="deleteEventOne">
Delete {{ formattedOccurrenceShort }}
</button>
<button type="button" class="ec-btn delete-btn" @click="deleteEventAll">All</button>
</div>
</template>
<template v-else>
<button type="button" class="ec-btn delete-btn" @click="deleteEventAll">
Delete
</button>
</template>
<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%);
}
.ec-weekday-selector {
display: grid;
gap: 0.5rem;
}
.ec-field-label {
font-size: 0.85em;
color: var(--muted);
}
.ec-weekdays {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 0.25rem;
}
.ec-weekday-label {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
cursor: pointer;
padding: 0.5rem 0.25rem;
border-radius: 0.3rem;
transition: background-color 0.2s ease;
}
.ec-weekday-label:hover {
background: var(--muted);
}
.ec-weekday-checkbox {
margin: 0;
}
.ec-weekday-text {
font-size: 0.8em;
font-weight: 500;
text-align: center;
}
/* New recurrence block */
.recurrence-block {
display: grid;
gap: 0.6rem;
}
.recurrence-header {
display: flex;
align-items: center;
gap: 0.75rem;
}
.recurrence-header .recurrence-summary {
font-size: 0.75rem;
color: var(--ink);
opacity: 0.85;
}
.recurrence-header .recurrence-summary.muted {
opacity: 0.5;
}
.switch {
display: inline-flex;
align-items: center;
gap: 0.4rem;
cursor: pointer;
font-size: 0.85rem;
}
.switch input {
width: 1rem;
height: 1rem;
}
.recurrence-form {
display: grid;
gap: 0.6rem;
padding: 0.6rem 0.75rem 0.75rem;
border: 1px solid var(--muted);
border-radius: 0.5rem;
background: color-mix(in srgb, var(--muted) 15%, transparent);
}
.line.compact {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
font-size: 0.75rem;
}
.freq-select {
padding: 0.4rem 0.55rem;
font-size: 0.75rem;
border: 1px solid var(--input-border);
background: var(--panel-alt);
color: var(--ink);
border-radius: 0.45rem;
transition:
border-color 0.18s ease,
background-color 0.18s ease;
}
.freq-select:focus {
outline: none;
border-color: var(--input-focus);
background: var(--panel-accent);
color: var(--ink);
box-shadow:
0 0 0 1px var(--input-focus),
0 0 0 4px rgba(37, 99, 235, 0.15);
}
.interval-input,
.occ-input {
display: none;
}
.ec-field input[type='text'] {
border: 1px solid var(--input-border);
background: var(--panel-alt);
border-radius: 0.45rem;
padding: 0.4rem 0.5rem;
transition:
border-color 0.18s ease,
background-color 0.18s ease,
box-shadow 0.18s ease;
}
.ec-field input[type='text']:focus {
outline: none;
border-color: var(--input-focus);
background: var(--panel-accent);
box-shadow:
0 0 0 1px var(--input-focus),
0 0 0 4px rgba(37, 99, 235, 0.15);
}
.mini-stepper {
display: inline-flex;
align-items: center;
background: var(--panel-alt);
border: 1px solid var(--input-border);
border-radius: 0.5rem;
overflow: hidden;
font-size: 0.7rem;
height: 1.9rem;
}
.mini-stepper .step {
background: transparent;
border: none;
color: var(--ink);
padding: 0 0.55rem;
cursor: pointer;
font-size: 0.9rem;
line-height: 1;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
transition:
background-color 0.15s ease,
color 0.15s ease;
}
.mini-stepper .step:hover:not(:disabled) {
background: var(--pill-hover-bg);
}
.mini-stepper .step:disabled {
opacity: 0.35;
cursor: default;
}
.mini-stepper .value {
min-width: 1.6rem;
text-align: center;
font-variant-numeric: tabular-nums;
font-weight: 600;
letter-spacing: 0.02em;
}
.mini-stepper:focus-within {
border-color: var(--input-focus);
box-shadow:
0 0 0 1px var(--input-focus),
0 0 0 4px rgba(37, 99, 235, 0.15);
}
.mini-stepper.occ .value {
min-width: 2rem;
}
.occ-stepper.mini-stepper.occ .value {
min-width: 2rem;
}
.mini-stepper .step:focus-visible {
outline: 2px solid var(--input-focus);
outline-offset: -2px;
}
.hint {
font-size: 0.65rem;
opacity: 0.65;
}
/* Recurrence UI */
.ec-recurrence-section {
display: grid;
gap: 0.4rem;
}
.ec-recurrence-toggle {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
padding: 0.6rem 0.8rem;
border: 1px solid var(--muted);
background: var(--panel);
border-radius: 0.4rem;
cursor: pointer;
font-size: 0.9rem;
text-align: left;
transition: background-color 0.15s ease;
}
.ec-recurrence-toggle:hover {
background: var(--muted);
}
.ec-recurrence-toggle .toggle-icon {
transition: transform 0.2s ease;
font-size: 0.7rem;
color: var(--muted);
}
.ec-recurrence-toggle .toggle-icon.open {
transform: rotate(180deg);
}
.ec-recurrence-panel {
display: grid;
gap: 0.6rem;
padding: 0.6rem;
border: 1px solid var(--muted);
border-radius: 0.4rem;
background: color-mix(in srgb, var(--muted) 20%, transparent);
}
/* Repeat modes */
.ec-repeat-modes {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.ec-repeat-modes .mode-btn {
flex: 1 1 auto;
padding: 0.4rem 0.6rem;
border: 1px solid var(--muted);
background: var(--panel);
border-radius: 0.4rem;
cursor: pointer;
font-size: 0.75rem;
line-height: 1.1;
white-space: nowrap;
transition:
background-color 0.15s ease,
color 0.15s ease,
border-color 0.15s ease;
}
.ec-repeat-modes .mode-btn.active {
background: var(--today);
color: #000;
border-color: var(--today);
font-weight: 600;
}
.ec-repeat-modes .mode-btn:hover {
background: var(--muted);
}
.ec-occurrences-field {
margin-top: 0.2rem;
}
.ec-occurrences-field .ec-field input[type='number'] {
max-width: 6rem;
}
</style>

View File

@@ -0,0 +1,620 @@
<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))
if (baseEvent.repeat === 'weeks') {
const repeatWeekdays = baseEvent.repeatWeekdays
if (targetDate < baseStartDate) continue
const maxOccurrences =
baseEvent.repeatCount === 'unlimited' ? Infinity : parseInt(baseEvent.repeatCount, 10)
if (maxOccurrences === 0) continue
const interval = baseEvent.repeatInterval || 1
const msPerDay = 24 * 60 * 60 * 1000
// Determine if targetDate lies within some occurrence span. We look backwards up to spanDays to find a start day.
let occStart = null
for (let back = 0; back <= spanDays; back++) {
const cand = new Date(targetDate)
cand.setDate(cand.getDate() - back)
if (cand < baseStartDate) break
const daysDiff = Math.floor((cand - baseStartDate) / msPerDay)
const weeksDiff = Math.floor(daysDiff / 7)
if (weeksDiff % interval !== 0) continue
if (repeatWeekdays[cand.getDay()]) {
// candidate start must produce span covering targetDate
const candEnd = new Date(cand)
candEnd.setDate(candEnd.getDate() + spanDays)
if (targetDate <= candEnd) {
occStart = cand
break
}
}
}
if (!occStart) continue
// Skip base occurrence if this is within its span (base already physically stored)
if (occStart.getTime() === baseStartDate.getTime()) continue
// Compute occurrence index (number of previous start days)
let occIdx = 0
const cursor = new Date(baseStartDate)
while (cursor < occStart && occIdx < maxOccurrences) {
const cDaysDiff = Math.floor((cursor - baseStartDate) / msPerDay)
const cWeeksDiff = Math.floor(cDaysDiff / 7)
if (cWeeksDiff % interval === 0 && repeatWeekdays[cursor.getDay()]) occIdx++
cursor.setDate(cursor.getDate() + 1)
}
if (occIdx >= maxOccurrences) continue
const occEnd = new Date(occStart)
occEnd.setDate(occStart.getDate() + spanDays)
const occStartStr = toLocalString(occStart)
const occEndStr = toLocalString(occEnd)
occurrences.push({
...baseEvent,
id: `${baseEvent.id}_repeat_${occIdx}_${occStart.getDay()}`,
startDate: occStartStr,
endDate: occEndStr,
isRepeatOccurrence: true,
repeatIndex: occIdx,
})
continue
} else {
// Handle other repeat types (months)
let intervalsPassed = 0
const timeDiff = targetDate - baseStartDate
if (baseEvent.repeat === 'months') {
intervalsPassed =
(targetDate.getFullYear() - baseStartDate.getFullYear()) * 12 +
(targetDate.getMonth() - baseStartDate.getMonth())
} else {
continue
}
const interval = baseEvent.repeatInterval || 1
if (intervalsPassed < 0 || intervalsPassed % interval !== 0) continue
// Check a few occurrences around the target date
const maxOccurrences =
baseEvent.repeatCount === 'unlimited' ? Infinity : parseInt(baseEvent.repeatCount, 10)
if (maxOccurrences === 0) continue
const i = intervalsPassed
if (i >= maxOccurrences) continue
const currentStart = new Date(baseStartDate)
currentStart.setMonth(baseStartDate.getMonth() + i)
const currentEnd = new Date(currentStart)
currentEnd.setDate(currentStart.getDate() + spanDays)
// If target day lies within base (i===0) we skip because base is stored already
if (i === 0) {
// only skip if targetDate within base span
if (targetDate >= baseStartDate && targetDate <= baseEndDate) continue
}
const currentStartStr = toLocalString(currentStart)
const currentEndStr = toLocalString(currentEnd)
if (currentStartStr <= targetDateStr && targetDateStr <= currentEndStr) {
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) {
if (justDragged.value) return
// Emit the actual span id (may include repeat suffix) so edit dialog knows occurrence context
emit('event-click', 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) {
let ev = store.getEventById(st.id)
let isRepeatOccurrence = false
let baseId = st.id
let repeatIndex = 0
let grabbedWeekday = null
// If not found (repeat occurrences aren't stored) parse synthetic id
if (!ev && typeof st.id === 'string' && st.id.includes('_repeat_')) {
const [bid, suffix] = st.id.split('_repeat_')
baseId = bid
ev = store.getEventById(baseId)
if (ev) {
const parts = suffix.split('_')
repeatIndex = parseInt(parts[0], 10) || 0
grabbedWeekday = parts.length > 1 ? parseInt(parts[1], 10) : null
isRepeatOccurrence = repeatIndex >= 0
}
}
if (!ev) return
const mode = st.mode === 'resize-left' || st.mode === 'resize-right' ? st.mode : 'move'
if (isRepeatOccurrence) {
if (repeatIndex === 0) {
store.setEventRange(baseId, startDate, endDate, { mode })
} else {
if (!st.splitNewBaseId) {
const newId = store.splitRepeatSeries(
baseId,
repeatIndex,
startDate,
endDate,
grabbedWeekday,
)
if (newId) {
st.splitNewBaseId = newId
st.id = newId
st.startDate = startDate
st.endDate = endDate
}
} else {
store.setEventRange(st.splitNewBaseId, startDate, endDate, { mode })
}
}
} else {
store.setEventRange(st.id, startDate, endDate, { mode })
}
}
// 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
View 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>

251
src/components/Numeric.vue Normal file
View File

@@ -0,0 +1,251 @@
<template>
<div
ref="rootEl"
class="mini-stepper drag-mode"
:class="[extraClass, { dragging }]"
:aria-label="ariaLabel"
role="spinbutton"
:aria-valuemin="minValue"
:aria-valuemax="maxValue"
:aria-valuenow="isPrefix(current) ? undefined : current"
:aria-valuetext="display"
tabindex="0"
@pointerdown="onPointerDown"
@keydown="onKey"
>
<span class="value" :title="String(current)">{{ display }}</span>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
const model = defineModel({ type: Number, default: 0 })
const props = defineProps({
min: { type: Number, default: 0 },
max: { type: Number, default: 999 },
step: { type: Number, default: 1 },
prefixValues: {
type: Array,
default: () => [],
validator: (arr) =>
arr.every((item) => typeof item === 'object' && 'value' in item && 'display' in item),
},
numberPrefix: { type: String, default: '' },
numberPostfix: { type: String, default: '' },
clamp: { type: Boolean, default: true },
pixelsPerStep: { type: Number, default: 16 },
// Movement now restricted to horizontal (x). Prop retained for compatibility but ignored.
axis: { type: String, default: 'x' },
ariaLabel: { type: String, default: '' },
extraClass: { type: String, default: '' },
})
const minValue = computed(() => props.min)
const maxValue = computed(() => props.max)
// Helper to check if a value is in the prefix values
const isPrefix = (value) => {
return props.prefixValues.some((prefix) => prefix.value === value)
}
// Helper to get the display for a prefix value
const getPrefixDisplay = (value) => {
const prefix = props.prefixValues.find((p) => p.value === value)
return prefix ? prefix.display : null
}
// Get all valid values in order: prefixValues, then min to max
const allValidValues = computed(() => {
const prefixVals = props.prefixValues.map((p) => p.value)
const numericVals = []
for (let i = props.min; i <= props.max; i += props.step) {
numericVals.push(i)
}
return [...prefixVals, ...numericVals]
})
const current = computed({
get() {
return model.value
},
set(v) {
if (props.clamp) {
// If it's a prefix value, allow it
if (isPrefix(v)) {
model.value = v
return
}
// Otherwise clamp to numeric range
if (v < props.min) v = props.min
if (v > props.max) v = props.max
}
model.value = v
},
})
const display = computed(() => {
const prefixDisplay = getPrefixDisplay(current.value)
if (prefixDisplay !== null) {
// For prefix values, show only the display text without number prefix/postfix
return prefixDisplay
}
// For numeric values, include prefix and postfix
const numericValue = String(current.value)
return `${props.numberPrefix}${numericValue}${props.numberPostfix}`
})
// Drag handling
const dragging = ref(false)
const rootEl = ref(null)
let startX = 0
let startY = 0
let startVal = 0
function onPointerDown(e) {
e.preventDefault()
startX = e.clientX
startY = e.clientY
startVal = current.value
dragging.value = true
try {
e.currentTarget.setPointerCapture(e.pointerId)
} catch {}
rootEl.value?.addEventListener('pointermove', onPointerMove)
rootEl.value?.addEventListener('pointerup', onPointerUp, { once: true })
rootEl.value?.addEventListener('pointercancel', onPointerCancel, { once: true })
}
function onPointerMove(e) {
if (!dragging.value) return
// Prevent page scroll on touch while dragging
if (e.pointerType === 'touch') e.preventDefault()
const primary = e.clientX - startX // horizontal only
const steps = Math.trunc(primary / props.pixelsPerStep)
// Find current value index in all valid values
const currentIndex = allValidValues.value.indexOf(startVal)
if (currentIndex === -1) return // shouldn't happen
const newIndex = currentIndex + steps
if (props.clamp) {
const clampedIndex = Math.max(0, Math.min(newIndex, allValidValues.value.length - 1))
const next = allValidValues.value[clampedIndex]
if (next !== current.value) current.value = next
} else {
if (newIndex >= 0 && newIndex < allValidValues.value.length) {
const next = allValidValues.value[newIndex]
if (next !== current.value) current.value = next
}
}
}
function endDragListeners() {
rootEl.value?.removeEventListener('pointermove', onPointerMove)
}
function onPointerUp() {
dragging.value = false
endDragListeners()
}
function onPointerCancel() {
dragging.value = false
endDragListeners()
}
function onKey(e) {
const key = e.key
let handled = false
let newValue = null
// Find current value index in all valid values
const currentIndex = allValidValues.value.indexOf(current.value)
switch (key) {
case 'ArrowRight':
case 'ArrowUp':
if (currentIndex !== -1 && currentIndex < allValidValues.value.length - 1) {
newValue = allValidValues.value[currentIndex + 1]
} else if (currentIndex === -1) {
// Current value not in list, try to increment normally
newValue = current.value + props.step
}
handled = true
break
case 'ArrowLeft':
case 'ArrowDown':
if (currentIndex !== -1 && currentIndex > 0) {
newValue = allValidValues.value[currentIndex - 1]
} else if (currentIndex === -1) {
// Current value not in list, try to decrement normally
newValue = current.value - props.step
}
handled = true
break
case 'PageUp':
if (currentIndex !== -1) {
const newIndex = Math.min(currentIndex + 10, allValidValues.value.length - 1)
newValue = allValidValues.value[newIndex]
} else {
newValue = current.value + props.step * 10
}
handled = true
break
case 'PageDown':
if (currentIndex !== -1) {
const newIndex = Math.max(currentIndex - 10, 0)
newValue = allValidValues.value[newIndex]
} else {
newValue = current.value - props.step * 10
}
handled = true
break
case 'Home':
newValue = allValidValues.value[0] || props.min
handled = true
break
case 'End':
newValue = allValidValues.value[allValidValues.value.length - 1] || props.max
handled = true
break
}
if (newValue !== null) {
current.value = newValue
}
if (handled) {
e.preventDefault()
e.stopPropagation()
}
}
</script>
<style scoped>
.mini-stepper.drag-mode {
cursor: ew-resize;
user-select: none;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 0.4rem;
gap: 0.25rem;
border: 1px solid var(--input-border, var(--muted));
background: var(--panel-alt);
border-radius: 0.4rem;
min-height: 1.8rem;
font-variant-numeric: tabular-nums;
touch-action: none; /* allow custom drag without scrolling */
}
.mini-stepper.drag-mode:focus-visible {
outline: 2px solid var(--input-focus, #2563eb);
outline-offset: 2px;
}
.mini-stepper.drag-mode .value {
font-weight: 600;
min-width: 1.6rem;
text-align: center;
pointer-events: none;
}
.mini-stepper.drag-mode.dragging {
cursor: grabbing;
}
</style>

View 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>

View File

@@ -0,0 +1,252 @@
<template>
<div class="weekgrid" @pointerleave="dragging = false">
<button
v-for="(d, di) in displayLabels"
:key="d + di"
type="button"
class="day"
:class="{
on: anySelected && displayDisplayValues[di],
// Show fallback styling on the reordered fallback day when none selected
fallback: !anySelected && displayDefault[di],
pressing: isPressing(di),
preview: previewActive && inPreviewRange(di),
}"
@pointerdown="onPointerDown(di)"
@pointerenter="onDragOver(di)"
@pointerup="onPointerUp"
>
{{ d.slice(0, 3) }}
</button>
<button
v-for="g in barGroups"
:key="g.start"
type="button"
tabindex="-1"
class="workday-weekend"
:style="{ gridColumn: 'span ' + g.span }"
@click.stop="toggleWeekend(g.type)"
>
<div :class="{ workday: !g.type, weekend: g.type }"></div>
</button>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import {
getLocalizedWeekdayNames,
getLocaleFirstDay,
getLocaleWeekendDays,
reorderByFirstDay,
} from '@/utils/date'
const model = defineModel({
type: Array,
default: () => [false, false, false, false, false, false, false],
})
const props = defineProps({
weekend: { type: Array, default: undefined },
fallback: {
type: Array,
default: () => [false, false, false, false, false, false, false],
},
firstDay: { type: Number, default: null },
})
// If external model provided is entirely false, keep as-is (user will see fallback styling),
// only overwrite if null/undefined.
if (!model.value) model.value = [...props.fallback]
const labelsMondayFirst = getLocalizedWeekdayNames()
const labels = [labelsMondayFirst[6], ...labelsMondayFirst.slice(0, 6)]
const anySelected = computed(() => model.value.some(Boolean))
const localeFirst = getLocaleFirstDay()
const localeWeekend = getLocaleWeekendDays()
const firstDay = computed(() => (props.firstDay ?? localeFirst) % 7)
const weekendDays = computed(() => {
if (props.weekend && props.weekend.length === 7) return props.weekend
return localeWeekend
})
const displayLabels = computed(() => reorderByFirstDay(labels, firstDay.value))
const displayValuesCommitted = computed(() => reorderByFirstDay(model.value, firstDay.value))
const displayWorking = computed(() => reorderByFirstDay(weekendDays.value, firstDay.value))
const displayDefault = computed(() => reorderByFirstDay(props.fallback, firstDay.value))
// Mapping from display index to original model index
const orderIndices = computed(() => Array.from({ length: 7 }, (_, i) => (i + firstDay.value) % 7))
const barGroups = computed(() => {
const arr = displayWorking.value
const groups = []
let type = arr[0]
let start = 0
for (let i = 1; i <= arr.length; i++) {
if (i === arr.length || arr[i] !== type) {
groups.push({ type, start, span: i - start })
if (i < arr.length) {
type = arr[i]
start = i
}
}
}
return groups
})
const dragging = ref(false)
const previewActive = ref(false)
const dragVal = ref(false)
const dragStart = ref(null)
const previewEnd = ref(null)
let originalValues = null
// Preview (drag) values; when none selected, still return committed (not fallback) so 'on' class
// is suppressed and only fallback styling applies via displayDefault
const displayPreviewValues = computed(() => {
if (
!dragging.value ||
!previewActive.value ||
dragStart.value == null ||
previewEnd.value == null ||
!originalValues
) {
return displayValuesCommitted.value
}
const [s, e] =
dragStart.value < previewEnd.value
? [dragStart.value, previewEnd.value]
: [previewEnd.value, dragStart.value]
return displayValuesCommitted.value.map((v, di) => (di >= s && di <= e ? dragVal.value : v))
})
const displayDisplayValues = displayPreviewValues
function inPreviewRange(di) {
if (!previewActive.value || dragStart.value == null || previewEnd.value == null) return false
const [s, e] =
dragStart.value < previewEnd.value
? [dragStart.value, previewEnd.value]
: [previewEnd.value, dragStart.value]
return di >= s && di <= e
}
function isPressing(di) {
return dragging.value && !previewActive.value && dragStart.value === di
}
function onPointerDown(di) {
originalValues = [...model.value]
dragVal.value = !model.value[(di + firstDay.value) % 7]
dragStart.value = di
previewEnd.value = di
dragging.value = true
previewActive.value = false
window.addEventListener('pointerup', onPointerUp, { once: true })
}
function onDragOver(di) {
if (!dragging.value) return
if (previewEnd.value === di) return
if (!previewActive.value && di !== dragStart.value) previewActive.value = true
previewEnd.value = di
}
function onPointerUp() {
if (!dragging.value) return
if (!previewActive.value) {
// simple click: toggle single
const next = [...originalValues]
next[(dragStart.value + firstDay.value) % 7] = dragVal.value
model.value = next
cleanupDrag()
} else {
commitDrag()
}
}
function commitDrag() {
if (dragStart.value == null || previewEnd.value == null || !originalValues) return cancelDrag()
const [s, e] =
dragStart.value < previewEnd.value
? [dragStart.value, previewEnd.value]
: [previewEnd.value, dragStart.value]
const next = [...originalValues]
for (let di = s; di <= e; di++) next[orderIndices.value[di]] = dragVal.value
model.value = next
cleanupDrag()
}
function cancelDrag() {
cleanupDrag()
}
function cleanupDrag() {
dragging.value = false
previewActive.value = false
dragStart.value = null
previewEnd.value = null
originalValues = null
}
function toggleWeekend(work) {
const base = weekendDays.value
const target = work ? base : base.map((v) => !v)
const current = model.value
const allOn = current.every(Boolean)
const isTargetActive = current.every((v, i) => v === target[i])
if (allOn || isTargetActive) {
model.value = [false, false, false, false, false, false, false]
} else {
model.value = [...target]
}
}
</script>
<style scoped>
.weekgrid {
display: grid;
grid-template-columns: repeat(7, 1fr);
grid-auto-rows: auto;
}
.workday-weekend {
height: 1em;
border: 0;
background: none;
padding: 0;
font: inherit;
cursor: pointer;
}
.workday-weekend div {
height: 0.3em;
border-radius: 0.15em;
margin: 0.1em;
}
.workday {
background: var(--workday, #888);
}
.weekend {
background: var(--weekend, #f88);
}
.day {
flex: 1;
cursor: pointer;
background: var(--panel-alt);
color: var(--ink);
font-size: 0.65rem;
font-weight: 500;
padding: 0.55rem 0.35rem;
border: none;
margin: 0 1px;
border-radius: 0.4rem;
user-select: none;
}
.day.on {
background: var(--pill-active-bg);
color: var(--pill-active-ink);
font-weight: 600;
}
.day.pressing {
filter: brightness(1.15);
}
.day.preview {
filter: brightness(1.15);
}
.day.fallback {
background: var(--muted-alt);
opacity: 0.65;
}
</style>