Major new version #2

Merged
LeoVasanko merged 86 commits from vol002 into main 2025-08-26 05:58:24 +01:00
28 changed files with 4467 additions and 2209 deletions

View File

@ -16,7 +16,11 @@
"format": "prettier --write src/" "format": "prettier --write src/"
}, },
"dependencies": { "dependencies": {
"date-fns": "^3.6.0",
"date-fns-tz": "^3.0.0",
"date-holidays": "^3.25.1",
"pinia": "^3.0.3", "pinia": "^3.0.3",
"pinia-plugin-persistedstate": "^4.5.0",
"vue": "^3.5.18" "vue": "^3.5.18"
}, },
"devDependencies": { "devDependencies": {

View File

@ -1,9 +1,45 @@
<script setup> <script setup>
import { ref } from 'vue' import { ref, onMounted, onBeforeUnmount } from 'vue'
import CalendarView from './components/CalendarView.vue' import CalendarView from './components/CalendarView.vue'
import EventDialog from './components/EventDialog.vue' import EventDialog from './components/EventDialog.vue'
import { useCalendarStore } from './stores/CalendarStore'
const eventDialog = ref(null) const eventDialog = ref(null)
const calendarStore = useCalendarStore()
// Initialize holidays when app starts
function isEditableElement(el) {
if (!el) return false
const tag = el.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA') return true
if (el.isContentEditable) return true
return false
}
function handleGlobalKey(e) {
// Only consider Ctrl/Meta+Z combos
if (!(e.ctrlKey || e.metaKey)) return
if (e.key !== 'z' && e.key !== 'Z') return
// Don't interfere with native undo/redo inside editable fields
const target = e.target
if (isEditableElement(target)) return
// Decide undo vs redo (Shift = redo)
if (e.shiftKey) {
calendarStore.$history?.redo()
} else {
calendarStore.$history?.undo()
}
e.preventDefault()
}
onMounted(() => {
calendarStore.initializeHolidaysFromConfig()
document.addEventListener('keydown', handleGlobalKey, { passive: false })
})
onBeforeUnmount(() => {
document.removeEventListener('keydown', handleGlobalKey)
})
const handleCreateEvent = (eventData) => { const handleCreateEvent = (eventData) => {
if (eventDialog.value) { if (eventDialog.value) {
@ -15,9 +51,9 @@ const handleCreateEvent = (eventData) => {
} }
} }
const handleEditEvent = (eventInstanceId) => { const handleEditEvent = (eventClickPayload) => {
if (eventDialog.value) { if (eventDialog.value) {
eventDialog.value.openEditDialog(eventInstanceId) eventDialog.value.openEditDialog(eventClickPayload)
} }
} }

View File

@ -20,6 +20,10 @@
--label-bg: #fafbfe; --label-bg: #fafbfe;
--label-bg-rgb: 250, 251, 254; --label-bg-rgb: 250, 251, 254;
/* Holiday colors */
--holiday: #da0;
--holiday-label: var(--strong);
/* Input / recurrence tokens */ /* Input / recurrence tokens */
--input-border: var(--muted-alt); --input-border: var(--muted-alt);
--input-focus: var(--accent); --input-focus: var(--accent);
@ -34,28 +38,68 @@
} }
/* Month tints (light) */ /* Month tints (light) */
.dec { background: hsl(220 50% 95%) } .dec {
.jan { background: hsl(220 50% 92%) } background: hsl(220 50% 95%);
.feb { background: hsl(220 50% 95%) } }
.mar { background: hsl(125 60% 92%) } .jan {
.apr { background: hsl(125 60% 95%) } background: hsl(220 50% 92%);
.may { background: hsl(125 60% 92%) } }
.jun { background: hsl(45 85% 95%) } .feb {
.jul { background: hsl(45 85% 92%) } background: hsl(220 50% 95%);
.aug { background: hsl(45 85% 95%) } }
.sep { background: hsl(18 78% 92%) } .mar {
.oct { background: hsl(18 78% 95%) } background: hsl(125 60% 92%);
.nov { background: hsl(18 78% 92%) } }
.apr {
background: hsl(125 60% 95%);
}
.may {
background: hsl(125 60% 92%);
}
.jun {
background: hsl(45 85% 95%);
}
.jul {
background: hsl(45 85% 92%);
}
.aug {
background: hsl(45 85% 95%);
}
.sep {
background: hsl(18 78% 92%);
}
.oct {
background: hsl(18 78% 95%);
}
.nov {
background: hsl(18 78% 92%);
}
/* Light mode — gray shades and colors */ /* Light mode — gray shades and colors */
.event-color-0 { background: hsl(0, 0%, 85%) } /* lightest grey */ .event-color-0 {
.event-color-1 { background: hsl(0, 0%, 75%) } /* light grey */ background: hsl(0, 0%, 85%);
.event-color-2 { background: hsl(0, 0%, 65%) } /* medium grey */ } /* lightest grey */
.event-color-3 { background: hsl(0, 0%, 55%) } /* dark grey */ .event-color-1 {
.event-color-4 { background: hsl(0, 70%, 70%) } /* red */ background: hsl(0, 0%, 75%);
.event-color-5 { background: hsl(90, 70%, 70%) } /* green */ } /* light grey */
.event-color-6 { background: hsl(230, 70%, 70%) } /* blue */ .event-color-2 {
.event-color-7 { background: hsl(280, 70%, 70%) } /* purple */ background: hsl(0, 0%, 65%);
} /* medium grey */
.event-color-3 {
background: hsl(0, 0%, 55%);
} /* dark grey */
.event-color-4 {
background: hsl(0, 70%, 70%);
} /* red */
.event-color-5 {
background: hsl(90, 70%, 70%);
} /* green */
.event-color-6 {
background: hsl(230, 70%, 70%);
} /* blue */
.event-color-7 {
background: hsl(280, 70%, 70%);
} /* purple */
/* Color tokens (dark) */ /* Color tokens (dark) */
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
@ -69,7 +113,7 @@
--muted: #7d8691; --muted: #7d8691;
--muted-alt: #5d646d; --muted-alt: #5d646d;
--accent: #3b82f6; --accent: #3b82f6;
--accent-soft: rgba(59,130,246,0.15); --accent-soft: rgba(59, 130, 246, 0.15);
--accent-hover: #2563eb; --accent-hover: #2563eb;
--danger: #ef4444; --danger: #ef4444;
--danger-hover: #dc2626; --danger-hover: #dc2626;
@ -85,32 +129,76 @@
--pill-bg: #222a32; --pill-bg: #222a32;
--pill-active-bg: var(--accent); --pill-active-bg: var(--accent);
--pill-active-ink: #fff; --pill-active-ink: #fff;
--pill-hover-bg: rgba(255,255,255,0.08); --pill-hover-bg: rgba(255, 255, 255, 0.08);
/* Vue component color mappings (dark) */ /* Vue component color mappings (dark) */
--bg: var(--panel); --bg: var(--panel);
--border-color: #333; --border-color: #333;
/* Holiday colors (dark mode) */
--holiday: #ffc107;
--holiday-label: #fff8e1;
} }
.dec { background: hsl(220 50% 8%) } .dec {
.jan { background: hsl(220 50% 6%) } background: hsl(220 50% 8%);
.feb { background: hsl(220 50% 8%) } }
.mar { background: hsl(125 60% 6%) } .jan {
.apr { background: hsl(125 60% 8%) } background: hsl(220 50% 6%);
.may { background: hsl(125 60% 6%) } }
.jun { background: hsl(45 85% 8%) } .feb {
.jul { background: hsl(45 85% 6%) } background: hsl(220 50% 8%);
.aug { background: hsl(45 85% 8%) } }
.sep { background: hsl(18 78% 6%) } .mar {
.oct { background: hsl(18 78% 8%) } background: hsl(125 60% 6%);
.nov { background: hsl(18 78% 6%) } }
.apr {
background: hsl(125 60% 8%);
}
.may {
background: hsl(125 60% 6%);
}
.jun {
background: hsl(45 85% 8%);
}
.jul {
background: hsl(45 85% 6%);
}
.aug {
background: hsl(45 85% 8%);
}
.sep {
background: hsl(18 78% 6%);
}
.oct {
background: hsl(18 78% 8%);
}
.nov {
background: hsl(18 78% 6%);
}
.event-color-0 { background: hsl(0, 0%, 50%) } /* lightest grey */ .event-color-0 {
.event-color-1 { background: hsl(0, 0%, 40%) } /* light grey */ background: hsl(0, 0%, 50%);
.event-color-2 { background: hsl(0, 0%, 30%) } /* medium grey */ } /* lightest grey */
.event-color-3 { background: hsl(0, 0%, 20%) } /* dark grey */ .event-color-1 {
.event-color-4 { background: hsl(0, 70%, 40%) } /* red */ background: hsl(0, 0%, 40%);
.event-color-5 { background: hsl(90, 70%, 30%) } /* green - darker for perceptional purposes */ } /* light grey */
.event-color-6 { background: hsl(230, 70%, 40%) } /* blue */ .event-color-2 {
.event-color-7 { background: hsl(280, 70%, 40%) } /* purple */ background: hsl(0, 0%, 30%);
} /* medium grey */
.event-color-3 {
background: hsl(0, 0%, 20%);
} /* dark grey */
.event-color-4 {
background: hsl(0, 70%, 40%);
} /* red */
.event-color-5 {
background: hsl(90, 70%, 30%);
} /* green - darker for perceptional purposes */
.event-color-6 {
background: hsl(230, 70%, 40%);
} /* blue */
.event-color-7 {
background: hsl(280, 70%, 40%);
} /* purple */
} }

View File

@ -1,53 +1,62 @@
/* Layout variables */
:root { :root {
/* Layout */ --week-w: 3rem;
--row-h: 2.2em; --day-w: 1fr;
--label-w: minmax(4em, 8%); --month-w: 2rem;
--cell-w: 1fr; --row-h: 15vh;
--cell-h: clamp(4em, 8vh, 8em); }
--overlay-w: minmax(3rem, 5%); * {
box-sizing: border-box;
} }
/* Layout & typography */ html,
* { box-sizing: border-box } body {
height: 100%;
}
body { body {
margin: 0; margin: 0;
font: 500 14px/1.2 ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Inter, Arial; font:
500 14px/1.2 ui-sans-serif,
system-ui,
-apple-system,
Segoe UI,
Roboto,
Inter,
Arial;
background: var(--bg); background: var(--bg);
color: var(--ink); color: var(--ink);
overflow: hidden;
} }
header { header {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
justify-content: space-between; justify-content: space-between;
margin-bottom: .75rem; margin-bottom: 0.75rem;
flex-shrink: 0; flex-shrink: 0;
} }
.header-controls { .today-date {
display: flex; cursor: pointer;
align-items: center;
gap: .75rem;
} }
.today-date::first-line {
.today-date { cursor: pointer } color: var(--today);
.today-date::first-line { color: var(--today) } }
.today-button:hover { opacity: .8 } .today-button:hover {
opacity: 0.8;
/* Header row */ }
.calendar-header, #calendar-header { .calendar-header,
#calendar-header {
display: grid; display: grid;
grid-template-columns: var(--label-w) repeat(7, var(--cell-w)) var(--overlay-w); grid-template-columns: var(--week-w) repeat(7, var(--day-w)) var(--month-w);
border-bottom: .2em solid var(--muted); border-bottom: 0.2em solid var(--muted);
align-items: last baseline; align-items: last baseline;
flex-shrink: 0; flex-shrink: 0;
width: 100%; width: 100%;
} }
/* Main container */ /* Main container */
.calendar-container, #calendar-container { .calendar-container,
#calendar-container {
flex: 1; flex: 1;
overflow: hidden; overflow: hidden;
position: relative; position: relative;
@ -56,7 +65,8 @@ header {
} }
/* Viewports (support id or class) */ /* Viewports (support id or class) */
.calendar-viewport, #calendar-viewport { .calendar-viewport,
#calendar-viewport {
height: 100%; height: 100%;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
@ -65,37 +75,27 @@ header {
scrollbar-width: none; scrollbar-width: none;
} }
.calendar-viewport::-webkit-scrollbar, .calendar-viewport::-webkit-scrollbar,
#calendar-viewport::-webkit-scrollbar { display: none } #calendar-viewport::-webkit-scrollbar {
display: none;
.jogwheel-viewport, #jogwheel-viewport { }
position: absolute; .calendar-content,
top: 0; right: 0; bottom: 0; #calendar-content {
width: var(--overlay-w); position: relative;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: none;
z-index: 20;
cursor: ns-resize;
} }
.jogwheel-viewport::-webkit-scrollbar,
#jogwheel-viewport::-webkit-scrollbar { display: none }
.jogwheel-content, #jogwheel-content { position: relative; width: 100% }
.calendar-content, #calendar-content { position: relative }
/* Week row: label + 7-day grid + jogwheel column */ /* Week row: label + 7-day grid + jogwheel column */
.week-row { .week-row {
display: grid; display: grid;
grid-template-columns: var(--label-w) repeat(7, var(--cell-w)) var(--overlay-w); grid-template-columns: var(--week-w) repeat(7, var(--day-w)) var(--month-w);
position: relative; position: relative;
overflow: visible; overflow: visible;
height: var(--cell-h); height: var(--row-h);
scroll-snap-align: start; scroll-snap-align: start;
width: 100%; width: 100%;
} }
/* Label cells */ /* Label cells */
.year-label, .week-label { .year-label,
.week-label {
display: grid; display: grid;
place-items: center; place-items: center;
width: 100%; width: 100%;
@ -105,7 +105,7 @@ header {
} }
.week-label { .week-label {
height: var(--cell-h); height: var(--row-h);
} }
/* 7-day grid inside each week row */ /* 7-day grid inside each week row */
.week-row > .days-grid { .week-row > .days-grid {
@ -130,7 +130,8 @@ header {
z-index: 15; z-index: 15;
overflow: visible; overflow: visible;
position: absolute; position: absolute;
top: 0; right: 0; top: 0;
right: 0;
width: 100%; width: 100%;
} }
.month-name-label > span { .month-name-label > span {

View File

@ -0,0 +1,264 @@
<script setup>
import { ref, computed, watch, onMounted, onUnmounted, nextTick, useAttrs } from 'vue'
// Disable automatic attr inheritance so we can forward class/style specifically to the modal element
defineOptions({ inheritAttrs: false })
const props = defineProps({
modelValue: { type: Boolean, default: false },
title: { type: String, default: '' },
draggable: { type: Boolean, default: true },
autoFocus: { type: Boolean, default: true },
// Optional external anchor element (e.g., a day cell) to position the dialog below.
// If not provided, falls back to internal anchorRef span (original behavior).
anchorEl: { type: Object, default: null },
})
const emit = defineEmits(['update:modelValue', 'opened', 'closed', 'submit'])
const modalRef = ref(null)
const anchorRef = ref(null)
const isDragging = ref(false)
const dragOffset = ref({ x: 0, y: 0 })
const modalPosition = ref({ x: 0, y: 0 })
const dialogWidth = ref(null)
const dialogHeight = ref(null)
const hasMoved = ref(false)
const margin = 8 // viewport margin in px to keep dialog from touching edges
// Collect incoming non-prop attributes (e.g., class / style from usage site)
const attrs = useAttrs()
function clamp(val, min, max) {
return Math.min(Math.max(val, min), max)
}
function startDrag(event) {
if (!props.draggable || !modalRef.value) return
const rect = modalRef.value.getBoundingClientRect()
// Lock current size so moving doesn't cause reflow / resize
dialogWidth.value = rect.width
dialogHeight.value = rect.height
// Initialize position to current on-screen coordinates BEFORE enabling moved mode
modalPosition.value = { x: rect.left, y: rect.top }
isDragging.value = true
hasMoved.value = true
dragOffset.value = { x: event.clientX - rect.left, y: event.clientY - rect.top }
if (event.pointerId !== undefined) {
try {
event.target.setPointerCapture(event.pointerId)
} catch {}
}
document.addEventListener('pointermove', handleDrag, { passive: false })
document.addEventListener('pointerup', stopDrag)
document.addEventListener('pointercancel', stopDrag)
event.preventDefault()
}
function handleDrag(event) {
if (!isDragging.value) return
let x = event.clientX - dragOffset.value.x
let y = event.clientY - dragOffset.value.y
const w = dialogWidth.value || modalRef.value?.offsetWidth || 0
const h = dialogHeight.value || modalRef.value?.offsetHeight || 0
const vw = window.innerWidth
const vh = window.innerHeight
x = clamp(x, margin, Math.max(margin, vw - w - margin))
y = clamp(y, margin, Math.max(margin, vh - h - margin))
modalPosition.value = { x, y }
event.preventDefault()
}
function stopDrag() {
isDragging.value = false
document.removeEventListener('pointermove', handleDrag)
document.removeEventListener('pointerup', stopDrag)
document.removeEventListener('pointercancel', stopDrag)
}
const modalStyle = computed(() => {
// Always position relative to calculated modalPosition once opened
if (modalRef.value && props.modelValue) {
const style = {
transform: 'none',
left: modalPosition.value.x + 'px',
top: modalPosition.value.y + 'px',
bottom: 'auto',
right: 'auto',
}
if (hasMoved.value) {
style.width = dialogWidth.value ? dialogWidth.value + 'px' : undefined
style.height = dialogHeight.value ? dialogHeight.value + 'px' : undefined
}
return style
}
return {}
})
// Merge external class / style with internal ones so parent usage like
// <BaseDialog class="settings-modal" :style="{ top: '...' }" /> works even with fragment root.
const modalAttrs = computed(() => {
const { class: extClass, style: extStyle, ...rest } = attrs
return {
...rest,
class: ['ec-modal', extClass].filter(Boolean),
style: [modalStyle.value, extStyle].filter(Boolean), // external style overrides internal
}
})
function close() {
emit('update:modelValue', false)
emit('closed')
}
function handleKeydown(e) {
if (e.key === 'Escape' && props.modelValue) close()
}
onMounted(() => document.addEventListener('keydown', handleKeydown))
onUnmounted(() => document.removeEventListener('keydown', handleKeydown))
function positionNearAnchor() {
const anchor = props.anchorEl || anchorRef.value
if (!anchor) return
const rect = anchor.getBoundingClientRect()
const offsetY = 8 // vertical gap below the anchor
const w = modalRef.value?.offsetWidth || dialogWidth.value || 320
const h = modalRef.value?.offsetHeight || dialogHeight.value || 200
const vw = window.innerWidth
const vh = window.innerHeight
let x = rect.left
let y = rect.bottom + offsetY
// If anchor is wider than dialog and would overflow right edge, clamp; otherwise keep left align
x = clamp(x, margin, Math.max(margin, vw - w - margin))
y = clamp(y, margin, Math.max(margin, vh - h - margin))
modalPosition.value = { x, y }
}
watch(
() => props.modelValue,
async (v) => {
if (v) {
emit('opened')
await nextTick()
// Reset movement state each time opened
hasMoved.value = false
dialogWidth.value = null
dialogHeight.value = null
positionNearAnchor()
if (props.autoFocus) {
const el = modalRef.value?.querySelector('[autofocus]')
if (el) el.focus()
}
}
},
)
// Reposition if anchorEl changes while open and user hasn't dragged dialog yet
watch(
() => props.anchorEl,
() => {
if (props.modelValue && !hasMoved.value) {
nextTick(() => positionNearAnchor())
}
},
)
function handleResize() {
if (!props.modelValue) return
// Re-clamp current position, and if not moved recalc near anchor
if (!hasMoved.value) positionNearAnchor()
else if (modalRef.value) {
const w = modalRef.value.offsetWidth
const h = modalRef.value.offsetHeight
const vw = window.innerWidth
const vh = window.innerHeight
modalPosition.value = {
x: clamp(modalPosition.value.x, margin, Math.max(margin, vw - w - margin)),
y: clamp(modalPosition.value.y, margin, Math.max(margin, vh - h - margin)),
}
}
}
onMounted(() => {
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
})
</script>
<template>
<span ref="anchorRef" class="ec-modal-anchor" aria-hidden="true"></span>
<div v-if="modelValue" ref="modalRef" v-bind="modalAttrs">
<form class="ec-form" @submit.prevent="emit('submit')">
<header class="ec-header" @pointerdown="startDrag">
<h2 class="ec-title">
<slot name="title">{{ title }}</slot>
</h2>
<div class="ec-header-extra"><slot name="header-extra" /></div>
</header>
<div class="ec-body">
<slot />
</div>
<footer v-if="$slots.footer" class="ec-footer">
<slot name="footer" />
</footer>
</form>
</div>
</template>
<style scoped>
.ec-modal {
position: fixed; /* still fixed for overlay & dragging, but now top/left are set dynamically */
background: color-mix(in srgb, var(--panel) 85%, transparent);
backdrop-filter: blur(0.625em);
-webkit-backdrop-filter: blur(0.625em);
color: var(--ink);
border-radius: 0.6em;
min-height: 23em;
min-width: 26em;
max-width: min(34em, 90vw);
box-shadow: 0 0.6em 1.8em rgba(0, 0, 0, 0.35);
border: 0.0625em solid color-mix(in srgb, var(--muted) 40%, transparent);
z-index: 1000;
overflow: hidden;
}
.ec-modal-anchor {
display: inline-block;
width: 0;
height: 0;
}
.ec-form {
display: grid;
grid-template-rows: auto 1fr auto;
min-height: 23em;
height: 100%;
width: 100%;
}
.ec-header {
cursor: move;
user-select: none;
padding: 0.75em 1em 0.5em 1em;
display: flex;
justify-content: space-between;
align-items: center;
gap: 1em;
}
.ec-title {
margin: 0;
font-size: 1.1em;
}
.ec-body {
display: flex;
flex-direction: column;
gap: 1em;
padding: 0 1em 0.5em 1em;
overflow: auto;
}
.ec-footer {
padding: 0.5em 1em 1em 1em;
display: flex;
justify-content: space-between;
gap: 1em;
flex-wrap: wrap;
}
</style>

View File

@ -1,35 +0,0 @@
<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

@ -1,18 +1,14 @@
<script setup> <script setup>
const props = defineProps({ const props = defineProps({
day: Object, day: Object,
dragging: { type: Boolean, default: false },
}) })
const emit = defineEmits(['event-click'])
const handleEventClick = (eventId) => {
emit('event-click', eventId)
}
</script> </script>
<template> <template>
<div <div
class="cell" class="cell"
:style="props.dragging ? 'touch-action:none;' : 'touch-action:pan-y;'"
:class="[ :class="[
props.day.monthClass, props.day.monthClass,
{ {
@ -20,6 +16,7 @@ const handleEventClick = (eventId) => {
weekend: props.day.isWeekend, weekend: props.day.isWeekend,
firstday: props.day.isFirstDay, firstday: props.day.isFirstDay,
selected: props.day.isSelected, selected: props.day.isSelected,
holiday: props.day.isHoliday,
}, },
]" ]"
:data-date="props.day.date" :data-date="props.day.date"
@ -27,19 +24,10 @@ const handleEventClick = (eventId) => {
<h1>{{ props.day.displayText }}</h1> <h1>{{ props.day.displayText }}</h1>
<span v-if="props.day.lunarPhase" class="lunar-phase">{{ props.day.lunarPhase }}</span> <span v-if="props.day.lunarPhase" class="lunar-phase">{{ props.day.lunarPhase }}</span>
<!-- Simple event display for now --> <div v-if="props.day.holiday" class="holiday-info">
<div v-if="props.day.events && props.day.events.length > 0" class="day-events"> <span class="holiday-name" :title="props.day.holiday.name">
<div {{ props.day.holiday.name }}
v-for="event in props.day.events.slice(0, 3)" </span>
: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>
</div> </div>
</template> </template>
@ -50,7 +38,6 @@ const handleEventClick = (eventId) => {
border-right: 1px solid var(--border-color); border-right: 1px solid var(--border-color);
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
user-select: none; user-select: none;
touch-action: none;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: flex-start; align-items: flex-start;
@ -58,7 +45,7 @@ const handleEventClick = (eventId) => {
padding: 0.25em; padding: 0.25em;
overflow: hidden; overflow: hidden;
width: 100%; width: 100%;
height: var(--cell-h); height: var(--row-h);
font-weight: 700; font-weight: 700;
transition: background-color 0.15s ease; transition: background-color 0.15s ease;
} }
@ -72,20 +59,6 @@ const handleEventClick = (eventId) => {
color: var(--ink); color: var(--ink);
transition: background-color 0.15s ease; 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 { .cell.weekend h1 {
color: var(--weekend); color: var(--weekend);
} }
@ -93,18 +66,64 @@ const handleEventClick = (eventId) => {
color: var(--firstday); color: var(--firstday);
text-shadow: 0 0 0.1em var(--strong); text-shadow: 0 0 0.1em var(--strong);
} }
.cell.today h1 {
border-radius: 2em;
background: var(--today);
border: 0.2em solid var(--today);
margin: -0.2em;
color: var(--strong);
font-weight: bold;
}
.cell.selected { .cell.selected {
filter: hue-rotate(180deg); filter: hue-rotate(180deg);
} }
.cell.selected h1 { .cell.selected h1 {
color: var(--strong); color: var(--strong);
} }
.lunar-phase { .lunar-phase {
position: absolute; position: absolute;
top: 0.1em; top: 0.5em;
right: 0.1em; right: 0.2em;
font-size: 0.8em; font-size: 0.8em;
opacity: 0.7; opacity: 0.7;
} }
.cell.holiday {
background-image: linear-gradient(
135deg,
var(--holiday-grad-start, rgba(255, 255, 255, 0.5)) 0%,
var(--holiday-grad-end, rgba(255, 255, 255, 0)) 70%
);
}
@media (prefers-color-scheme: dark) {
.cell.holiday {
background-image: linear-gradient(
135deg,
var(--holiday-grad-start, rgba(255, 255, 255, 0.1)) 0%,
var(--holiday-grad-end, rgba(255, 255, 255, 0)) 70%
);
}
}
.cell.holiday h1 {
/* Slight emphasis without forcing a specific hue */
color: var(--holiday);
text-shadow: 0 0 0.3em rgba(255, 255, 255, 0.4);
}
.holiday-info {
position: absolute;
bottom: 0.1em;
left: 0.1em;
right: 0.1em;
line-height: 1;
overflow: hidden;
font-size: clamp(1.2vw, 0.6em, 1em);
}
.holiday-name {
display: block;
color: var(--holiday-label);
padding: 0.15em 0.35em 0.15em 0.25em;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
</style> </style>

View File

@ -1,184 +0,0 @@
<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

@ -1,7 +1,16 @@
<script setup> <script setup>
import { computed } from 'vue' import { computed } from 'vue'
import { useCalendarStore } from '@/stores/CalendarStore' import { useCalendarStore } from '@/stores/CalendarStore'
import { getLocalizedWeekdayNames, reorderByFirstDay, isoWeekInfo } from '@/utils/date' import {
getLocalizedWeekdayNames,
reorderByFirstDay,
getISOWeek,
getISOWeekYear,
MIN_YEAR,
MAX_YEAR,
} from '@/utils/date'
import Numeric from '@/components/Numeric.vue'
import { addDays } from 'date-fns'
const props = defineProps({ const props = defineProps({
scrollTop: { type: Number, default: 0 }, scrollTop: { type: Number, default: 0 },
@ -11,17 +20,64 @@ const props = defineProps({
const calendarStore = useCalendarStore() const calendarStore = useCalendarStore()
const yearLabel = computed(() => { // Emits year-change events
const emit = defineEmits(['year-change'])
const baseDate = computed(() => new Date(1970, 0, 4 + calendarStore.config.first_day))
const WEEK_MS = 7 * 24 * 60 * 60 * 1000
const topVirtualWeek = computed(() => {
const topDisplayIndex = Math.floor(props.scrollTop / props.rowHeight) const topDisplayIndex = Math.floor(props.scrollTop / props.rowHeight)
const topVW = topDisplayIndex + props.minVirtualWeek return 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 currentYear = computed(() => {
const weekStart = addDays(baseDate.value, topVirtualWeek.value * 7)
const anchor = addDays(weekStart, (4 - weekStart.getDay() + 7) % 7)
return getISOWeekYear(anchor)
})
function virtualWeekOf(d) {
const o = (d.getDay() - calendarStore.config.first_day + 7) % 7
const fd = addDays(d, -o)
return Math.floor((fd.getTime() - baseDate.value.getTime()) / WEEK_MS)
}
function isoWeekMonday(isoYear, isoWeek) {
const jan4 = new Date(isoYear, 0, 4)
const week1Mon = addDays(jan4, -((jan4.getDay() + 6) % 7))
return addDays(week1Mon, (isoWeek - 1) * 7)
}
function changeYear(y) {
if (y == null) return
y = Math.round(Math.max(MIN_YEAR, Math.min(MAX_YEAR, y)))
if (y === currentYear.value) return
const vw = topVirtualWeek.value
// Fraction within current row
const weekStartScroll = (vw - props.minVirtualWeek) * props.rowHeight
const frac = Math.max(0, Math.min(1, (props.scrollTop - weekStartScroll) / props.rowHeight))
// Anchor Thursday of current calendar week
const curCalWeekStart = addDays(baseDate.value, vw * 7)
const curAnchorThu = addDays(curCalWeekStart, (4 - curCalWeekStart.getDay() + 7) % 7)
let isoW = getISOWeek(curAnchorThu)
// Build Monday of ISO week
let weekMon = isoWeekMonday(y, isoW)
if (getISOWeekYear(weekMon) !== y) {
isoW--
weekMon = isoWeekMonday(y, isoW)
}
// Align to configured first day
const shift = (weekMon.getDay() - calendarStore.config.first_day + 7) % 7
const calWeekStart = addDays(weekMon, -shift)
const targetVW = virtualWeekOf(calWeekStart)
let scrollTop = (targetVW - props.minVirtualWeek) * props.rowHeight + frac * props.rowHeight
if (Math.abs(scrollTop - props.scrollTop) < 0.5) scrollTop += 0.25 * props.rowHeight
emit('year-change', { year: y, scrollTop })
}
const weekdayNames = computed(() => { const weekdayNames = computed(() => {
// Get Monday-first names, then reorder by first day, then add weekend info // Reorder names & weekend flags
const mondayFirstNames = getLocalizedWeekdayNames() const mondayFirstNames = getLocalizedWeekdayNames()
const sundayFirstNames = [mondayFirstNames[6], ...mondayFirstNames.slice(0, 6)] const sundayFirstNames = [mondayFirstNames[6], ...mondayFirstNames.slice(0, 6)]
const reorderedNames = reorderByFirstDay(sundayFirstNames, calendarStore.config.first_day) const reorderedNames = reorderByFirstDay(sundayFirstNames, calendarStore.config.first_day)
@ -36,7 +92,18 @@ const weekdayNames = computed(() => {
<template> <template>
<div class="calendar-header"> <div class="calendar-header">
<div class="year-label">{{ yearLabel }}</div> <div class="year-label">
<Numeric
:model-value="currentYear"
@update:modelValue="changeYear"
:min="MIN_YEAR"
:max="MAX_YEAR"
:step="1"
aria-label="Year"
number-prefix=""
number-postfix=""
/>
</div>
<div <div
v-for="day in weekdayNames" v-for="day in weekdayNames"
:key="day.name" :key="day.name"
@ -52,7 +119,7 @@ const weekdayNames = computed(() => {
<style scoped> <style scoped>
.calendar-header { .calendar-header {
display: grid; display: grid;
grid-template-columns: var(--label-w) repeat(7, 1fr) 3rem; grid-template-columns: var(--week-w) repeat(7, 1fr) var(--month-w);
border-bottom: 2px solid var(--muted); border-bottom: 2px solid var(--muted);
align-items: last baseline; align-items: last baseline;
flex-shrink: 0; flex-shrink: 0;
@ -65,20 +132,11 @@ const weekdayNames = computed(() => {
-webkit-touch-callout: none; -webkit-touch-callout: none;
-webkit-tap-highlight-color: transparent; -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 { .dow {
text-transform: uppercase; text-transform: uppercase;
text-align: center; text-align: center;
padding: 0.5rem; font-weight: 600;
font-weight: 500; font-size: 1.2em;
} }
.dow.weekend { .dow.weekend {
color: var(--weekend); color: var(--weekend);

View File

@ -1,235 +1,172 @@
<script setup> <script setup>
import { ref, onMounted, onBeforeUnmount, computed } from 'vue' import { ref, onMounted, onBeforeUnmount, computed, watch } from 'vue'
import { useCalendarStore } from '@/stores/CalendarStore' import { useCalendarStore } from '@/stores/CalendarStore'
import CalendarHeader from '@/components/CalendarHeader.vue' import CalendarHeader from '@/components/CalendarHeader.vue'
import CalendarWeek from '@/components/CalendarWeek.vue' import CalendarWeek from '@/components/CalendarWeek.vue'
import Jogwheel from '@/components/Jogwheel.vue' import HeaderControls from '@/components/HeaderControls.vue'
import { import {
isoWeekInfo, createScrollManager,
getLocalizedMonthName, createWeekColumnScrollManager,
monthAbbr, createMonthScrollManager,
lunarPhaseSymbol, } from '@/plugins/scrollManager'
pad, import { daysInclusive, addDaysStr, MIN_YEAR, MAX_YEAR } from '@/utils/date'
daysInclusive, import { toLocalString, fromLocalString, DEFAULT_TZ } from '@/utils/date'
addDaysStr, import { addDays, differenceInWeeks } from 'date-fns'
formatDateRange, import { createVirtualWeekManager } from '@/plugins/virtualWeeks'
} from '@/utils/date'
import { toLocalString, fromLocalString } from '@/utils/date'
const calendarStore = useCalendarStore() const calendarStore = useCalendarStore()
const viewport = ref(null)
const emit = defineEmits(['create-event', 'edit-event']) const emit = defineEmits(['create-event', 'edit-event'])
const viewport = ref(null)
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 viewportHeight = ref(600)
const rowHeight = ref(64) const rowHeight = ref(64)
const baseDate = new Date(1970, 0, 4 + calendarStore.config.first_day) const rowProbe = ref(null)
let rowProbeObserver = null
const baseDate = computed(() => new Date(1970, 0, 4 + calendarStore.config.first_day))
const selection = ref({ startDate: null, dayCount: 0 }) const selection = ref({ startDate: null, dayCount: 0 })
const isDragging = ref(false) const isDragging = ref(false)
const dragAnchor = ref(null) const dragAnchor = ref(null)
const DOUBLE_TAP_DELAY = 300
const pendingTap = ref({ date: null, time: 0, type: null })
const suppressMouseUntil = ref(0)
function normalizeDate(val) {
if (typeof val === 'string') return val
if (val && typeof val === 'object') {
if (val.date) return String(val.date)
if (val.startDate) return String(val.startDate)
}
return String(val)
}
const WEEK_MS = 7 * 24 * 60 * 60 * 1000 function registerTap(rawDate, type) {
const dateStr = normalizeDate(rawDate)
const now = Date.now()
const prev = pendingTap.value
const delta = now - prev.time
const isDouble =
prev.date === dateStr && prev.type === type && delta <= DOUBLE_TAP_DELAY && delta >= 35
if (isDouble) {
pendingTap.value = { date: null, time: 0, type: null }
return true
}
pendingTap.value = { date: dateStr, time: now, type }
return false
}
const minVirtualWeek = computed(() => { const minVirtualWeek = computed(() => {
const date = new Date(calendarStore.minYear, 0, 1) const date = new Date(MIN_YEAR, 0, 1)
const firstDayOfWeek = new Date(date)
const dayOffset = (date.getDay() - calendarStore.config.first_day + 7) % 7 const dayOffset = (date.getDay() - calendarStore.config.first_day + 7) % 7
firstDayOfWeek.setDate(date.getDate() - dayOffset) const firstDayOfWeek = addDays(date, -dayOffset)
return Math.floor((firstDayOfWeek - baseDate) / WEEK_MS) return differenceInWeeks(firstDayOfWeek, baseDate.value)
}) })
const maxVirtualWeek = computed(() => { const maxVirtualWeek = computed(() => {
const date = new Date(calendarStore.maxYear, 11, 31) const date = new Date(MAX_YEAR, 11, 31)
const firstDayOfWeek = new Date(date)
const dayOffset = (date.getDay() - calendarStore.config.first_day + 7) % 7 const dayOffset = (date.getDay() - calendarStore.config.first_day + 7) % 7
firstDayOfWeek.setDate(date.getDate() - dayOffset) const firstDayOfWeek = addDays(date, -dayOffset)
return Math.floor((firstDayOfWeek - baseDate) / WEEK_MS) return differenceInWeeks(firstDayOfWeek, baseDate.value)
}) })
const totalVirtualWeeks = computed(() => { const totalVirtualWeeks = computed(() => {
return maxVirtualWeek.value - minVirtualWeek.value + 1 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(() => { const contentHeight = computed(() => {
return totalVirtualWeeks.value * rowHeight.value return totalVirtualWeeks.value * rowHeight.value
}) })
// Virtual weeks manager (after dependent refs exist)
const vwm = createVirtualWeekManager({
calendarStore,
viewport,
viewportHeight,
rowHeight,
selection,
baseDate,
minVirtualWeek,
maxVirtualWeek,
contentHeight,
})
const visibleWeeks = vwm.visibleWeeks
const { scheduleWindowUpdate, resetWeeks, refreshEvents } = vwm
// Scroll managers (after scheduleWindowUpdate available)
const scrollManager = createScrollManager({ viewport, scheduleRebuild: scheduleWindowUpdate })
const { scrollTop, setScrollTop, onScroll } = scrollManager
const weekColumnScrollManager = createWeekColumnScrollManager({
viewport,
viewportHeight,
contentHeight,
setScrollTop,
})
const { isWeekColDragging, handleWeekColMouseDown, handlePointerLockChange } =
weekColumnScrollManager
const monthScrollManager = createMonthScrollManager({
viewport,
viewportHeight,
contentHeight,
setScrollTop,
})
const { handleMonthScrollPointerDown, handleMonthScrollTouchStart, handleMonthScrollWheel } =
monthScrollManager
// Provide scroll refs to virtual week manager
vwm.attachScroll(scrollTop, setScrollTop)
const initialScrollTop = computed(() => {
const nowDate = new Date(calendarStore.now)
const targetWeekIndex = getWeekIndex(nowDate) - 3
return (targetWeekIndex - minVirtualWeek.value) * rowHeight.value
})
function computeRowHeight() { function computeRowHeight() {
if (rowProbe.value) {
const h = rowProbe.value.getBoundingClientRect().height || 64
rowHeight.value = Math.round(h)
return rowHeight.value
}
const el = document.createElement('div') const el = document.createElement('div')
el.style.position = 'absolute' el.style.position = 'absolute'
el.style.visibility = 'hidden' el.style.visibility = 'hidden'
el.style.height = 'var(--cell-h)' el.style.height = 'var(--row-h)'
document.body.appendChild(el) document.body.appendChild(el)
const h = el.getBoundingClientRect().height || 64 const h = el.getBoundingClientRect().height || 64
el.remove() el.remove()
rowHeight.value = Math.round(h) rowHeight.value = Math.round(h)
return rowHeight.value return rowHeight.value
} }
function measureFromProbe() {
function getWeekIndex(date) { if (!rowProbe.value) return
const firstDayOfWeek = new Date(date) const h = rowProbe.value.getBoundingClientRect().height
const dayOffset = (date.getDay() - calendarStore.config.first_day + 7) % 7 if (!h) return
firstDayOfWeek.setDate(date.getDate() - dayOffset) const newH = Math.round(h)
return Math.floor((firstDayOfWeek - baseDate) / WEEK_MS) if (newH !== rowHeight.value) {
} const oldH = rowHeight.value
// Anchor: keep the same top virtual week visible.
function getFirstDayForVirtualWeek(virtualWeek) { const topVirtualWeek = Math.floor(scrollTop.value / oldH) + minVirtualWeek.value
const firstDay = new Date(baseDate) rowHeight.value = newH
firstDay.setDate(firstDay.getDate() + virtualWeek * 7) const newScrollTop = (topVirtualWeek - minVirtualWeek.value) * newH
return firstDay setScrollTop(newScrollTop, 'row-height-change')
} resetWeeks('row-height-change')
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 { getWeekIndex, getFirstDayForVirtualWeek, goToToday, handleHeaderYearChange } = vwm
const top = new Date(calendarStore.now)
top.setDate(top.getDate() - 21) // createWeek logic moved to virtualWeeks plugin
const targetWeekIndex = getWeekIndex(top)
scrollTop.value = (targetWeekIndex - minVirtualWeek.value) * rowHeight.value // goToToday now provided by manager
if (viewport.value) {
viewport.value.scrollTop = scrollTop.value
}
}
function clearSelection() { function clearSelection() {
selection.value = { startDate: null, dayCount: 0 } selection.value = { startDate: null, dayCount: 0 }
} }
function startDrag(dateStr) { function startDrag(dateStr) {
dateStr = normalizeDate(dateStr)
if (calendarStore.config.select_days === 0) return if (calendarStore.config.select_days === 0) return
isDragging.value = true isDragging.value = true
dragAnchor.value = dateStr dragAnchor.value = dateStr
selection.value = { startDate: dateStr, dayCount: 1 } selection.value = { startDate: dateStr, dayCount: 1 }
addGlobalTouchListeners()
} }
function updateDrag(dateStr) { function updateDrag(dateStr) {
@ -245,10 +182,102 @@ function endDrag(dateStr) {
selection.value = { startDate, dayCount } selection.value = { startDate, dayCount }
} }
function finalizeDragAndCreate() {
if (!isDragging.value) return
isDragging.value = false
const eventData = createEventFromSelection()
if (eventData) {
clearSelection()
emit('create-event', eventData)
}
removeGlobalTouchListeners()
}
// Build a minimal event creation payload from current selection
// Returns null if selection is invalid or empty.
function createEventFromSelection() {
const sel = selection.value || {}
if (!sel.startDate || !sel.dayCount || sel.dayCount <= 0) return null
return {
startDate: sel.startDate,
dayCount: sel.dayCount,
}
}
function getDateUnderPoint(x, y) {
const el = document.elementFromPoint(x, y)
let cur = el
while (cur) {
if (cur.dataset && cur.dataset.date) return cur.dataset.date
cur = cur.parentElement
}
return getDateFromCoordinates(x, y)
}
function onGlobalTouchMove(e) {
if (!isDragging.value) return
const t = e.touches && e.touches[0]
if (!t) return
e.preventDefault()
const dateStr = getDateUnderPoint(t.clientX, t.clientY)
if (dateStr) updateDrag(dateStr)
}
function onGlobalTouchEnd(e) {
if (!isDragging.value) {
removeGlobalTouchListeners()
return
}
const t = (e.changedTouches && e.changedTouches[0]) || (e.touches && e.touches[0])
if (t) {
const dateStr = getDateUnderPoint(t.clientX, t.clientY)
if (dateStr) {
const { startDate, dayCount } = calculateSelection(dragAnchor.value, dateStr)
selection.value = { startDate, dayCount }
}
}
finalizeDragAndCreate()
}
function addGlobalTouchListeners() {
window.addEventListener('touchmove', onGlobalTouchMove, { passive: false })
window.addEventListener('touchend', onGlobalTouchEnd, { passive: false })
window.addEventListener('touchcancel', onGlobalTouchEnd, { passive: false })
}
function removeGlobalTouchListeners() {
window.removeEventListener('touchmove', onGlobalTouchMove)
window.removeEventListener('touchend', onGlobalTouchEnd)
window.removeEventListener('touchcancel', onGlobalTouchEnd)
}
// Fallback hit-test if elementFromPoint doesn't find a day cell (e.g., moving between rows).
function getDateFromCoordinates(clientX, clientY) {
if (!viewport.value) return null
const vpRect = viewport.value.getBoundingClientRect()
const yOffset = clientY - vpRect.top + viewport.value.scrollTop
if (yOffset < 0) return null
const rowIndex = Math.floor(yOffset / rowHeight.value)
const virtualWeek = minVirtualWeek.value + rowIndex
if (virtualWeek < minVirtualWeek.value || virtualWeek > maxVirtualWeek.value) return null
const sampleWeek = viewport.value.querySelector('.week-row')
if (!sampleWeek) return null
const labelEl = sampleWeek.querySelector('.week-label')
const wrRect = sampleWeek.getBoundingClientRect()
const labelRight = labelEl ? labelEl.getBoundingClientRect().right : wrRect.left
const daysAreaRight = wrRect.right
const daysWidth = daysAreaRight - labelRight
if (clientX < labelRight || clientX > daysAreaRight) return null
const col = Math.min(6, Math.max(0, Math.floor(((clientX - labelRight) / daysWidth) * 7)))
const firstDay = getFirstDayForVirtualWeek(virtualWeek)
const targetDate = addDays(firstDay, col)
return toLocalString(targetDate, DEFAULT_TZ)
}
function calculateSelection(anchorStr, otherStr) { function calculateSelection(anchorStr, otherStr) {
const limit = calendarStore.config.select_days const limit = calendarStore.config.select_days
const anchorDate = fromLocalString(anchorStr) const anchorDate = fromLocalString(anchorStr, DEFAULT_TZ)
const otherDate = fromLocalString(otherStr) const otherDate = fromLocalString(otherStr, DEFAULT_TZ)
const forward = otherDate >= anchorDate const forward = otherDate >= anchorDate
const span = daysInclusive(anchorStr, otherStr) const span = daysInclusive(anchorStr, otherStr)
@ -260,21 +289,18 @@ function calculateSelection(anchorStr, otherStr) {
if (forward) { if (forward) {
return { startDate: anchorStr, dayCount: limit } return { startDate: anchorStr, dayCount: limit }
} else { } else {
const startDate = addDaysStr(anchorStr, -(limit - 1)) const startDate = addDaysStr(anchorStr, -(limit - 1), DEFAULT_TZ)
return { startDate, dayCount: limit } return { startDate, dayCount: limit }
} }
} }
const onScroll = () => { // ---------------- Week label column drag scrolling ----------------
if (viewport.value) { function getWeekLabelRect() {
scrollTop.value = viewport.value.scrollTop // Prefer header year label width as stable reference
} const headerYear = document.querySelector('.calendar-header .year-label')
} if (headerYear) return headerYear.getBoundingClientRect()
const weekLabel = viewport.value?.querySelector('.week-row .week-label')
const handleJogwheelScrollTo = (newScrollTop) => { return weekLabel ? weekLabel.getBoundingClientRect() : null
if (viewport.value) {
viewport.value.scrollTop = newScrollTop
}
} }
onMounted(() => { onMounted(() => {
@ -283,14 +309,27 @@ onMounted(() => {
if (viewport.value) { if (viewport.value) {
viewportHeight.value = viewport.value.clientHeight viewportHeight.value = viewport.value.clientHeight
viewport.value.scrollTop = initialScrollTop.value setScrollTop(initialScrollTop.value, 'initial-mount')
viewport.value.addEventListener('scroll', onScroll) viewport.value.addEventListener('scroll', onScroll)
// Capture mousedown in viewport to allow dragging via week label column
viewport.value.addEventListener('mousedown', handleWeekColMouseDown, true)
} }
document.addEventListener('pointerlockchange', handlePointerLockChange)
const timer = setInterval(() => { const timer = setInterval(() => {
calendarStore.updateCurrentDate() calendarStore.updateCurrentDate()
}, 60000) }, 60000)
// Initial incremental build (no existing weeks yet)
scheduleWindowUpdate('init')
if (window.ResizeObserver && rowProbe.value) {
rowProbeObserver = new ResizeObserver(() => {
measureFromProbe()
})
rowProbeObserver.observe(rowProbe.value)
}
onBeforeUnmount(() => { onBeforeUnmount(() => {
clearInterval(timer) clearInterval(timer)
}) })
@ -299,113 +338,157 @@ onMounted(() => {
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (viewport.value) { if (viewport.value) {
viewport.value.removeEventListener('scroll', onScroll) viewport.value.removeEventListener('scroll', onScroll)
viewport.value.removeEventListener('mousedown', handleWeekColMouseDown, true)
} }
if (rowProbeObserver && rowProbe.value) {
try {
rowProbeObserver.unobserve(rowProbe.value)
rowProbeObserver.disconnect()
} catch (e) {}
}
document.removeEventListener('pointerlockchange', handlePointerLockChange)
}) })
const handleDayMouseDown = (dateStr) => { const handleDayMouseDown = (d) => {
startDrag(dateStr) d = normalizeDate(d)
if (Date.now() < suppressMouseUntil.value) return
if (registerTap(d, 'mouse')) startDrag(d)
} }
const handleDayMouseEnter = (d) => updateDrag(normalizeDate(d))
const handleDayMouseEnter = (dateStr) => { const handleDayMouseUp = (d) => {
if (isDragging.value) { d = normalizeDate(d)
updateDrag(dateStr) if (Date.now() < suppressMouseUntil.value && !isDragging.value) return
} if (!isDragging.value) return
} endDrag(d)
const ev = createEventFromSelection()
const handleDayMouseUp = (dateStr) => { if (ev) {
if (isDragging.value) {
endDrag(dateStr)
const eventData = createEventFromSelection()
if (eventData) {
clearSelection() clearSelection()
emit('create-event', eventData) emit('create-event', ev)
}
} }
} }
const handleDayTouchStart = (d) => {
d = normalizeDate(d)
suppressMouseUntil.value = Date.now() + 800
if (registerTap(d, 'touch')) startDrag(d)
}
const handleDayTouchStart = (dateStr) => { const handleEventClick = (payload) => {
startDrag(dateStr) emit('edit-event', payload)
} }
const handleDayTouchMove = (dateStr) => { // header year change delegated to manager
if (isDragging.value) {
updateDrag(dateStr) // Heuristic: rotate month label (180deg) only for predominantly Latin text.
} // We explicitly avoid locale detection; rely solely on characters present.
// Disable rotation if any CJK Unified Ideograph or Compatibility Ideograph appears.
function shouldRotateMonth(label) {
if (!label) return false
return /\p{Script=Latin}/u.test(label)
} }
const handleDayTouchEnd = (dateStr) => { // Watch first day changes (e.g., first_day config update) to adjust scroll
if (isDragging.value) { // Keep roughly same visible date when first_day setting changes.
endDrag(dateStr) watch(
const eventData = createEventFromSelection() () => calendarStore.config.first_day,
if (eventData) { () => {
clearSelection() const currentTopVW = Math.floor(scrollTop.value / rowHeight.value) + minVirtualWeek.value
emit('create-event', eventData) const currentTopDate = getFirstDayForVirtualWeek(currentTopVW)
} requestAnimationFrame(() => {
} const newTopWeekIndex = getWeekIndex(currentTopDate)
} const newScroll = (newTopWeekIndex - minVirtualWeek.value) * rowHeight.value
setScrollTop(newScroll, 'first-day-change')
resetWeeks('first-day-change')
})
},
)
const handleEventClick = (eventInstanceId) => { // Watch lightweight mutation counter only (not deep events map) and rebuild lazily
emit('edit-event', eventInstanceId) watch(
} () => calendarStore.events,
() => {
refreshEvents('events')
},
{ deep: true },
)
// Reflect selection & events by rebuilding day objects in-place
watch(
() => [selection.value.startDate, selection.value.dayCount],
() => refreshEvents('selection'),
)
// Rebuild if viewport height changes (e.g., resize)
window.addEventListener('resize', () => {
if (viewport.value) viewportHeight.value = viewport.value.clientHeight
measureFromProbe()
scheduleWindowUpdate('resize')
})
</script> </script>
<template> <template>
<div class="calendar-view-root">
<div ref="rowProbe" class="row-height-probe" aria-hidden="true"></div>
<div class="wrap"> <div class="wrap">
<header> <HeaderControls @go-to-today="goToToday" />
<h1>Calendar</h1>
<div class="header-controls">
<div class="today-date" @click="goToToday">{{ todayString }}</div>
</div>
</header>
<CalendarHeader <CalendarHeader
:scroll-top="scrollTop" :scroll-top="scrollTop"
:row-height="rowHeight" :row-height="rowHeight"
:min-virtual-week="minVirtualWeek" :min-virtual-week="minVirtualWeek"
@year-change="handleHeaderYearChange"
/> />
<div class="calendar-container"> <div class="calendar-container">
<div class="calendar-viewport" ref="viewport"> <div class="calendar-viewport" ref="viewport">
<!-- Main calendar content (weeks and days) -->
<div class="main-calendar-area">
<div class="calendar-content" :style="{ height: contentHeight + 'px' }"> <div class="calendar-content" :style="{ height: contentHeight + 'px' }">
<CalendarWeek <CalendarWeek
v-for="week in visibleWeeks" v-for="week in visibleWeeks"
:key="week.virtualWeek" :key="week.virtualWeek"
:week="week" :week="week"
:dragging="isDragging"
:style="{ top: week.top + 'px' }" :style="{ top: week.top + 'px' }"
@day-mousedown="handleDayMouseDown" @day-mousedown="handleDayMouseDown"
@day-mouseenter="handleDayMouseEnter" @day-mouseenter="handleDayMouseEnter"
@day-mouseup="handleDayMouseUp" @day-mouseup="handleDayMouseUp"
@day-touchstart="handleDayTouchStart" @day-touchstart="handleDayTouchStart"
@day-touchmove="handleDayTouchMove"
@day-touchend="handleDayTouchEnd"
@event-click="handleEventClick" @event-click="handleEventClick"
/> />
<!-- Month labels positioned absolutely --> </div>
</div>
<!-- Month column area -->
<div class="month-column-area">
<!-- Month labels -->
<div class="month-labels-container" :style="{ height: contentHeight + 'px' }">
<template v-for="monthWeek in visibleWeeks" :key="monthWeek.virtualWeek + '-month'">
<div <div
v-for="week in visibleWeeks" v-if="monthWeek && monthWeek.monthLabel"
:key="`month-${week.virtualWeek}`" class="month-label"
v-show="week.monthLabel" :class="monthWeek.monthLabel?.monthClass"
class="month-name-label"
:style="{ :style="{
top: week.top + 'px', height: `calc(var(--row-h) * ${monthWeek.monthLabel?.weeksSpan || 1})`,
height: week.monthLabel?.height + 'px', top: (monthWeek.top || 0) + 'px',
}" }"
@pointerdown="handleMonthScrollPointerDown"
@touchstart.prevent="handleMonthScrollTouchStart"
@wheel="handleMonthScrollWheel"
> >
<span>{{ week.monthLabel?.text }}</span> <span :class="{ bottomup: shouldRotateMonth(monthWeek.monthLabel?.text) }">{{
monthWeek.monthLabel?.text || ''
}}</span>
</div>
</template>
</div>
</div> </div>
</div> </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>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.calendar-view-root {
display: contents;
}
.wrap { .wrap {
height: 100vh; height: 100vh;
display: flex; display: flex;
@ -414,33 +497,15 @@ const handleEventClick = (eventInstanceId) => {
header { header {
display: flex; display: flex;
justify-content: space-between;
align-items: center; align-items: center;
gap: 1.25rem;
padding: 0.75rem 0.5rem 0.25rem 0.5rem;
} }
header h1 { header h1 {
margin: 0; margin: 0;
padding: 0; padding: 0;
} font-size: 1.6rem;
.header-controls { font-weight: 600;
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 { .calendar-container {
@ -460,7 +525,13 @@ header h1 {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
display: grid;
grid-template-columns: 1fr var(--month-w);
}
.main-calendar-area {
position: relative; position: relative;
overflow: hidden;
} }
.calendar-content { .calendar-content {
@ -468,27 +539,52 @@ header h1 {
width: 100%; width: 100%;
} }
.month-name-label { .month-column-area {
position: relative;
cursor: ns-resize;
}
.month-labels-container {
position: relative;
width: 100%;
height: 100%;
}
.month-label {
position: absolute; position: absolute;
right: 0; left: 0;
width: 3rem; /* Match jogwheel width */ width: 100%;
background-image: linear-gradient(to bottom, rgba(186, 186, 186, 0.3), rgba(186, 186, 186, 0.2));
font-size: 2em; font-size: 2em;
font-weight: 700; font-weight: 700;
color: var(--muted); color: var(--muted);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
pointer-events: none;
z-index: 15; z-index: 15;
overflow: visible; overflow: hidden;
cursor: ns-resize;
user-select: none;
touch-action: none;
} }
.month-name-label > span { .month-label > span {
display: inline-block; display: inline-block;
white-space: nowrap; white-space: nowrap;
writing-mode: vertical-rl; writing-mode: vertical-rl;
text-orientation: mixed; text-orientation: mixed;
transform: rotate(180deg);
transform-origin: center; transform-origin: center;
pointer-events: none;
}
.bottomup {
transform: rotate(180deg);
}
.row-height-probe {
position: absolute;
visibility: hidden;
height: var(--row-h);
pointer-events: none;
} }
</style> </style>

View File

@ -2,11 +2,15 @@
import CalendarDay from './CalendarDay.vue' import CalendarDay from './CalendarDay.vue'
import EventOverlay from './EventOverlay.vue' import EventOverlay from './EventOverlay.vue'
const props = defineProps({ const props = defineProps({ week: Object, dragging: { type: Boolean, default: false } })
week: Object
})
const emit = defineEmits(['day-mousedown', 'day-mouseenter', 'day-mouseup', 'day-touchstart', 'day-touchmove', 'day-touchend', 'event-click']) const emit = defineEmits([
'day-mousedown',
'day-mouseenter',
'day-mouseup',
'day-touchstart',
'event-click',
])
const handleDayMouseDown = (dateStr) => { const handleDayMouseDown = (dateStr) => {
emit('day-mousedown', dateStr) emit('day-mousedown', dateStr)
@ -24,42 +28,38 @@ const handleDayTouchStart = (dateStr) => {
emit('day-touchstart', dateStr) emit('day-touchstart', dateStr)
} }
const handleDayTouchMove = (dateStr) => { // touchmove & touchend handled globally in CalendarView
emit('day-touchmove', dateStr)
const handleEventClick = (payload) => {
emit('event-click', payload)
} }
const handleDayTouchEnd = (dateStr) => { // Only apply upside-down rotation (bottomup) for Latin script month labels
emit('day-touchend', dateStr) function shouldRotateMonth(label) {
} if (!label) return false
try {
const handleEventClick = (eventId) => { return /\p{Script=Latin}/u.test(label)
emit('event-click', eventId) } catch (e) {
return /[A-Za-z\u00C0-\u024F\u1E00-\u1EFF]/u.test(label)
}
} }
</script> </script>
<template> <template>
<div <div class="week-row" :style="{ top: `${props.week.top}px` }">
class="week-row"
:style="{ top: `${props.week.top}px` }"
>
<div class="week-label">W{{ props.week.weekNumber }}</div> <div class="week-label">W{{ props.week.weekNumber }}</div>
<div class="days-grid"> <div class="days-grid">
<CalendarDay <CalendarDay
v-for="day in props.week.days" v-for="day in props.week.days"
:key="day.date" :key="day.date"
:day="day" :day="day"
:dragging="props.dragging"
@mousedown="handleDayMouseDown(day.date)" @mousedown="handleDayMouseDown(day.date)"
@mouseenter="handleDayMouseEnter(day.date)" @mouseenter="handleDayMouseEnter(day.date)"
@mouseup="handleDayMouseUp(day.date)" @mouseup="handleDayMouseUp(day.date)"
@touchstart="handleDayTouchStart(day.date)" @touchstart="handleDayTouchStart(day.date)"
@touchmove="handleDayTouchMove(day.date)"
@touchend="handleDayTouchEnd(day.date)"
@event-click="handleEventClick"
/>
<EventOverlay
:week="props.week"
@event-click="handleEventClick"
/> />
<EventOverlay :week="props.week" @event-click="handleEventClick" />
</div> </div>
</div> </div>
</template> </template>
@ -67,9 +67,9 @@ const handleEventClick = (eventId) => {
<style scoped> <style scoped>
.week-row { .week-row {
display: grid; display: grid;
grid-template-columns: var(--label-w) repeat(7, 1fr) 3rem; grid-template-columns: var(--week-w) repeat(7, 1fr);
position: absolute; position: absolute;
height: var(--cell-h); height: var(--row-h);
width: 100%; width: 100%;
} }
@ -80,13 +80,8 @@ const handleEventClick = (eventId) => {
color: var(--muted); color: var(--muted);
font-size: 1.2em; font-size: 1.2em;
font-weight: 500; font-weight: 500;
/* Prevent text selection */
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none; user-select: none;
-webkit-touch-callout: none; height: var(--row-h);
-webkit-tap-highlight-color: transparent;
} }
.days-grid { .days-grid {
@ -96,10 +91,4 @@ const handleEventClick = (eventId) => {
height: 100%; height: 100%;
width: 100%; width: 100%;
} }
/* Fixed heights for cells and labels (from cells.css) */
.week-row :deep(.cell),
.week-label {
height: var(--cell-h);
}
</style> </style>

File diff suppressed because it is too large Load Diff

View File

@ -5,6 +5,8 @@
:key="span.id" :key="span.id"
class="event-span" class="event-span"
:class="[`event-color-${span.colorId}`]" :class="[`event-color-${span.colorId}`]"
:data-id="span.id"
:data-n="span._recurrenceIndex != null ? span._recurrenceIndex : 0"
:style="{ :style="{
gridColumn: `${span.startIdx + 1} / ${span.endIdx + 2}`, gridColumn: `${span.startIdx + 1} / ${span.endIdx + 2}`,
gridRow: `${span.row}`, gridRow: `${span.row}`,
@ -24,174 +26,104 @@
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useCalendarStore } from '@/stores/CalendarStore' import { useCalendarStore } from '@/stores/CalendarStore'
import { toLocalString, fromLocalString, daysInclusive, addDaysStr } from '@/utils/date' import { daysInclusive, addDaysStr } from '@/utils/date'
const props = defineProps({ const props = defineProps({
week: { week: { type: Object, required: true },
type: Object,
required: true,
},
}) })
const emit = defineEmits(['event-click']) const emit = defineEmits(['event-click'])
const store = useCalendarStore() const store = useCalendarStore()
// Local drag state // Drag state
const dragState = ref(null) const dragState = ref(null)
const justDragged = ref(false) const justDragged = ref(false)
// Generate repeat occurrences for a specific date // Consolidate already-provided day.events into contiguous spans (no recurrence generation)
function generateRepeatOccurrencesForDate(targetDateStr) { const eventSpans = computed(() => {
const occurrences = [] const weekEvents = new Map()
props.week.days.forEach((day, dayIndex) => {
// Get all events from the store and check for repeating ones day.events.forEach((ev) => {
for (const [, eventList] of store.events) { const key = ev.id
for (const baseEvent of eventList) { if (!weekEvents.has(key)) {
if (!baseEvent.isRepeating || baseEvent.repeat === 'none') { weekEvents.set(key, { ...ev, startIdx: dayIndex, endIdx: dayIndex })
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 { } else {
// Handle other repeat types (months) const ref = weekEvents.get(key)
let intervalsPassed = 0 ref.endIdx = Math.max(ref.endIdx, dayIndex)
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,
}) })
})
const arr = Array.from(weekEvents.values())
arr.sort((a, b) => {
const spanA = a.endIdx - a.startIdx
const spanB = b.endIdx - b.startIdx
if (spanA !== spanB) return spanB - spanA
if (a.startIdx !== b.startIdx) return a.startIdx - b.startIdx
// For one-day events that are otherwise equal, sort by color (0 first)
if (spanA === 0 && spanB === 0 && a.startIdx === b.startIdx) {
const colorA = a.colorId || 0
const colorB = b.colorId || 0
if (colorA !== colorB) return colorA - colorB
} }
} return String(a.id).localeCompare(String(b.id))
} })
} // Assign non-overlapping rows
const rowsLastEnd = []
arr.forEach((ev) => {
let row = 0
while (row < rowsLastEnd.length && !(ev.startIdx > rowsLastEnd[row])) row++
if (row === rowsLastEnd.length) rowsLastEnd.push(-1)
rowsLastEnd[row] = ev.endIdx
ev.row = row + 1
})
return arr
})
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) { function handleEventClick(span) {
if (justDragged.value) return if (justDragged.value) return
// Emit the actual span id (may include repeat suffix) so edit dialog knows occurrence context // Emit composite payload: base id (without virtual marker), instance id, occurrence index (data-n)
emit('event-click', span.id) const idStr = span.id
const hasVirtualMarker = typeof idStr === 'string' && idStr.includes('_v_')
const baseId = hasVirtualMarker ? idStr.slice(0, idStr.lastIndexOf('_v_')) : idStr
emit('event-click', {
id: baseId,
instanceId: span.id,
occurrenceIndex: span._recurrenceIndex != null ? span._recurrenceIndex : 0,
})
} }
// Handle event pointer down for dragging
function handleEventPointerDown(span, event) { function handleEventPointerDown(span, event) {
// Don't start drag if clicking on resize handle
if (event.target.classList.contains('resize-handle')) return if (event.target.classList.contains('resize-handle')) return
event.stopPropagation() event.stopPropagation()
// Do not preventDefault here to allow click unless drag threshold is passed const idStr = span.id
const hasVirtualMarker = typeof idStr === 'string' && idStr.includes('_v_')
// Get the date under the pointer const baseId = hasVirtualMarker ? idStr.slice(0, idStr.lastIndexOf('_v_')) : idStr
const hit = getDateUnderPointer(event.clientX, event.clientY, event.currentTarget) const isVirtual = hasVirtualMarker
const anchorDate = hit ? hit.date : span.startDate // Determine which day within the span was grabbed so we maintain relative position
let anchorDate = span.startDate
try {
const spanDays = daysInclusive(span.startDate, span.endDate)
const targetEl = event.currentTarget
if (targetEl && spanDays > 0) {
const rect = targetEl.getBoundingClientRect()
const relX = event.clientX - rect.left
const dayWidth = rect.width / spanDays
let dayIndex = Math.floor(relX / dayWidth)
if (!isFinite(dayIndex)) dayIndex = 0
if (dayIndex < 0) dayIndex = 0
if (dayIndex >= spanDays) dayIndex = spanDays - 1
anchorDate = addDaysStr(span.startDate, dayIndex)
}
} catch (e) {
// Fallback to startDate if any calculation fails
}
startLocalDrag( startLocalDrag(
{ {
id: span.id, id: baseId,
originalId: span.id,
isVirtual,
mode: 'move', mode: 'move',
pointerStartX: event.clientX, pointerStartX: event.clientX,
pointerStartY: event.clientY, pointerStartY: event.clientY,
@ -203,13 +135,17 @@ function handleEventPointerDown(span, event) {
) )
} }
// Handle resize handle pointer down
function handleResizePointerDown(span, mode, event) { function handleResizePointerDown(span, mode, event) {
event.stopPropagation() event.stopPropagation()
// Start drag from the current edge; anchorDate not needed for resize const idStr = span.id
const hasVirtualMarker = typeof idStr === 'string' && idStr.includes('_v_')
const baseId = hasVirtualMarker ? idStr.slice(0, idStr.lastIndexOf('_v_')) : idStr
const isVirtual = hasVirtualMarker
startLocalDrag( startLocalDrag(
{ {
id: span.id, id: baseId,
originalId: span.id,
isVirtual,
mode, mode,
pointerStartX: event.clientX, pointerStartX: event.clientX,
pointerStartY: event.clientY, pointerStartY: event.clientY,
@ -221,94 +157,6 @@ function handleResizePointerDown(span, mode, 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 // Local drag handling
function startLocalDrag(init, evt) { function startLocalDrag(init, evt) {
const spanDays = daysInclusive(init.startDate, init.endDate) const spanDays = daysInclusive(init.startDate, init.endDate)
@ -319,13 +167,39 @@ function startLocalDrag(init, evt) {
else anchorOffset = daysInclusive(init.startDate, init.anchorDate) - 1 else anchorOffset = daysInclusive(init.startDate, init.anchorDate) - 1
} }
// Capture original repeating pattern & weekday (for weekly repeats) so we can rotate relative to original
let originalWeekday = null
let originalPattern = null
if (init.mode === 'move') {
try {
originalWeekday = new Date(init.startDate + 'T00:00:00').getDay()
const baseEv = store.getEventById(init.id)
if (
baseEv &&
baseEv.recur &&
baseEv.recur.freq === 'weeks' &&
Array.isArray(baseEv.recur.weekdays)
) {
originalPattern = [...baseEv.recur.weekdays]
}
} catch {}
}
dragState.value = { dragState.value = {
...init, ...init,
anchorOffset, anchorOffset,
originSpanDays: spanDays, originSpanDays: spanDays,
eventMoved: false, eventMoved: false,
tentativeStart: init.startDate,
tentativeEnd: init.endDate,
originalWeekday,
originalPattern,
realizedId: null, // for virtual occurrence converted to real during drag
} }
// Begin compound history session (single snapshot after drag completes)
store.$history?.beginCompound()
// Capture pointer events globally // Capture pointer events globally
if (evt.currentTarget && evt.pointerId !== undefined) { if (evt.currentTarget && evt.pointerId !== undefined) {
try { try {
@ -335,14 +209,35 @@ function startLocalDrag(init, evt) {
} }
} }
// Prevent default to avoid text selection and other interference // Prevent default for mouse/pen to avoid text selection. For touch we skip so the user can still scroll.
if (!(evt.pointerType === 'touch')) {
evt.preventDefault() evt.preventDefault()
}
window.addEventListener('pointermove', onDragPointerMove, { passive: false }) window.addEventListener('pointermove', onDragPointerMove, { passive: false })
window.addEventListener('pointerup', onDragPointerUp, { passive: false }) window.addEventListener('pointerup', onDragPointerUp, { passive: false })
window.addEventListener('pointercancel', onDragPointerUp, { passive: false }) window.addEventListener('pointercancel', onDragPointerUp, { passive: false })
} }
// Determine date under pointer: traverse DOM to find day cell carrying data-date attribute
function getDateUnderPointer(x, y, el) {
let cur = el
while (cur) {
if (cur.dataset && cur.dataset.date) {
return { date: cur.dataset.date }
}
cur = cur.parentElement
}
// Fallback: elementFromPoint scan
const probe = document.elementFromPoint(x, y)
let p = probe
while (p) {
if (p.dataset && p.dataset.date) return { date: p.dataset.date }
p = p.parentElement
}
return null
}
function onDragPointerMove(e) { function onDragPointerMove(e) {
const st = dragState.value const st = dragState.value
if (!st) return if (!st) return
@ -360,7 +255,66 @@ function onDragPointerMove(e) {
const [ns, ne] = computeTentativeRangeFromPointer(st, hit.date) const [ns, ne] = computeTentativeRangeFromPointer(st, hit.date)
if (!ns || !ne) return if (!ns || !ne) return
applyRangeDuringDrag(st, ns, ne) // Only proceed if changed
if (ns === st.tentativeStart && ne === st.tentativeEnd) return
st.tentativeStart = ns
st.tentativeEnd = ne
if (st.mode === 'move') {
if (st.isVirtual) {
// On first movement convert virtual occurrence into a real new event (split series)
if (!st.realizedId) {
const newId = store.splitMoveVirtualOccurrence(st.id, st.startDate, ns, ne)
if (newId) {
st.realizedId = newId
st.id = newId
st.isVirtual = false
} else {
return
}
} else {
// Subsequent moves: update range without rotating pattern automatically
store.setEventRange(st.id, ns, ne, { mode: 'move', rotatePattern: false })
}
} else {
// Normal non-virtual move; rotate handled in setEventRange
store.setEventRange(st.id, ns, ne, { mode: 'move', rotatePattern: false })
}
// Manual rotation relative to original pattern (keeps pattern anchored to initially grabbed weekday)
if (st.originalPattern && st.originalWeekday != null) {
try {
const currentWeekday = new Date(ns + 'T00:00:00').getDay()
const shift = currentWeekday - st.originalWeekday
const rotated = store._rotateWeekdayPattern([...st.originalPattern], shift)
const ev = store.getEventById(st.id)
if (ev && ev.recur && ev.recur.freq === 'weeks') {
ev.recur.weekdays = rotated
store.touchEvents()
}
} catch {}
}
} else if (!st.isVirtual) {
// Resizes on real events update immediately
applyRangeDuringDrag(
{ id: st.id, isVirtual: st.isVirtual, mode: st.mode, startDate: ns, endDate: ne },
ns,
ne,
)
} else if (st.isVirtual && (st.mode === 'resize-left' || st.mode === 'resize-right')) {
// For virtual occurrence resize: convert to real once, then adjust range
if (!st.realizedId) {
const initialStart = ns
const initialEnd = ne
const newId = store.splitMoveVirtualOccurrence(st.id, st.startDate, initialStart, initialEnd)
if (newId) {
st.realizedId = newId
st.id = newId
st.isVirtual = false
} else return
}
// Apply range change; rotate if left edge moved and weekday changed
const rotate = st.mode === 'resize-left'
store.setEventRange(st.id, ns, ne, { mode: st.mode, rotatePattern: rotate })
}
} }
function onDragPointerUp(e) { function onDragPointerUp(e) {
@ -377,6 +331,8 @@ function onDragPointerUp(e) {
} }
const moved = !!st.eventMoved const moved = !!st.eventMoved
const finalStart = st.tentativeStart
const finalEnd = st.tentativeEnd
dragState.value = null dragState.value = null
window.removeEventListener('pointermove', onDragPointerMove) window.removeEventListener('pointermove', onDragPointerMove)
@ -384,11 +340,27 @@ function onDragPointerUp(e) {
window.removeEventListener('pointercancel', onDragPointerUp) window.removeEventListener('pointercancel', onDragPointerUp)
if (moved) { if (moved) {
// Apply final mutation if virtual (we deferred) or if non-virtual no further change (rare)
if (st.isVirtual) {
applyRangeDuringDrag(
{
id: st.id,
isVirtual: st.isVirtual,
mode: st.mode,
startDate: finalStart,
endDate: finalEnd,
},
finalStart,
finalEnd,
)
}
justDragged.value = true justDragged.value = true
setTimeout(() => { setTimeout(() => {
justDragged.value = false justDragged.value = false
}, 120) }, 120)
} }
// End compound session (snapshot if changed)
store.$history?.endCompound()
} }
function computeTentativeRangeFromPointer(st, dropDateStr) { function computeTentativeRangeFromPointer(st, dropDateStr) {
@ -416,133 +388,13 @@ function normalizeDateOrder(aStr, bStr) {
} }
function applyRangeDuringDrag(st, startDate, endDate) { function applyRangeDuringDrag(st, startDate, endDate) {
let ev = store.getEventById(st.id) if (st.isVirtual) {
let isRepeatOccurrence = false if (st.mode !== 'move') return // no resize for virtual occurrence
let baseId = st.id // Split-move: occurrence being dragged treated as first of new series
let repeatIndex = 0 store.splitMoveVirtualOccurrence(st.id, st.startDate, startDate, endDate)
let grabbedWeekday = null return
// 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
} }
} store.setEventRange(st.id, startDate, endDate, { mode: st.mode })
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> </script>
@ -564,7 +416,7 @@ function timeToMinutes(timeStr) {
.event-span { .event-span {
padding: 0.1em 0.3em; padding: 0.1em 0.3em;
border-radius: 0.2em; border-radius: 1em;
font-size: clamp(0.45em, 1.8vh, 0.75em); font-size: clamp(0.45em, 1.8vh, 0.75em);
font-weight: 600; font-weight: 600;
cursor: grab; cursor: grab;

View File

@ -0,0 +1,210 @@
<template>
<Transition name="header-controls" appear>
<div v-if="isVisible" class="header-controls">
<div class="today-date" @click="goToToday">{{ todayString }}</div>
<button
type="button"
class="hist-btn"
:disabled="!calendarStore.historyCanUndo"
@click="calendarStore.$history?.undo()"
title="Undo (Ctrl+Z)"
aria-label="Undo"
>
</button>
<button
type="button"
class="hist-btn"
:disabled="!calendarStore.historyCanRedo"
@click="calendarStore.$history?.redo()"
title="Redo (Ctrl+Shift+Z)"
aria-label="Redo"
>
</button>
<button
type="button"
class="settings-btn"
@click="openSettings"
aria-label="Open settings"
title="Settings"
>
</button>
<!-- Settings dialog now lives here -->
<SettingsDialog ref="settingsDialog" />
</div>
</Transition>
<button
type="button"
class="toggle-btn"
@click="toggleVisibility"
:aria-label="isVisible ? 'Hide controls' : 'Show controls'"
:title="isVisible ? 'Hide controls' : 'Show controls'"
>
</button>
</template>
<script setup>
import { computed, ref, onMounted, onBeforeUnmount } from 'vue'
import { useCalendarStore } from '@/stores/CalendarStore'
import { formatTodayString } from '@/utils/date'
import SettingsDialog from '@/components/SettingsDialog.vue'
const calendarStore = useCalendarStore()
const todayString = computed(() => {
const d = new Date(calendarStore.now)
return formatTodayString(d)
})
const emit = defineEmits(['go-to-today'])
function goToToday() {
// Emit the event so the parent can handle the viewport scrolling logic
// since this component doesn't have access to viewport refs
emit('go-to-today')
}
// Screen size detection and visibility toggle
const isVisible = ref(false)
function checkScreenSize() {
const isSmallScreen = window.innerHeight < 600
// Default to open on large screens, closed on small screens
isVisible.value = !isSmallScreen
}
function toggleVisibility() {
isVisible.value = !isVisible.value
}
// Settings dialog integration
const settingsDialog = ref(null)
function openSettings() {
settingsDialog.value?.open()
}
onMounted(() => {
checkScreenSize()
window.addEventListener('resize', checkScreenSize)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', checkScreenSize)
})
</script>
<style scoped>
.header-controls {
display: flex;
justify-content: end;
align-items: center;
margin-right: 1.5rem;
}
.toggle-btn {
position: fixed;
top: 0;
right: 0;
background: transparent;
border: none;
color: var(--muted);
padding: 0;
margin: 0.5em;
cursor: pointer;
font-size: 1em;
font-weight: 700;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
outline: none;
width: 1em;
height: 1em;
transition: all 0.2s ease;
}
.toggle-btn:hover {
color: var(--strong);
}
.toggle-btn:active {
transform: scale(0.9);
}
.header-controls-enter-active,
.header-controls-leave-active {
transition: all 0.3s ease;
overflow: hidden;
}
.header-controls-enter-from,
.header-controls-leave-to {
opacity: 0;
max-height: 0;
transform: translateY(-20px);
}
.header-controls-enter-to,
.header-controls-leave-from {
opacity: 1;
max-height: 100px;
transform: translateY(0);
}
.settings-btn {
background: transparent;
border: none;
color: var(--muted);
padding: 0;
margin: 0;
margin-right: 0.6rem;
cursor: pointer;
font-size: 1.5rem;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
outline: none;
}
.hist-btn {
background: transparent;
border: none;
color: var(--muted);
padding: 0;
margin: 0;
cursor: pointer;
font-size: 1.2rem;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
outline: none;
width: 1.9rem;
height: 1.9rem;
}
.hist-btn:disabled {
opacity: 0.35;
cursor: default;
}
.hist-btn:not(:disabled):hover,
.hist-btn:not(:disabled):focus-visible {
color: var(--strong);
}
.hist-btn:active:not(:disabled) {
transform: scale(0.88);
}
.settings-btn:hover {
color: var(--strong);
}
.today-date {
white-space: pre-line;
text-align: center;
margin-right: 2rem;
}
</style>

View File

@ -1,17 +1,21 @@
<template> <template>
<div class="jogwheel-viewport" ref="jogwheelViewport" @scroll="handleJogwheelScroll"> <div class="jogwheel-viewport" ref="jogwheelViewport" @scroll="handleJogwheelScroll">
<div class="jogwheel-content" ref="jogwheelContent" :style="{ height: jogwheelHeight + 'px' }"></div> <div
class="jogwheel-content"
ref="jogwheelContent"
:style="{ height: jogwheelHeight + 'px' }"
></div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, computed, watch } from 'vue' import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
const props = defineProps({ const props = defineProps({
totalVirtualWeeks: { type: Number, required: true }, totalVirtualWeeks: { type: Number, required: true },
rowHeight: { type: Number, required: true }, rowHeight: { type: Number, required: true },
viewportHeight: { type: Number, required: true }, viewportHeight: { type: Number, required: true },
scrollTop: { type: Number, required: true } scrollTop: { type: Number, required: true },
}) })
const emit = defineEmits(['scroll-to']) const emit = defineEmits(['scroll-to'])
@ -19,6 +23,12 @@ const emit = defineEmits(['scroll-to'])
const jogwheelViewport = ref(null) const jogwheelViewport = ref(null)
const jogwheelContent = ref(null) const jogwheelContent = ref(null)
const syncLock = ref(null) const syncLock = ref(null)
// Drag state (no momentum, 1:1 mapping)
const isDragging = ref(false)
let mainStartScroll = 0
let dragScale = 1 // mainScrollPixels per mouse pixel
let accumDelta = 0
let pointerLocked = false
// Jogwheel content height is 1/10th of main calendar // Jogwheel content height is 1/10th of main calendar
const jogwheelHeight = computed(() => { const jogwheelHeight = computed(() => {
@ -30,13 +40,92 @@ const handleJogwheelScroll = () => {
syncFromJogwheel() syncFromJogwheel()
} }
function onDragMouseDown(e) {
if (e.button !== 0) return
isDragging.value = true
mainStartScroll = props.scrollTop
accumDelta = 0
// Precompute scale between jogwheel scrollable range and main scrollable range
const mainScrollable = Math.max(
0,
props.totalVirtualWeeks * props.rowHeight - props.viewportHeight,
)
let jogScrollable = 0
if (jogwheelViewport.value && jogwheelContent.value) {
jogScrollable = Math.max(
0,
jogwheelContent.value.scrollHeight - jogwheelViewport.value.clientHeight,
)
}
dragScale = jogScrollable > 0 ? mainScrollable / jogScrollable : 1
if (!isFinite(dragScale) || dragScale <= 0) dragScale = 1
// Attempt pointer lock for relative movement
if (jogwheelViewport.value && jogwheelViewport.value.requestPointerLock) {
jogwheelViewport.value.requestPointerLock()
}
window.addEventListener('mousemove', onDragMouseMove, { passive: false })
window.addEventListener('mouseup', onDragMouseUp, { passive: false })
e.preventDefault()
}
function onDragMouseMove(e) {
if (!isDragging.value) return
const dy = pointerLocked ? e.movementY : e.clientY // movementY only valid in pointer lock
accumDelta += dy
let desired = mainStartScroll - accumDelta * dragScale
if (desired < 0) desired = 0
const maxScroll = Math.max(0, props.totalVirtualWeeks * props.rowHeight - props.viewportHeight)
if (desired > maxScroll) desired = maxScroll
emit('scroll-to', desired)
e.preventDefault()
}
function onDragMouseUp(e) {
if (!isDragging.value) return
isDragging.value = false
window.removeEventListener('mousemove', onDragMouseMove)
window.removeEventListener('mouseup', onDragMouseUp)
if (pointerLocked && document.exitPointerLock) document.exitPointerLock()
e.preventDefault()
}
function handlePointerLockChange() {
pointerLocked = document.pointerLockElement === jogwheelViewport.value
if (!pointerLocked && isDragging.value) {
// Pointer lock lost (Esc) -> end drag gracefully
onDragMouseUp(new MouseEvent('mouseup'))
}
}
onMounted(() => {
if (jogwheelViewport.value) {
jogwheelViewport.value.addEventListener('mousedown', onDragMouseDown)
}
document.addEventListener('pointerlockchange', handlePointerLockChange)
})
onBeforeUnmount(() => {
if (jogwheelViewport.value) {
jogwheelViewport.value.removeEventListener('mousedown', onDragMouseDown)
}
window.removeEventListener('mousemove', onDragMouseMove)
window.removeEventListener('mouseup', onDragMouseUp)
document.removeEventListener('pointerlockchange', handlePointerLockChange)
})
const syncFromJogwheel = () => { const syncFromJogwheel = () => {
if (!jogwheelViewport.value || !jogwheelContent.value) return if (!jogwheelViewport.value || !jogwheelContent.value) return
syncLock.value = 'main' syncLock.value = 'main'
const jogScrollable = Math.max(0, jogwheelContent.value.scrollHeight - jogwheelViewport.value.clientHeight) const jogScrollable = Math.max(
const mainScrollable = Math.max(0, props.totalVirtualWeeks * props.rowHeight - props.viewportHeight) 0,
jogwheelContent.value.scrollHeight - jogwheelViewport.value.clientHeight,
)
const mainScrollable = Math.max(
0,
props.totalVirtualWeeks * props.rowHeight - props.viewportHeight,
)
if (jogScrollable > 0) { if (jogScrollable > 0) {
const ratio = jogwheelViewport.value.scrollTop / jogScrollable const ratio = jogwheelViewport.value.scrollTop / jogScrollable
@ -56,8 +145,14 @@ const syncFromMain = (mainScrollTop) => {
syncLock.value = 'jogwheel' syncLock.value = 'jogwheel'
const mainScrollable = Math.max(0, props.totalVirtualWeeks * props.rowHeight - props.viewportHeight) const mainScrollable = Math.max(
const jogScrollable = Math.max(0, jogwheelContent.value.scrollHeight - jogwheelViewport.value.clientHeight) 0,
props.totalVirtualWeeks * props.rowHeight - props.viewportHeight,
)
const jogScrollable = Math.max(
0,
jogwheelContent.value.scrollHeight - jogwheelViewport.value.clientHeight,
)
if (mainScrollable > 0) { if (mainScrollable > 0) {
const ratio = mainScrollTop / mainScrollable const ratio = mainScrollTop / mainScrollable
@ -70,12 +165,15 @@ const syncFromMain = (mainScrollTop) => {
} }
// Watch for main calendar scroll changes // Watch for main calendar scroll changes
watch(() => props.scrollTop, (newScrollTop) => { watch(
() => props.scrollTop,
(newScrollTop) => {
syncFromMain(newScrollTop) syncFromMain(newScrollTop)
}) },
)
defineExpose({ defineExpose({
syncFromMain syncFromMain,
}) })
</script> </script>
@ -85,20 +183,12 @@ defineExpose({
top: 0; top: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
width: 3rem; /* Use fixed width since minmax() doesn't work for absolute positioning */ width: var(--month-w);
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
scrollbar-width: none; scrollbar-width: none;
z-index: 20; z-index: 20;
cursor: ns-resize; 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 { .jogwheel-viewport::-webkit-scrollbar {

View File

@ -7,21 +7,20 @@
role="spinbutton" role="spinbutton"
:aria-valuemin="minValue" :aria-valuemin="minValue"
:aria-valuemax="maxValue" :aria-valuemax="maxValue"
:aria-valuenow="isPrefix(current) ? undefined : current" :aria-valuenow="isPrefix(model) ? undefined : model"
:aria-valuetext="display" :aria-valuetext="display"
tabindex="0" tabindex="0"
@pointerdown="onPointerDown" @pointerdown="onPointerDown"
@keydown="onKey" @keydown="onKey"
@wheel.prevent="onWheel"
> >
<span class="value" :title="String(current)">{{ display }}</span> <span class="value" :title="String(model)">{{ display }}</span>
</div> </div>
</template> </template>
<script setup> <script setup>
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
const model = defineModel({ default: 0 })
const model = defineModel({ type: Number, default: 0 })
const props = defineProps({ const props = defineProps({
min: { type: Number, default: 0 }, min: { type: Number, default: 0 },
max: { type: Number, default: 999 }, max: { type: Number, default: 999 },
@ -36,111 +35,122 @@ const props = defineProps({
numberPostfix: { type: String, default: '' }, numberPostfix: { type: String, default: '' },
clamp: { type: Boolean, default: true }, clamp: { type: Boolean, default: true },
pixelsPerStep: { type: Number, default: 16 }, pixelsPerStep: { type: Number, default: 16 },
// Movement now restricted to horizontal (x). Prop retained for compatibility but ignored.
axis: { type: String, default: 'x' }, axis: { type: String, default: 'x' },
ariaLabel: { type: String, default: '' }, ariaLabel: { type: String, default: '' },
extraClass: { type: String, default: '' }, extraClass: { type: String, default: '' },
}) })
const minValue = computed(() => props.min) const minValue = computed(() => props.min)
const maxValue = computed(() => props.max) const maxValue = computed(() => props.max)
const isPrefix = (value) => props.prefixValues.some((p) => p.value === value)
// Helper to check if a value is in the prefix values const getPrefixDisplay = (value) =>
const isPrefix = (value) => { props.prefixValues.find((p) => p.value === value)?.display ?? null
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 allValidValues = computed(() => {
const prefixVals = props.prefixValues.map((p) => p.value) const prefixVals = props.prefixValues.map((p) => p.value)
const numericVals = [] const numericVals = []
for (let i = props.min; i <= props.max; i += props.step) { for (let i = props.min; i <= props.max; i += props.step) numericVals.push(i)
numericVals.push(i)
}
return [...prefixVals, ...numericVals] 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 display = computed(() => {
const prefixDisplay = getPrefixDisplay(current.value) const prefixDisplay = getPrefixDisplay(model.value)
if (prefixDisplay !== null) { if (prefixDisplay !== null) return prefixDisplay
// For prefix values, show only the display text without number prefix/postfix return `${props.numberPrefix}${String(model.value)}${props.numberPostfix}`
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 dragging = ref(false)
const rootEl = ref(null) const rootEl = ref(null)
let startX = 0 let startX = 0
let startY = 0 let startY = 0
let startVal = 0 let accumX = 0
let lastClientX = 0
const pointerLocked = ref(false)
function updatePointerLocked() {
pointerLocked.value =
typeof document !== 'undefined' && document.pointerLockElement === rootEl.value
if (pointerLocked.value) {
accumX = 0
startX = 0
}
}
function addPointerLockListeners() {
if (typeof document === 'undefined') return
document.addEventListener('pointerlockchange', updatePointerLocked)
document.addEventListener('pointerlockerror', updatePointerLocked)
}
function removePointerLockListeners() {
if (typeof document === 'undefined') return
document.removeEventListener('pointerlockchange', updatePointerLocked)
document.removeEventListener('pointerlockerror', updatePointerLocked)
}
function onPointerDown(e) { function onPointerDown(e) {
e.preventDefault() e.preventDefault()
startX = e.clientX startX = e.clientX
startY = e.clientY startY = e.clientY
startVal = current.value lastClientX = e.clientX
accumX = 0
dragging.value = true dragging.value = true
try { try {
e.currentTarget.setPointerCapture(e.pointerId) e.currentTarget.setPointerCapture?.(e.pointerId)
} catch {} } catch {}
rootEl.value?.addEventListener('pointermove', onPointerMove) if (e.pointerType === 'mouse' && rootEl.value?.requestPointerLock) {
rootEl.value?.addEventListener('pointerup', onPointerUp, { once: true }) addPointerLockListeners()
rootEl.value?.addEventListener('pointercancel', onPointerCancel, { once: true }) try {
rootEl.value.requestPointerLock()
} catch {}
}
document.addEventListener('pointermove', onPointerMove)
document.addEventListener('pointerup', onPointerUp, { once: true })
document.addEventListener('pointercancel', onPointerCancel, { once: true })
} }
function onPointerMove(e) { function onPointerMove(e) {
if (!dragging.value) return if (!dragging.value) return
// Prevent page scroll on touch while dragging
if (e.pointerType === 'touch') e.preventDefault() if (e.pointerType === 'touch') e.preventDefault()
const primary = e.clientX - startX // horizontal only let dx = pointerLocked.value ? e.movementX || 0 : e.clientX - lastClientX
const steps = Math.trunc(primary / props.pixelsPerStep) if (!pointerLocked.value) lastClientX = e.clientX
if (!dx) return
// Find current value index in all valid values accumX += dx
const currentIndex = allValidValues.value.indexOf(startVal) const stepSize = props.pixelsPerStep || 1
if (currentIndex === -1) return // shouldn't happen let steps = Math.trunc(accumX / stepSize)
if (steps === 0) return
const newIndex = currentIndex + steps const applySteps = (count) => {
if (props.clamp) { if (!count) return
const clampedIndex = Math.max(0, Math.min(newIndex, allValidValues.value.length - 1)) let direction = count > 0 ? 1 : -1
const next = allValidValues.value[clampedIndex] let remaining = Math.abs(count)
if (next !== current.value) current.value = next let curVal = model.value
const isNumeric = typeof curVal === 'number' && !Number.isNaN(curVal)
let idx = allValidValues.value.indexOf(curVal)
if (idx === -1) {
if (!isNumeric) {
curVal = props.prefixValues.length ? props.prefixValues[0].value : props.min
} else { } else {
if (newIndex >= 0 && newIndex < allValidValues.value.length) { if (direction > 0) curVal = props.min
const next = allValidValues.value[newIndex] else
if (next !== current.value) current.value = next curVal = props.prefixValues.length
? props.prefixValues[props.prefixValues.length - 1].value
: props.min
} }
remaining--
} }
while (remaining > 0) {
idx = allValidValues.value.indexOf(curVal)
if (idx === -1) break
let targetIdx = idx + direction
if (props.clamp) targetIdx = Math.max(0, Math.min(targetIdx, allValidValues.value.length - 1))
if (targetIdx < 0 || targetIdx >= allValidValues.value.length || targetIdx === idx) break
curVal = allValidValues.value[targetIdx]
remaining--
}
model.value = curVal
}
applySteps(steps)
accumX -= steps * stepSize
} }
function endDragListeners() { function endDragListeners() {
rootEl.value?.removeEventListener('pointermove', onPointerMove) document.removeEventListener('pointermove', onPointerMove)
if (pointerLocked.value && document.exitPointerLock) {
try {
document.exitPointerLock()
} catch {}
}
removePointerLockListeners()
} }
function onPointerUp() { function onPointerUp() {
dragging.value = false dragging.value = false
@ -150,52 +160,43 @@ function onPointerCancel() {
dragging.value = false dragging.value = false
endDragListeners() endDragListeners()
} }
function onKey(e) { function onKey(e) {
const key = e.key const key = e.key
let handled = false let handled = false
let newValue = null let newValue = null
const currentIndex = allValidValues.value.indexOf(model.value)
// Find current value index in all valid values
const currentIndex = allValidValues.value.indexOf(current.value)
switch (key) { switch (key) {
case 'ArrowRight': case 'ArrowRight':
case 'ArrowUp': case 'ArrowUp':
if (currentIndex !== -1 && currentIndex < allValidValues.value.length - 1) { if (currentIndex !== -1 && currentIndex < allValidValues.value.length - 1)
newValue = allValidValues.value[currentIndex + 1] newValue = allValidValues.value[currentIndex + 1]
} else if (currentIndex === -1) { else if (currentIndex === -1) {
// Current value not in list, try to increment normally const curVal = model.value
newValue = current.value + props.step const isNumeric = typeof curVal === 'number' && !Number.isNaN(curVal)
if (!isNumeric && props.prefixValues.length) newValue = props.prefixValues[0].value
else newValue = props.min
} }
handled = true handled = true
break break
case 'ArrowLeft': case 'ArrowLeft':
case 'ArrowDown': case 'ArrowDown':
if (currentIndex !== -1 && currentIndex > 0) { if (currentIndex !== -1 && currentIndex > 0) newValue = allValidValues.value[currentIndex - 1]
newValue = allValidValues.value[currentIndex - 1] else if (currentIndex === -1)
} else if (currentIndex === -1) { newValue = props.prefixValues.length
// Current value not in list, try to decrement normally ? props.prefixValues[props.prefixValues.length - 1].value
newValue = current.value - props.step : props.min
}
handled = true handled = true
break break
case 'PageUp': case 'PageUp':
if (currentIndex !== -1) { if (currentIndex !== -1)
const newIndex = Math.min(currentIndex + 10, allValidValues.value.length - 1) newValue =
newValue = allValidValues.value[newIndex] allValidValues.value[Math.min(currentIndex + 10, allValidValues.value.length - 1)]
} else { else newValue = model.value + props.step * 10
newValue = current.value + props.step * 10
}
handled = true handled = true
break break
case 'PageDown': case 'PageDown':
if (currentIndex !== -1) { if (currentIndex !== -1) newValue = allValidValues.value[Math.max(currentIndex - 10, 0)]
const newIndex = Math.max(currentIndex - 10, 0) else newValue = model.value - props.step * 10
newValue = allValidValues.value[newIndex]
} else {
newValue = current.value - props.step * 10
}
handled = true handled = true
break break
case 'Home': case 'Home':
@ -207,16 +208,32 @@ function onKey(e) {
handled = true handled = true
break break
} }
if (newValue !== null) model.value = newValue
if (newValue !== null) {
current.value = newValue
}
if (handled) { if (handled) {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
} }
} }
function onWheel(e) {
const direction = e.deltaY < 0 ? -1 : e.deltaY > 0 ? 1 : 0
if (direction === 0) return
const idx = allValidValues.value.indexOf(model.value)
if (idx !== -1) {
const nextIdx = idx + direction
if (nextIdx >= 0 && nextIdx < allValidValues.value.length)
model.value = allValidValues.value[nextIdx]
} else {
const curVal = model.value
const isNumeric = typeof curVal === 'number' && !Number.isNaN(curVal)
if (!isNumeric)
model.value = props.prefixValues.length ? props.prefixValues[0].value : props.min
else if (direction > 0) model.value = props.min
else
model.value = props.prefixValues.length
? props.prefixValues[props.prefixValues.length - 1].value
: props.min
}
}
</script> </script>
<style scoped> <style scoped>
@ -226,18 +243,14 @@ function onKey(e) {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 0 0.4rem;
gap: 0.25rem; gap: 0.25rem;
border: 1px solid var(--input-border, var(--muted)); background: none;
background: var(--panel-alt);
border-radius: 0.4rem;
min-height: 1.8rem;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
touch-action: none; /* allow custom drag without scrolling */ touch-action: none;
} }
.mini-stepper.drag-mode:focus-visible { .mini-stepper.drag-mode:focus-visible {
outline: 2px solid var(--input-focus, #2563eb); box-shadow: 0 0 0 2px var(--input-focus, #2563eb);
outline-offset: 2px; outline: none;
} }
.mini-stepper.drag-mode .value { .mini-stepper.drag-mode .value {
font-weight: 600; font-weight: 600;

View File

@ -0,0 +1,309 @@
<script setup>
import { ref, computed } from 'vue'
import BaseDialog from './BaseDialog.vue'
import { useCalendarStore } from '@/stores/CalendarStore'
import WeekdaySelector from './WeekdaySelector.vue'
const show = ref(false)
const calendarStore = useCalendarStore()
// Reactive bindings to store
const firstDay = computed({
get: () => calendarStore.config.first_day,
set: (v) => (calendarStore.config.first_day = v),
})
const weekend = computed({
get: () => calendarStore.weekend,
set: (v) => (calendarStore.weekend = [...v]),
})
// Holiday settings - simplified
const holidayMode = computed({
get: () => {
if (!calendarStore.config.holidays.enabled) {
return 'none'
}
return calendarStore.config.holidays.country || 'auto'
},
set: (v) => {
if (v === 'none') {
calendarStore.config.holidays.enabled = false
calendarStore.config.holidays.country = null
calendarStore.config.holidays.state = null
} else if (v === 'auto') {
const detectedCountry = getDetectedCountryCode()
if (detectedCountry) {
calendarStore.config.holidays.enabled = true
calendarStore.config.holidays.country = 'auto'
calendarStore.config.holidays.state = null
calendarStore.initializeHolidays('auto', null, null)
} else {
calendarStore.config.holidays.enabled = false
calendarStore.config.holidays.country = null
calendarStore.config.holidays.state = null
}
} else {
calendarStore.config.holidays.enabled = true
calendarStore.config.holidays.country = v
calendarStore.config.holidays.state = null
calendarStore.initializeHolidays(v, null, null)
}
},
})
const holidayState = computed({
get: () => calendarStore.config.holidays.state,
set: (v) => {
calendarStore.config.holidays.state = v
const country =
calendarStore.config.holidays.country === 'auto'
? 'auto'
: calendarStore.config.holidays.country
calendarStore.initializeHolidays(country, v, calendarStore.config.holidays.region)
},
})
// Get detected country code
function getDetectedCountryCode() {
const locale = navigator.language || navigator.languages?.[0]
if (!locale) return null
const parts = locale.split('-')
if (parts.length < 2) return null
return parts[parts.length - 1].toUpperCase()
} // Get display name for any country code
function getCountryDisplayName(countryCode) {
if (!countryCode || countryCode.length !== 2) {
return countryCode
}
try {
const regionNames = new Intl.DisplayNames([navigator.language || 'en'], { type: 'region' })
return regionNames.of(countryCode) || countryCode
} catch {
return countryCode
}
}
// Get display name for auto option
const autoDisplayName = computed(() => {
const detectedCode = getDetectedCountryCode()
if (!detectedCode) return 'Auto'
return getCountryDisplayName(detectedCode)
})
// Get state/province name from state code
function getStateName(stateCode, countryCode) {
return stateCode
}
// Get available countries and states
const availableCountries = computed(() => {
try {
const countries = calendarStore.getAvailableCountries()
const countryArray = Array.isArray(countries) ? countries : ['US', 'GB', 'DE', 'FR', 'CA', 'AU']
return countryArray.sort((a, b) => {
const nameA = getCountryDisplayName(a)
const nameB = getCountryDisplayName(b)
return nameA.localeCompare(nameB, navigator.language || 'en')
})
} catch (error) {
console.warn('Failed to get available countries:', error)
return ['US', 'GB', 'DE', 'FR', 'CA', 'AU']
}
})
const availableStates = computed(() => {
try {
if (holidayMode.value === 'none') return []
let country = holidayMode.value
if (holidayMode.value === 'auto') {
country = getDetectedCountryCode()
if (!country) return []
}
const states = calendarStore.getAvailableStates(country)
return Array.isArray(states) ? states : []
} catch (error) {
console.warn('Failed to get available states:', error)
return []
}
})
function open() {
// Toggle behavior: if already open, close instead
show.value = !show.value
}
function close() {
show.value = false
}
function resetAll() {
if (confirm('Delete ALL events and reset settings? This cannot be undone.')) {
if (typeof calendarStore.$reset === 'function') {
calendarStore.$reset()
} else {
const now = new Date()
calendarStore.today = now.toISOString().slice(0, 10)
calendarStore.now = now.toISOString()
calendarStore.events = new Map()
calendarStore.weekend = [6, 0]
calendarStore.config.first_day = 1
}
close()
}
}
defineExpose({ open })
</script>
<template>
<BaseDialog
v-model="show"
title="Settings"
class="settings-modal"
:style="{ top: '4.5rem', right: '2rem', bottom: 'auto', left: 'auto', transform: 'none' }"
>
<div class="setting-group">
<label class="ec-field">
<span>First day of week</span>
<select v-model.number="firstDay">
<option :value="0">Sunday</option>
<option :value="1">Monday</option>
<option :value="2">Tuesday</option>
<option :value="3">Wednesday</option>
<option :value="4">Thursday</option>
<option :value="5">Friday</option>
<option :value="6">Saturday</option>
</select>
</label>
<div class="weekend-select ec-field">
<span>Weekend days</span>
<WeekdaySelector v-model="weekend" :first-day="firstDay" />
</div>
</div>
<div class="setting-group">
<label class="ec-field">
<span>Holiday Region</span>
<div class="holiday-row">
<select v-model="holidayMode" class="country-select">
<option value="none">Do not show holidays</option>
<option v-if="getDetectedCountryCode()" value="auto">
{{ autoDisplayName }} (Auto)
</option>
<option v-for="country in availableCountries" :key="country" :value="country">
{{ getCountryDisplayName(country) }}
</option>
</select>
<select
v-if="holidayMode !== 'none' && availableStates.length > 0"
v-model="holidayState"
class="state-select"
>
<option value="">None</option>
<option v-for="state in availableStates" :key="state" :value="state">
{{ state }}
</option>
</select>
</div>
</label>
</div>
<template #footer>
<div class="footer-row split">
<div class="left">
<button type="button" class="ec-btn delete-btn" @click="resetAll">Clear All Data</button>
</div>
<div class="right">
<button type="button" class="ec-btn close-btn" @click="close">Close</button>
</div>
</div>
</template>
</BaseDialog>
</template>
<style scoped>
.setting-group {
display: grid;
gap: 1rem;
}
.setting-group h3 {
margin: 0;
padding: 0;
font-size: 1rem;
color: var(--strong);
}
.ec-field {
display: grid;
gap: 0.25rem;
}
.ec-field > span {
font-size: 0.75rem;
color: var(--muted);
}
.holiday-settings {
display: grid;
gap: 0.75rem;
margin-left: 1rem;
padding-left: 1rem;
border-left: 2px solid var(--border-color);
}
select {
border: 1px solid var(--muted);
background: var(--panel-alt, transparent);
color: var(--ink);
padding: 0.4rem 0.5rem;
border-radius: 0.4rem;
}
.holiday-row {
display: flex;
gap: 0.5rem;
align-items: center;
}
.country-select {
flex: 1;
min-width: 0;
}
.state-select {
flex: 0 0 auto;
min-width: 120px;
}
/* WeekdaySelector display tweaks */
.footer-row {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
width: 100%;
}
.footer-row.split {
justify-content: space-between;
}
.footer-row.split .left,
.footer-row.split .right {
display: flex;
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;
}
.ec-btn.close-btn {
background: var(--panel-alt);
border-color: var(--muted);
font-weight: 500;
}
.ec-btn.delete-btn {
background: hsl(0, 70%, 50%);
color: #fff;
border-color: transparent;
font-weight: 500;
}
.ec-btn.delete-btn:hover {
background: hsl(0, 70%, 45%);
}
</style>

View File

@ -3,11 +3,13 @@
<div class="week-label">W{{ weekNumber }}</div> <div class="week-label">W{{ weekNumber }}</div>
<div class="days-grid"> <div class="days-grid">
<DayCell v-for="day in days" :key="day.dateStr" :day="day" /> <DayCell v-for="day in days" :key="day.dateStr" :day="day" />
<div class="week-overlay"> <div class="week-overlay"></div>
<!-- Event spans will be rendered here -->
</div> </div>
</div> <div
<div v-if="monthLabel" class="month-name-label" :style="{ height: `${monthLabel.weeksSpan * 64}px` }"> v-if="monthLabel"
class="month-name-label"
:style="{ height: `${monthLabel.weeksSpan * 64}px` }"
>
<span>{{ monthLabel.name }} '{{ monthLabel.year }}</span> <span>{{ monthLabel.name }} '{{ monthLabel.year }}</span>
</div> </div>
</div> </div>
@ -16,39 +18,44 @@
<script setup> <script setup>
import { computed } from 'vue' import { computed } from 'vue'
import DayCell from './DayCell.vue' import DayCell from './DayCell.vue'
import { isoWeekInfo, toLocalString, getLocalizedMonthName, monthAbbr } from '@/utils/date' import {
toLocalString,
getLocalizedMonthName,
monthAbbr,
DEFAULT_TZ,
getISOWeek,
} from '@/utils/date'
import { addDays } from 'date-fns'
const props = defineProps({ const props = defineProps({
week: { week: {
type: Object, type: Object,
required: true required: true,
} },
}) })
const weekNumber = computed(() => { const weekNumber = computed(() => getISOWeek(props.week.monday))
return isoWeekInfo(props.week.monday).week
})
const days = computed(() => { const days = computed(() => {
const d = new Date(props.week.monday) const d = new Date(props.week.monday)
const result = [] const result = []
for (let i = 0; i < 7; i++) { for (let i = 0; i < 7; i++) {
const dateStr = toLocalString(d) const dateStr = toLocalString(d, DEFAULT_TZ)
result.push({ result.push({
date: new Date(d), date: new Date(d),
dateStr, dateStr,
dayOfMonth: d.getDate(), dayOfMonth: d.getDate(),
month: d.getMonth(), month: d.getMonth(),
isFirstDayOfMonth: d.getDate() === 1, isFirstDayOfMonth: d.getDate() === 1,
monthClass: monthAbbr[d.getMonth()] monthClass: monthAbbr[d.getMonth()],
}) })
d.setDate(d.getDate() + 1) d.setTime(addDays(d, 1).getTime())
} }
return result return result
}) })
const monthLabel = computed(() => { const monthLabel = computed(() => {
const firstDayOfMonth = days.value.find(d => d.isFirstDayOfMonth) const firstDayOfMonth = days.value.find((d) => d.isFirstDayOfMonth)
if (!firstDayOfMonth) return null if (!firstDayOfMonth) return null
const month = firstDayOfMonth.month const month = firstDayOfMonth.month
@ -60,7 +67,7 @@ const monthLabel = computed(() => {
return { return {
name: getLocalizedMonthName(month), name: getLocalizedMonthName(month),
year: String(year).slice(-2), year: String(year).slice(-2),
weeksSpan weeksSpan,
} }
}) })
</script> </script>

View File

@ -33,7 +33,7 @@
</template> </template>
<script setup> <script setup>
import { computed, ref } from 'vue' import { computed, ref, watch } from 'vue'
import { import {
getLocalizedWeekdayNames, getLocalizedWeekdayNames,
getLocaleFirstDay, getLocaleFirstDay,
@ -44,7 +44,10 @@ import {
const model = defineModel({ const model = defineModel({
type: Array, type: Array,
default: () => [false, false, false, false, false, false, false], default: () => [false, false, false, false, false, false, false],
}) }) // external value consumers see
// Internal state preserves the user's explicit picks even if all false
const internal = ref([false, false, false, false, false, false, false])
const props = defineProps({ const props = defineProps({
weekend: { type: Array, default: undefined }, weekend: { type: Array, default: undefined },
@ -55,12 +58,11 @@ const props = defineProps({
firstDay: { type: Number, default: null }, firstDay: { type: Number, default: null },
}) })
// If external model provided is entirely false, keep as-is (user will see fallback styling), // Initialize internal from external if it has any true; else keep empty (fallback handled on emit)
// only overwrite if null/undefined. if (model.value?.some?.(Boolean)) internal.value = [...model.value]
if (!model.value) model.value = [...props.fallback]
const labelsMondayFirst = getLocalizedWeekdayNames() const labelsMondayFirst = getLocalizedWeekdayNames()
const labels = [labelsMondayFirst[6], ...labelsMondayFirst.slice(0, 6)] const labels = [labelsMondayFirst[6], ...labelsMondayFirst.slice(0, 6)]
const anySelected = computed(() => model.value.some(Boolean)) const anySelected = computed(() => internal.value.some(Boolean))
const localeFirst = getLocaleFirstDay() const localeFirst = getLocaleFirstDay()
const localeWeekend = getLocaleWeekendDays() const localeWeekend = getLocaleWeekendDays()
const firstDay = computed(() => (props.firstDay ?? localeFirst) % 7) const firstDay = computed(() => (props.firstDay ?? localeFirst) % 7)
@ -71,10 +73,38 @@ const weekendDays = computed(() => {
}) })
const displayLabels = computed(() => reorderByFirstDay(labels, firstDay.value)) const displayLabels = computed(() => reorderByFirstDay(labels, firstDay.value))
const displayValuesCommitted = computed(() => reorderByFirstDay(model.value, firstDay.value)) const displayValuesCommitted = computed(() => reorderByFirstDay(internal.value, firstDay.value))
const displayWorking = computed(() => reorderByFirstDay(weekendDays.value, firstDay.value)) const displayWorking = computed(() => reorderByFirstDay(weekendDays.value, firstDay.value))
const displayDefault = computed(() => reorderByFirstDay(props.fallback, firstDay.value)) const displayDefault = computed(() => reorderByFirstDay(props.fallback, firstDay.value))
// Expose a normalized pattern (Sunday-first) that substitutes the fallback day if none selected.
// This keeps UI visually showing fallback (muted) but downstream logic can opt-in by reading this.
function computeFallbackPattern() {
const fb = props.fallback && props.fallback.length === 7 ? props.fallback : null
if (fb && fb.some(Boolean)) return [...fb]
const arr = [false, false, false, false, false, false, false]
const idx = fb ? fb.findIndex(Boolean) : -1
if (idx >= 0) arr[idx] = true
else arr[0] = true
return arr
}
function emitExternal() {
model.value = internal.value.some(Boolean) ? [...internal.value] : computeFallbackPattern()
}
emitExternal()
watch(
() => model.value,
(nv) => {
if (!nv) return
if (!nv.some(Boolean)) return
const fb = computeFallbackPattern()
const isFallback = fb.every((v, i) => v === nv[i])
// If internal is empty and model only reflects fallback, do not sync into internal
if (isFallback && !internal.value.some(Boolean)) return
internal.value = [...nv]
},
)
// Mapping from display index to original model index // Mapping from display index to original model index
const orderIndices = computed(() => Array.from({ length: 7 }, (_, i) => (i + firstDay.value) % 7)) const orderIndices = computed(() => Array.from({ length: 7 }, (_, i) => (i + firstDay.value) % 7))
@ -135,8 +165,8 @@ function isPressing(di) {
} }
function onPointerDown(di) { function onPointerDown(di) {
originalValues = [...model.value] originalValues = [...internal.value]
dragVal.value = !model.value[(di + firstDay.value) % 7] dragVal.value = !internal.value[(di + firstDay.value) % 7]
dragStart.value = di dragStart.value = di
previewEnd.value = di previewEnd.value = di
dragging.value = true dragging.value = true
@ -155,7 +185,8 @@ function onPointerUp() {
// simple click: toggle single // simple click: toggle single
const next = [...originalValues] const next = [...originalValues]
next[(dragStart.value + firstDay.value) % 7] = dragVal.value next[(dragStart.value + firstDay.value) % 7] = dragVal.value
model.value = next internal.value = next
emitExternal()
cleanupDrag() cleanupDrag()
} else { } else {
commitDrag() commitDrag()
@ -169,7 +200,8 @@ function commitDrag() {
: [previewEnd.value, dragStart.value] : [previewEnd.value, dragStart.value]
const next = [...originalValues] const next = [...originalValues]
for (let di = s; di <= e; di++) next[orderIndices.value[di]] = dragVal.value for (let di = s; di <= e; di++) next[orderIndices.value[di]] = dragVal.value
model.value = next internal.value = next
emitExternal()
cleanupDrag() cleanupDrag()
} }
function cancelDrag() { function cancelDrag() {
@ -185,14 +217,15 @@ function cleanupDrag() {
function toggleWeekend(work) { function toggleWeekend(work) {
const base = weekendDays.value const base = weekendDays.value
const target = work ? base : base.map((v) => !v) const target = work ? base : base.map((v) => !v)
const current = model.value const current = internal.value
const allOn = current.every(Boolean) const allOn = current.every(Boolean)
const isTargetActive = current.every((v, i) => v === target[i]) const isTargetActive = current.every((v, i) => v === target[i])
if (allOn || isTargetActive) { if (allOn || isTargetActive) {
model.value = [false, false, false, false, false, false, false] internal.value = [false, false, false, false, false, false, false]
} else { } else {
model.value = [...target] internal.value = [...target]
} }
emitExternal()
} }
</script> </script>

View File

@ -2,11 +2,17 @@ import './assets/calendar.css'
import { createApp } from 'vue' import { createApp } from 'vue'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
import { calendarHistory } from '@/plugins/calendarHistory'
import App from './App.vue' import App from './App.vue'
const app = createApp(App) const app = createApp(App)
app.use(createPinia()) const pinia = createPinia()
// Order: persistence first so snapshots recorded by undo reflect already-hydrated state
pinia.use(piniaPluginPersistedstate)
pinia.use(calendarHistory)
app.use(pinia)
app.mount('#app') app.mount('#app')

View File

@ -0,0 +1,200 @@
// Custom lightweight undo/redo specifically for calendar store with Map support
// Adds store.$history = { undo(), redo(), canUndo, canRedo, clear(), pushManual() }
// Wraps action calls to create history entries only for meaningful mutations.
function deepCloneCalendarState(raw) {
// We only need to snapshot keys we care about; omit volatile fields
const { today, events, config, weekend } = raw
return {
today,
weekend: Array.isArray(weekend) ? [...weekend] : weekend,
config: JSON.parse(JSON.stringify(config)),
events: new Map([...events].map(([k, v]) => [k, { ...v }])),
}
}
function restoreCalendarState(store, snap) {
store.today = snap.today
store.weekend = Array.isArray(snap.weekend) ? [...snap.weekend] : snap.weekend
store.config = JSON.parse(JSON.stringify(snap.config))
store.events = new Map([...snap.events].map(([k, v]) => [k, { ...v }]))
store.eventsMutation = (store.eventsMutation + 1) % 1_000_000_000
}
export function calendarHistory({ store }) {
if (store.$id !== 'calendar') return
const max = 100 // history depth limit
const history = [] // past states
let pointer = -1 // index of current state in history
let isRestoring = false
let lastSerialized = null
// Compound editing session (e.g. event dialog) flags
let compoundActive = false
let compoundBaseSig = null
let compoundChanged = false
function serializeForComparison() {
const evCount = store.events instanceof Map ? store.events.size : 0
const em = store.eventsMutation || 0
return `${em}|${evCount}|${store.today}|${JSON.stringify(store.config)}`
}
function pushSnapshot() {
if (isRestoring) return
const sig = serializeForComparison()
if (sig === lastSerialized) return
// Drop any redo branch
if (pointer < history.length - 1) history.splice(pointer + 1)
history.push(deepCloneCalendarState(store))
if (history.length > max) history.shift()
pointer = history.length - 1
lastSerialized = sig
bumpIndicators()
// console.log('[history] pushed', pointer, sig)
}
function bumpIndicators() {
if (typeof store.historyTick === 'number') {
store.historyTick = (store.historyTick + 1) % 1_000_000_000
}
if (typeof store.historyCanUndo === 'boolean') {
store.historyCanUndo = pointer > 0
}
if (typeof store.historyCanRedo === 'boolean') {
store.historyCanRedo = pointer >= 0 && pointer < history.length - 1
}
}
function markPotentialChange() {
if (isRestoring) return
if (compoundActive) {
const sig = serializeForComparison()
if (sig !== compoundBaseSig) compoundChanged = true
return
}
pushSnapshot()
}
function beginCompound() {
if (compoundActive) return
compoundActive = true
compoundBaseSig = serializeForComparison()
compoundChanged = false
}
function endCompound() {
if (!compoundActive) return
const finalSig = serializeForComparison()
const changed = compoundChanged || finalSig !== compoundBaseSig
compoundActive = false
compoundBaseSig = null
if (changed) pushSnapshot()
else bumpIndicators() // session ended without change still refresh flags
}
function undo() {
// Ensure any active compound changes are finalized before moving back
if (compoundActive) endCompound()
else {
// If current state differs from last snapshot, push it so redo can restore it
const curSig = serializeForComparison()
if (curSig !== lastSerialized) pushSnapshot()
}
if (pointer <= 0) return
pointer--
isRestoring = true
try {
restoreCalendarState(store, history[pointer])
lastSerialized = serializeForComparison()
} finally {
isRestoring = false
}
bumpIndicators()
}
function redo() {
if (compoundActive) endCompound()
else {
const curSig = serializeForComparison()
if (curSig !== lastSerialized) pushSnapshot()
}
if (pointer >= history.length - 1) return
pointer++
isRestoring = true
try {
restoreCalendarState(store, history[pointer])
lastSerialized = serializeForComparison()
} finally {
isRestoring = false
}
bumpIndicators()
}
function clear() {
history.length = 0
pointer = -1
lastSerialized = null
bumpIndicators()
}
// Wrap selected mutating actions to push snapshot AFTER they run if state changed.
const actionNames = [
'createEvent',
'deleteEvent',
'deleteFirstOccurrence',
'deleteSingleOccurrence',
'deleteFromOccurrence',
'setEventRange',
'splitMoveVirtualOccurrence',
'splitRepeatSeries',
'_terminateRepeatSeriesAtIndex',
'toggleHolidays',
'initializeHolidays',
]
for (const name of actionNames) {
if (typeof store[name] === 'function') {
const original = store[name].bind(store)
store[name] = (...args) => {
const beforeSig = serializeForComparison()
const result = original(...args)
const afterSig = serializeForComparison()
if (afterSig !== beforeSig) markPotentialChange()
return result
}
}
}
// Capture direct property edits (e.g., deep field edits signaled via touchEvents())
store.$subscribe((mutation, _state) => {
if (mutation.storeId !== 'calendar') return
markPotentialChange()
})
// Initial snapshot after hydration (next microtask to let persistence load)
Promise.resolve().then(() => pushSnapshot())
store.$history = {
undo,
redo,
clear,
pushManual: pushSnapshot,
beginCompound,
endCompound,
flush() {
pushSnapshot()
},
get canUndo() {
return pointer > 0
},
get canRedo() {
return pointer >= 0 && pointer < history.length - 1
},
get compoundActive() {
return compoundActive
},
_debug() {
return { pointer, length: history.length }
},
}
}

View File

@ -0,0 +1,57 @@
// Pinia plugin to ensure calendar store keeps Map for events after undo/redo snapshots
export function calendarUndoNormalize({ store }) {
if (store.$id !== 'calendar') return
function fixEvents() {
const evs = store.events
if (evs instanceof Map) return
// If serialized form { __map: true, data: [...] }
if (evs && evs.__map && Array.isArray(evs.data)) {
store.events = new Map(evs.data)
return
}
// If an array of [k,v]
if (Array.isArray(evs) && evs.every((x) => Array.isArray(x) && x.length === 2)) {
store.events = new Map(evs)
return
}
// If plain object, convert own enumerable props
if (evs && typeof evs === 'object') {
store.events = new Map(Object.entries(evs))
}
}
// Patch undo/redo if present (after pinia-undo is installed)
const patchFns = ['undo', 'redo']
for (const fn of patchFns) {
if (typeof store[fn] === 'function') {
const original = store[fn].bind(store)
store[fn] = (...args) => {
console.log(`[calendar history] ${fn} invoked`)
const beforeType = store.events && store.events.constructor && store.events.constructor.name
const out = original(...args)
const afterRawType =
store.events && store.events.constructor && store.events.constructor.name
fixEvents()
const finalType = store.events && store.events.constructor && store.events.constructor.name
let size = null
try {
if (store.events instanceof Map) size = store.events.size
else if (Array.isArray(store.events)) size = store.events.length
} catch {}
console.log(
`[calendar history] ${fn} types: before=${beforeType} afterRaw=${afterRawType} final=${finalType} size=${size}`,
)
return out
}
}
}
// Also watch all mutations (includes direct assigns and action commits)
store.$subscribe(() => {
fixEvents()
})
// Initial sanity
fixEvents()
}

24
src/plugins/persist.js Normal file
View File

@ -0,0 +1,24 @@
// Simple Pinia persistence plugin supporting `persist: true` and Map serialization.
export function persistPlugin({ store }) {
if (!store.$options || !store.$options.persist) return
const key = `pinia-${store.$id}`
try {
const raw = localStorage.getItem(key)
if (raw) {
const state = JSON.parse(raw, (k, v) => {
if (v && v.__map === true && Array.isArray(v.data)) return new Map(v.data)
return v
})
store.$patch(state)
}
} catch {}
store.$subscribe((_mutation, state) => {
try {
const json = JSON.stringify(state, (_k, v) => {
if (v instanceof Map) return { __map: true, data: Array.from(v.entries()) }
return v
})
localStorage.setItem(key, json)
} catch {}
})
}

View File

@ -0,0 +1,331 @@
import { ref } from 'vue'
function createMomentumDrag({
viewport,
viewportHeight,
contentHeight,
setScrollTop,
speed,
reasonDragPointer,
reasonDragTouch,
reasonMomentum,
allowTouch,
hitTest,
}) {
let dragging = false
let startY = 0
let startScroll = 0
let velocity = 0
let samples = [] // { timestamp, position }
let momentumActive = false
let momentumFrame = null
let dragAccumY = 0 // used when pointer lock active
let usingPointerLock = false
const frictionPerMs = 0.0018
const MIN_V = 0.03
const VELOCITY_MS = 50
function cancelMomentum() {
if (!momentumActive) return
momentumActive = false
if (momentumFrame) cancelAnimationFrame(momentumFrame)
momentumFrame = null
}
function startMomentum() {
if (Math.abs(velocity) < MIN_V) return
cancelMomentum()
momentumActive = true
let lastTs = performance.now()
const step = () => {
if (!momentumActive) return
const now = performance.now()
const dt = now - lastTs
lastTs = now
if (dt <= 0) {
momentumFrame = requestAnimationFrame(step)
return
}
const decay = Math.exp(-frictionPerMs * dt)
velocity *= decay
const delta = velocity * dt
if (viewport.value) {
let cur = viewport.value.scrollTop
let target = cur + delta
const maxScroll = Math.max(0, contentHeight.value - viewportHeight.value)
if (target < 0) {
target = 0
velocity = 0
} else if (target > maxScroll) {
target = maxScroll
velocity = 0
}
setScrollTop(target, reasonMomentum)
}
if (Math.abs(velocity) < MIN_V * 0.6) {
momentumActive = false
return
}
momentumFrame = requestAnimationFrame(step)
}
momentumFrame = requestAnimationFrame(step)
}
function applyDragByDelta(deltaY, reason) {
const now = performance.now()
while (samples[0]?.timestamp < now - VELOCITY_MS) samples.shift()
samples.push({ timestamp: now, position: deltaY })
const newScrollTop = startScroll - deltaY * speed
const maxScroll = Math.max(0, contentHeight.value - viewportHeight.value)
const clamped = Math.max(0, Math.min(newScrollTop, maxScroll))
setScrollTop(clamped, reason)
}
function applyDragPosition(clientY, reason) {
const deltaY = clientY - startY
applyDragByDelta(deltaY, reason)
}
function endDrag() {
if (!dragging) return
dragging = false
window.removeEventListener('pointermove', onPointerMove, true)
window.removeEventListener('pointerup', endDrag, true)
window.removeEventListener('pointercancel', endDrag, true)
if (allowTouch) {
window.removeEventListener('touchmove', onTouchMove)
window.removeEventListener('touchend', endDrag)
window.removeEventListener('touchcancel', endDrag)
}
document.removeEventListener('pointerlockchange', onPointerLockChange, true)
if (usingPointerLock && document.pointerLockElement === viewport.value) {
try {
document.exitPointerLock()
} catch {}
}
usingPointerLock = false
velocity = 0
if (samples.length) {
const first = samples[0]
const now = performance.now()
const last = samples[samples.length - 1]
const dy = last.position - first.position
if (Math.abs(dy) > 5) velocity = (-dy * speed) / (now - first.timestamp)
}
samples = []
startMomentum()
}
function onPointerMove(e) {
if (!dragging || document.pointerLockElement !== viewport.value) return
dragAccumY += e.movementY
applyDragByDelta(dragAccumY, reasonDragPointer)
e.preventDefault()
}
function onTouchMove(e) {
if (!dragging) return
if (e.touches.length !== 1) {
endDrag()
return
}
applyDragPosition(e.touches[0].clientY, reasonDragTouch)
e.preventDefault()
}
function handlePointerDown(e) {
if (e.button !== undefined && e.button !== 0) return
if (hitTest && !hitTest(e)) return
e.preventDefault()
cancelMomentum()
dragging = true
startY = e.clientY
startScroll = viewport.value?.scrollTop || 0
velocity = 0
dragAccumY = 0
samples = [{ timestamp: performance.now(), position: e.clientY }]
window.addEventListener('pointermove', onPointerMove, true)
window.addEventListener('pointerup', endDrag, true)
window.addEventListener('pointercancel', endDrag, true)
document.addEventListener('pointerlockchange', onPointerLockChange, true)
viewport.value.requestPointerLock({ unadjustedMovement: true })
}
function handleTouchStart(e) {
if (!allowTouch) return
if (e.touches.length !== 1) return
if (hitTest && !hitTest(e.touches[0])) return
cancelMomentum()
dragging = true
const t = e.touches[0]
startY = t.clientY
startScroll = viewport.value?.scrollTop || 0
velocity = 0
dragAccumY = 0
samples = [{ timestamp: performance.now(), position: t.clientY }]
window.addEventListener('touchmove', onTouchMove, { passive: false })
window.addEventListener('touchend', endDrag, { passive: false })
window.addEventListener('touchcancel', endDrag, { passive: false })
e.preventDefault()
}
function onPointerLockChange() {
const lockedEl = document.pointerLockElement
if (dragging && lockedEl === viewport.value) {
usingPointerLock = true
return
}
if (dragging && usingPointerLock && lockedEl !== viewport.value) endDrag()
if (!dragging) usingPointerLock = false
}
return { handlePointerDown, handleTouchStart, cancelMomentum }
}
export function createScrollManager({ viewport, scheduleRebuild }) {
const scrollTop = ref(0)
let lastProgrammatic = null
let pendingTarget = null
let pendingAttempts = 0
let pendingLoopActive = false
function setScrollTop(val, reason = 'programmatic') {
let applied = val
if (viewport.value) {
const maxScroll = viewport.value.scrollHeight - viewport.value.clientHeight
if (applied > maxScroll) {
applied = maxScroll < 0 ? 0 : maxScroll
pendingTarget = val
pendingAttempts = 0
startPendingLoop()
}
if (applied < 0) applied = 0
viewport.value.scrollTop = applied
}
scrollTop.value = applied
lastProgrammatic = applied
scheduleRebuild(reason)
}
function onScroll() {
if (!viewport.value) return
const cur = viewport.value.scrollTop
const maxScroll = viewport.value.scrollHeight - viewport.value.clientHeight
let effective = cur
if (cur < 0) effective = 0
else if (cur > maxScroll) effective = maxScroll
scrollTop.value = effective
if (lastProgrammatic !== null && effective === lastProgrammatic) {
lastProgrammatic = null
return
}
if (pendingTarget !== null && Math.abs(effective - pendingTarget) > 4) {
pendingTarget = null
}
scheduleRebuild('scroll')
}
function startPendingLoop() {
if (pendingLoopActive || !viewport.value) return
pendingLoopActive = true
const loop = () => {
if (pendingTarget == null || !viewport.value) {
pendingLoopActive = false
return
}
const maxScroll = viewport.value.scrollHeight - viewport.value.clientHeight
if (pendingTarget <= maxScroll) {
setScrollTop(pendingTarget, 'pending-fulfill')
pendingTarget = null
pendingLoopActive = false
return
}
pendingAttempts++
if (pendingAttempts > 120) {
pendingTarget = null
pendingLoopActive = false
return
}
requestAnimationFrame(loop)
}
requestAnimationFrame(loop)
}
return { scrollTop, setScrollTop, onScroll }
}
export function createWeekColumnScrollManager({
viewport,
viewportHeight,
contentHeight,
setScrollTop,
}) {
const isWeekColDragging = ref(false)
function getWeekLabelRect() {
const headerYear = document.querySelector('.calendar-header .year-label')
if (headerYear) return headerYear.getBoundingClientRect()
const weekLabel = viewport.value?.querySelector('.week-row .week-label')
return weekLabel ? weekLabel.getBoundingClientRect() : null
}
const drag = createMomentumDrag({
viewport,
viewportHeight,
contentHeight,
setScrollTop,
speed: 1,
reasonDragPointer: 'week-col-drag',
reasonDragTouch: 'week-col-drag',
reasonMomentum: 'week-col-momentum',
allowTouch: false,
hitTest: (e) => {
const rect = getWeekLabelRect()
if (!rect) return false
const x = e.clientX ?? e.pageX
return x >= rect.left && x <= rect.right
},
})
function handleWeekColMouseDown(e) {
if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) return
isWeekColDragging.value = true
drag.handlePointerDown(e)
const end = () => {
isWeekColDragging.value = false
window.removeEventListener('pointerup', end, true)
window.removeEventListener('pointercancel', end, true)
}
window.addEventListener('pointerup', end, true)
window.addEventListener('pointercancel', end, true)
}
function handlePointerLockChange() {
if (document.pointerLockElement !== viewport.value) {
isWeekColDragging.value = false
}
}
return { isWeekColDragging, handleWeekColMouseDown, handlePointerLockChange }
}
export function createMonthScrollManager({
viewport,
viewportHeight,
contentHeight,
setScrollTop,
}) {
const drag = createMomentumDrag({
viewport,
viewportHeight,
contentHeight,
setScrollTop,
speed: 10,
reasonDragPointer: 'month-scroll-drag',
reasonDragTouch: 'month-scroll-touch',
reasonMomentum: 'month-scroll-momentum',
allowTouch: true,
hitTest: null,
})
function handleMonthScrollPointerDown(e) {
drag.handlePointerDown(e)
}
function handleMonthScrollTouchStart(e) {
drag.handleTouchStart(e)
}
function handleMonthScrollWheel(e) {
drag.cancelMomentum()
const currentScroll = viewport.value?.scrollTop || 0
const newScrollTop = currentScroll + e.deltaY * 10
const maxScroll = Math.max(0, contentHeight.value - viewportHeight.value)
const clamped = Math.max(0, Math.min(newScrollTop, maxScroll))
setScrollTop(clamped, 'month-scroll-wheel')
e.preventDefault()
}
return { handleMonthScrollPointerDown, handleMonthScrollTouchStart, handleMonthScrollWheel }
}

400
src/plugins/virtualWeeks.js Normal file
View File

@ -0,0 +1,400 @@
import { ref } from 'vue'
import { addDays, differenceInWeeks } from 'date-fns'
import {
toLocalString,
fromLocalString,
DEFAULT_TZ,
getISOWeek,
addDaysStr,
pad,
getLocalizedMonthName,
monthAbbr,
lunarPhaseSymbol,
MAX_YEAR,
getOccurrenceIndex,
getVirtualOccurrenceEndDate,
} from '@/utils/date'
import { getHolidayForDate } from '@/utils/holidays'
/**
* Factory handling virtual week window & incremental building.
* Exposes reactive visibleWeeks plus scheduling functions.
*/
export function createVirtualWeekManager({
calendarStore,
viewport,
viewportHeight,
rowHeight,
selection,
baseDate,
minVirtualWeek,
maxVirtualWeek,
contentHeight, // not currently used inside manager but kept for future
}) {
const visibleWeeks = ref([])
let lastScrollRange = { startVW: null, endVW: null }
let updating = false
// Scroll refs injected later to break cyclic dependency with scroll manager
let scrollTopRef = null
let setScrollTopFn = null
function attachScroll(scrollTop, setScrollTop) {
scrollTopRef = scrollTop
setScrollTopFn = setScrollTop
}
function getWeekIndex(date) {
const dayOffset = (date.getDay() - calendarStore.config.first_day + 7) % 7
const firstDayOfWeek = addDays(date, -dayOffset)
return differenceInWeeks(firstDayOfWeek, baseDate.value)
}
function getFirstDayForVirtualWeek(virtualWeek) {
return addDays(baseDate.value, virtualWeek * 7)
}
function createWeek(virtualWeek) {
const firstDay = getFirstDayForVirtualWeek(virtualWeek)
const isoAnchor = addDays(firstDay, (4 - firstDay.getDay() + 7) % 7)
const weekNumber = getISOWeek(isoAnchor)
const days = []
let cur = new Date(firstDay)
let hasFirst = false
let monthToLabel = null
let labelYear = null
const repeatingBases = []
if (calendarStore.events) {
for (const ev of calendarStore.events.values()) {
if (ev.recur) repeatingBases.push(ev)
}
}
const collectEventsForDate = (dateStr, curDateObj) => {
const storedEvents = []
for (const ev of calendarStore.events.values()) {
if (!ev.recur) {
const evEnd = toLocalString(
addDays(fromLocalString(ev.startDate, DEFAULT_TZ), (ev.days || 1) - 1),
DEFAULT_TZ,
)
if (dateStr >= ev.startDate && dateStr <= evEnd) {
storedEvents.push({ ...ev, endDate: evEnd })
}
}
}
const dayEvents = [...storedEvents]
for (const base of repeatingBases) {
const baseEnd = toLocalString(
addDays(fromLocalString(base.startDate, DEFAULT_TZ), (base.days || 1) - 1),
DEFAULT_TZ,
)
if (dateStr >= base.startDate && dateStr <= baseEnd) {
dayEvents.push({ ...base, endDate: baseEnd, _recurrenceIndex: 0, _baseId: base.id })
continue
}
const spanDays = (base.days || 1) - 1
const currentDate = curDateObj
let occurrenceFound = false
for (let offset = 0; offset <= spanDays && !occurrenceFound; offset++) {
const candidateStart = addDays(currentDate, -offset)
const candidateStartStr = toLocalString(candidateStart, DEFAULT_TZ)
const occurrenceIndex = getOccurrenceIndex(base, candidateStartStr, DEFAULT_TZ)
if (occurrenceIndex !== null) {
const virtualEndDate = getVirtualOccurrenceEndDate(base, candidateStartStr, DEFAULT_TZ)
if (dateStr >= candidateStartStr && dateStr <= virtualEndDate) {
const virtualId = base.id + '_v_' + candidateStartStr
const alreadyExists = dayEvents.some((ev) => ev.id === virtualId)
if (!alreadyExists) {
dayEvents.push({
...base,
id: virtualId,
startDate: candidateStartStr,
endDate: virtualEndDate,
_recurrenceIndex: occurrenceIndex,
_baseId: base.id,
})
}
occurrenceFound = true
}
}
}
}
return dayEvents
}
for (let i = 0; i < 7; i++) {
const dateStr = toLocalString(cur, DEFAULT_TZ)
const dayEvents = collectEventsForDate(dateStr, fromLocalString(dateStr, DEFAULT_TZ))
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()
}
let holiday = null
if (calendarStore.config.holidays.enabled) {
calendarStore._ensureHolidaysInitialized?.()
holiday = getHolidayForDate(dateStr)
}
days.push({
date: dateStr,
dayOfMonth: cur.getDate(),
displayText,
monthClass: monthAbbr[cur.getMonth()],
isToday: dateStr === calendarStore.today,
isWeekend: calendarStore.weekend[dow],
isFirstDay: isFirst,
lunarPhase: lunarPhaseSymbol(cur),
holiday,
isHoliday: holiday !== null,
isSelected:
selection.value.startDate &&
selection.value.dayCount > 0 &&
dateStr >= selection.value.startDate &&
dateStr <= addDaysStr(selection.value.startDate, selection.value.dayCount - 1),
events: dayEvents,
})
cur = addDays(cur, 1)
}
let monthLabel = null
if (hasFirst && monthToLabel !== null) {
if (labelYear && labelYear <= MAX_YEAR) {
let weeksSpan = 0
const d = addDays(cur, -1)
for (let i = 0; i < 6; i++) {
const probe = addDays(cur, -1 + i * 7)
d.setTime(probe.getTime())
if (d.getMonth() === monthToLabel) weeksSpan++
}
const remainingWeeks = Math.max(1, maxVirtualWeek.value - virtualWeek + 1)
weeksSpan = Math.max(1, Math.min(weeksSpan, remainingWeeks))
const year = String(labelYear).slice(-2)
monthLabel = {
text: `${getLocalizedMonthName(monthToLabel)} '${year}`,
month: monthToLabel,
weeksSpan,
monthClass: monthAbbr[monthToLabel],
}
}
}
return {
virtualWeek,
weekNumber: pad(weekNumber),
days,
monthLabel,
top: (virtualWeek - minVirtualWeek.value) * rowHeight.value,
}
}
function internalWindowCalc() {
const buffer = 6
const currentScrollTop = viewport.value?.scrollTop ?? scrollTopRef?.value ?? 0
const startIdx = Math.floor((currentScrollTop - buffer * rowHeight.value) / rowHeight.value)
const endIdx = Math.ceil(
(currentScrollTop + 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)
return { startVW, endVW }
}
function updateVisibleWeeks(_reason) {
const { startVW, endVW } = internalWindowCalc()
// Prune outside
if (visibleWeeks.value.length) {
while (visibleWeeks.value.length && visibleWeeks.value[0].virtualWeek < startVW) {
visibleWeeks.value.shift()
}
while (
visibleWeeks.value.length &&
visibleWeeks.value[visibleWeeks.value.length - 1].virtualWeek > endVW
) {
visibleWeeks.value.pop()
}
}
// Add at most one week (ensuring contiguity)
let added = false
if (!visibleWeeks.value.length) {
visibleWeeks.value.push(createWeek(startVW))
added = true
} else {
visibleWeeks.value.sort((a, b) => a.virtualWeek - b.virtualWeek)
const firstVW = visibleWeeks.value[0].virtualWeek
const lastVW = visibleWeeks.value[visibleWeeks.value.length - 1].virtualWeek
if (firstVW > startVW) {
visibleWeeks.value.unshift(createWeek(firstVW - 1))
added = true
} else {
let gapInserted = false
for (let i = 0; i < visibleWeeks.value.length - 1; i++) {
const curVW = visibleWeeks.value[i].virtualWeek
const nextVW = visibleWeeks.value[i + 1].virtualWeek
if (nextVW - curVW > 1 && curVW < endVW) {
visibleWeeks.value.splice(i + 1, 0, createWeek(curVW + 1))
added = true
gapInserted = true
break
}
}
if (!gapInserted && lastVW < endVW) {
visibleWeeks.value.push(createWeek(lastVW + 1))
added = true
}
}
}
// Coverage check
const firstAfter = visibleWeeks.value[0].virtualWeek
const lastAfter = visibleWeeks.value[visibleWeeks.value.length - 1].virtualWeek
let contiguous = true
for (let i = 0; i < visibleWeeks.value.length - 1; i++) {
if (visibleWeeks.value[i + 1].virtualWeek !== visibleWeeks.value[i].virtualWeek + 1) {
contiguous = false
break
}
}
const coverageComplete =
firstAfter <= startVW &&
lastAfter >= endVW &&
contiguous &&
visibleWeeks.value.length === endVW - startVW + 1
if (!coverageComplete) return false
if (
lastScrollRange.startVW === startVW &&
lastScrollRange.endVW === endVW &&
!added &&
visibleWeeks.value.length
) {
return true
}
lastScrollRange = { startVW, endVW }
return true
}
function scheduleWindowUpdate(reason) {
if (updating) return
updating = true
const run = () => {
updating = false
updateVisibleWeeks(reason) || scheduleWindowUpdate('incremental-build')
}
if ('requestIdleCallback' in window) requestIdleCallback(run, { timeout: 16 })
else requestAnimationFrame(run)
}
function resetWeeks(reason = 'reset') {
visibleWeeks.value = []
lastScrollRange = { startVW: null, endVW: null }
scheduleWindowUpdate(reason)
}
// Reflective update of only events inside currently visible weeks (keeps week objects stable)
function refreshEvents(reason = 'events-refresh') {
if (!visibleWeeks.value.length) return
const repeatingBases = []
if (calendarStore.events) {
for (const ev of calendarStore.events.values()) if (ev.recur) repeatingBases.push(ev)
}
const selStart = selection.value.startDate
const selCount = selection.value.dayCount
const selEnd = selStart && selCount > 0 ? addDaysStr(selStart, selCount - 1) : null
for (const week of visibleWeeks.value) {
for (const day of week.days) {
const dateStr = day.date
// Update selection flag
if (selStart && selEnd) day.isSelected = dateStr >= selStart && dateStr <= selEnd
else day.isSelected = false
// Rebuild events list for this day
const storedEvents = []
for (const ev of calendarStore.events.values()) {
if (!ev.recur) {
const evEnd = toLocalString(
addDays(fromLocalString(ev.startDate, DEFAULT_TZ), (ev.days || 1) - 1),
DEFAULT_TZ,
)
if (dateStr >= ev.startDate && dateStr <= evEnd) {
storedEvents.push({ ...ev, endDate: evEnd })
}
}
}
const dayEvents = [...storedEvents]
for (const base of repeatingBases) {
const baseEndStr = toLocalString(
addDays(fromLocalString(base.startDate, DEFAULT_TZ), (base.days || 1) - 1),
DEFAULT_TZ,
)
if (dateStr >= base.startDate && dateStr <= baseEndStr) {
dayEvents.push({ ...base, endDate: baseEndStr, _recurrenceIndex: 0, _baseId: base.id })
continue
}
const spanDays = (base.days || 1) - 1
const currentDate = fromLocalString(dateStr, DEFAULT_TZ)
let occurrenceFound = false
for (let offset = 0; offset <= spanDays && !occurrenceFound; offset++) {
const candidateStart = addDays(currentDate, -offset)
const candidateStartStr = toLocalString(candidateStart, DEFAULT_TZ)
const occurrenceIndex = getOccurrenceIndex(base, candidateStartStr, DEFAULT_TZ)
if (occurrenceIndex !== null) {
const virtualEndDate = getVirtualOccurrenceEndDate(
base,
candidateStartStr,
DEFAULT_TZ,
)
if (dateStr >= candidateStartStr && dateStr <= virtualEndDate) {
const virtualId = base.id + '_v_' + candidateStartStr
const alreadyExists = dayEvents.some((ev) => ev.id === virtualId)
if (!alreadyExists) {
dayEvents.push({
...base,
id: virtualId,
startDate: candidateStartStr,
endDate: virtualEndDate,
_recurrenceIndex: occurrenceIndex,
_baseId: base.id,
})
}
occurrenceFound = true
}
}
}
}
day.events = dayEvents
}
}
if (process.env.NODE_ENV !== 'production') {
// eslint-disable-next-line no-console
console.debug('[VirtualWeeks] refreshEvents', reason, { weeks: visibleWeeks.value.length })
}
}
function goToToday() {
const top = addDays(new Date(calendarStore.now), -21)
const targetWeekIndex = getWeekIndex(top)
const newScrollTop = (targetWeekIndex - minVirtualWeek.value) * rowHeight.value
if (setScrollTopFn) setScrollTopFn(newScrollTop, 'go-to-today')
}
function handleHeaderYearChange({ scrollTop }) {
const maxScroll = contentHeight.value - viewportHeight.value
const clamped = Math.max(0, Math.min(scrollTop, isFinite(maxScroll) ? maxScroll : scrollTop))
if (setScrollTopFn) setScrollTopFn(clamped, 'header-year-change')
resetWeeks('header-year-change')
}
return {
visibleWeeks,
scheduleWindowUpdate,
resetWeeks,
updateVisibleWeeks,
refreshEvents,
getWeekIndex,
getFirstDayForVirtualWeek,
goToToday,
handleHeaderYearChange,
attachScroll,
}
}

View File

@ -2,76 +2,113 @@ import { defineStore } from 'pinia'
import { import {
toLocalString, toLocalString,
fromLocalString, fromLocalString,
getLocaleFirstDay,
getLocaleWeekendDays, getLocaleWeekendDays,
getMondayOfISOWeek,
getOccurrenceDate,
DEFAULT_TZ,
} from '@/utils/date' } from '@/utils/date'
import { differenceInCalendarDays, addDays } from 'date-fns'
/** import { initializeHolidays, getAvailableCountries, getAvailableStates } from '@/utils/holidays'
* Calendar configuration can be overridden via window.calendarConfig:
*
* window.calendarConfig = {
* firstDay: 0, // 0=Sunday, 1=Monday, etc. (default: 1)
* firstDay: 'auto', // Use locale detection
* weekendDays: [true, false, false, false, false, false, true], // Custom weekend
* weekendDays: 'auto' // Use locale detection (default)
* }
*/
const MIN_YEAR = 1900
const MAX_YEAR = 2100
// Helper function to determine first day with config override support
function getConfiguredFirstDay() {
// Check for environment variable or global config
const configOverride = window?.calendarConfig?.firstDay
if (configOverride !== undefined) {
return configOverride === 'auto' ? getLocaleFirstDay() : Number(configOverride)
}
// Default to Monday (1) instead of locale
return 1
}
// Helper function to determine weekend days with config override support
function getConfiguredWeekendDays() {
// Check for environment variable or global config
const configOverride = window?.calendarConfig?.weekendDays
if (configOverride !== undefined) {
return configOverride === 'auto' ? getLocaleWeekendDays() : configOverride
}
// Default to locale-based weekend days
return getLocaleWeekendDays()
}
export const useCalendarStore = defineStore('calendar', { export const useCalendarStore = defineStore('calendar', {
state: () => ({ state: () => ({
today: toLocalString(new Date()), today: toLocalString(new Date(), DEFAULT_TZ),
now: new Date(), now: new Date().toISOString(),
events: new Map(), // Map of date strings to arrays of events events: new Map(),
weekend: getConfiguredWeekendDays(), // Lightweight mutation counter so views can rebuild in a throttled / idle way
// without tracking deep reactivity on every event object.
eventsMutation: 0,
// Incremented internally by history plugin to force reactive updates for canUndo/canRedo
historyTick: 0,
historyCanUndo: false,
historyCanRedo: false,
weekend: getLocaleWeekendDays(),
_holidayConfigSignature: null,
_holidaysInitialized: false,
config: { config: {
select_days: 1000, select_days: 14,
min_year: MIN_YEAR, first_day: 1,
max_year: MAX_YEAR, holidays: {
first_day: getConfiguredFirstDay(), enabled: true,
country: 'auto',
state: null,
region: null,
},
}, },
}), }),
getters: {
// Basic configuration getters
minYear: () => MIN_YEAR,
maxYear: () => MAX_YEAR,
},
actions: { actions: {
updateCurrentDate() { _rotateWeekdayPattern(pattern, shift) {
this.now = new Date() const k = (7 - (shift % 7)) % 7
const today = toLocalString(this.now) return pattern.slice(k).concat(pattern.slice(0, k))
if (this.today !== today) { },
this.today = today _resolveCountry(code) {
} if (!code || code !== 'auto') return code
const locale = navigator.language || navigator.languages?.[0]
if (!locale) return null
const parts = locale.split('-')
if (parts.length < 2) return null
return parts[parts.length - 1].toUpperCase()
},
initializeHolidaysFromConfig() {
if (!this.config.holidays.enabled) return false
const country = this._resolveCountry(this.config.holidays.country)
if (country) {
return this.initializeHolidays(
country,
this.config.holidays.state,
this.config.holidays.region,
)
}
return false
},
updateCurrentDate() {
const d = new Date()
this.now = d.toISOString()
const today = toLocalString(d, DEFAULT_TZ)
if (this.today !== today) this.today = today
},
initializeHolidays(country, state = null, region = null) {
const actualCountry = this._resolveCountry(country)
if (this.config.holidays.country !== 'auto') this.config.holidays.country = country
this.config.holidays.state = state
this.config.holidays.region = region
this._holidayConfigSignature = null
this._holidaysInitialized = false
return initializeHolidays(actualCountry, state, region)
},
_ensureHolidaysInitialized() {
if (!this.config.holidays.enabled) return false
const actualCountry = this._resolveCountry(this.config.holidays.country)
const sig = `${actualCountry}-${this.config.holidays.state}-${this.config.holidays.region}-${this.config.holidays.enabled}`
if (this._holidayConfigSignature !== sig || !this._holidaysInitialized) {
const ok = initializeHolidays(
actualCountry,
this.config.holidays.state,
this.config.holidays.region,
)
if (ok) {
this._holidayConfigSignature = sig
this._holidaysInitialized = true
}
return ok
}
return this._holidaysInitialized
},
getAvailableCountries() {
return getAvailableCountries() || []
},
getAvailableStates(country) {
return getAvailableStates(country) || []
},
toggleHolidays() {
this.config.holidays.enabled = !this.config.holidays.enabled
}, },
// Event management
generateId() { generateId() {
try { try {
if (window.crypto && typeof window.crypto.randomUUID === 'function') { if (window.crypto && typeof window.crypto.randomUUID === 'function') {
@ -81,357 +118,308 @@ export const useCalendarStore = defineStore('calendar', {
return 'e-' + Math.random().toString(36).slice(2, 10) + '-' + Date.now().toString(36) return 'e-' + Math.random().toString(36).slice(2, 10) + '-' + Date.now().toString(36)
}, },
notifyEventsChanged() {
// Bump simple counter (wrapping to avoid overflow in extreme long sessions)
this.eventsMutation = (this.eventsMutation + 1) % 1_000_000_000
},
touchEvents() {
this.notifyEventsChanged()
},
createEvent(eventData) { createEvent(eventData) {
const singleDay = eventData.startDate === eventData.endDate let days = 1
if (typeof eventData.days === 'number') {
days = Math.max(1, Math.floor(eventData.days))
}
const singleDay = days === 1
const event = { const event = {
id: this.generateId(), id: this.generateId(),
title: eventData.title, title: eventData.title,
startDate: eventData.startDate, startDate: eventData.startDate,
endDate: eventData.endDate, days,
colorId: colorId:
eventData.colorId ?? this.selectEventColorId(eventData.startDate, eventData.endDate), eventData.colorId ?? this.selectEventColorId(eventData.startDate, eventData.startDate),
startTime: singleDay ? eventData.startTime || '09:00' : null, startTime: singleDay ? eventData.startTime || '09:00' : null,
durationMinutes: singleDay ? eventData.durationMinutes || 60 : null, durationMinutes: singleDay ? eventData.durationMinutes || 60 : null,
repeat: recur:
(eventData.repeat === 'weekly' eventData.recur && ['weeks', 'months'].includes(eventData.recur.freq)
? 'weeks' ? {
: eventData.repeat === 'monthly' freq: eventData.recur.freq,
? 'months' interval: eventData.recur.interval || 1,
: eventData.repeat) || 'none', count: eventData.recur.count ?? 'unlimited',
repeatInterval: eventData.repeatInterval || 1, weekdays: Array.isArray(eventData.recur.weekdays)
repeatCount: eventData.repeatCount || 'unlimited', ? [...eventData.recur.weekdays]
repeatWeekdays: eventData.repeatWeekdays, : null,
isRepeating: eventData.repeat && eventData.repeat !== 'none',
} }
: null,
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 }) this.events.set(event.id, { ...event, isSpanning: event.days > 1 })
} this.notifyEventsChanged()
// No physical expansion; repeats are virtual
return event.id return event.id
}, },
getEventById(id) { getEventById(id) {
for (const [, list] of this.events) { return this.events.get(id) || null
const found = list.find((e) => e.id === id)
if (found) return found
}
return null
}, },
selectEventColorId(startDateStr, endDateStr) { selectEventColorId(startDateStr, endDateStr) {
const colorCounts = [0, 0, 0, 0, 0, 0, 0, 0] const colorCounts = [0, 0, 0, 0, 0, 0, 0, 0]
const startDate = new Date(fromLocalString(startDateStr)) const startDate = fromLocalString(startDateStr, DEFAULT_TZ)
const endDate = new Date(fromLocalString(endDateStr)) const endDate = fromLocalString(endDateStr, DEFAULT_TZ)
for (const ev of this.events.values()) {
for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) { const evStart = fromLocalString(ev.startDate)
const dateStr = toLocalString(d) const evEnd = addDays(evStart, (ev.days || 1) - 1)
const dayEvents = this.events.get(dateStr) || [] if (evEnd < startDate || evStart > endDate) continue
for (const event of dayEvents) { if (ev.colorId >= 0 && ev.colorId < 8) colorCounts[ev.colorId]++
if (event.colorId >= 0 && event.colorId < 8) {
colorCounts[event.colorId]++
} }
}
}
let minCount = colorCounts[0] let minCount = colorCounts[0]
let selectedColor = 0 let selectedColor = 0
for (let c = 1; c < 8; c++) {
for (let colorId = 1; colorId < 8; colorId++) { if (colorCounts[c] < minCount) {
if (colorCounts[colorId] < minCount) { minCount = colorCounts[c]
minCount = colorCounts[colorId] selectedColor = c
selectedColor = colorId
} }
} }
return selectedColor return selectedColor
}, },
deleteEvent(eventId) { deleteEvent(eventId) {
const datesToCleanup = [] this.events.delete(eventId)
for (const [dateStr, eventList] of this.events) { this.notifyEventsChanged()
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))
},
deleteSingleOccurrence(ctx) {
const { baseId, occurrenceIndex } = ctx
const base = this.getEventById(baseId)
if (!base || base.repeat !== 'weekly') return
if (!base || base.repeat !== 'weeks') return
// Strategy: clone pattern into two: reduce base repeatCount to exclude this occurrence; create new series for remainder excluding this one
// Simpler: convert base to explicit exception by creating a one-off non-repeating event? For now: create exception by inserting a blocking dummy? Instead just split by shifting repeatCount logic: decrement repeatCount and create a new series starting after this single occurrence.
// Implementation (approx): terminate at occurrenceIndex; create a new series starting next occurrence with remaining occurrences.
const remaining =
base.repeatCount === 'unlimited'
? 'unlimited'
: String(Math.max(0, parseInt(base.repeatCount, 10) - (occurrenceIndex + 1)))
this._terminateRepeatSeriesAtIndex(baseId, occurrenceIndex)
if (remaining === '0') return
// Find date of next occurrence
const startDate = new Date(base.startDate + 'T00:00:00')
let idx = 0
let cur = new Date(startDate)
while (idx <= occurrenceIndex && idx < 10000) {
cur.setDate(cur.getDate() + 1)
if (base.repeatWeekdays[cur.getDay()]) idx++
}
const nextStartStr = toLocalString(cur)
this.createEvent({
title: base.title,
startDate: nextStartStr,
endDate: nextStartStr,
colorId: base.colorId,
repeat: 'weeks',
repeatCount: remaining,
repeatWeekdays: base.repeatWeekdays,
})
},
deleteFromOccurrence(ctx) {
const { baseId, occurrenceIndex } = ctx
this._terminateRepeatSeriesAtIndex(baseId, occurrenceIndex)
}, },
deleteFirstOccurrence(baseId) { deleteFirstOccurrence(baseId) {
const base = this.getEventById(baseId) const base = this.getEventById(baseId)
if (!base || !base.isRepeating) return if (!base) return
const oldStart = new Date(fromLocalString(base.startDate)) if (!base.recur) {
const oldEnd = new Date(fromLocalString(base.endDate))
const spanDays = Math.round((oldEnd - oldStart) / (24 * 60 * 60 * 1000))
let newStart = null
if (base.repeat === 'weeks' && base.repeatWeekdays) {
const probe = new Date(oldStart)
for (let i = 0; i < 14; i++) {
// search ahead up to 2 weeks
probe.setDate(probe.getDate() + 1)
if (base.repeatWeekdays[probe.getDay()]) {
newStart = new Date(probe)
break
}
}
} else if (base.repeat === 'months') {
newStart = new Date(oldStart)
newStart.setMonth(newStart.getMonth() + 1)
} else {
// Unknown pattern: delete entire series
this.deleteEvent(baseId) this.deleteEvent(baseId)
return return
} }
const numericCount =
if (!newStart) { base.recur.count === 'unlimited' ? Infinity : parseInt(base.recur.count, 10)
// No subsequent occurrence -> delete entire series if (numericCount <= 1) {
this.deleteEvent(baseId) this.deleteEvent(baseId)
return return
} }
const nextStartStr = getOccurrenceDate(base, 1, DEFAULT_TZ)
if (base.repeatCount !== 'unlimited') { if (!nextStartStr) {
const rc = parseInt(base.repeatCount, 10)
if (!isNaN(rc)) {
const newRc = Math.max(0, rc - 1)
if (newRc === 0) {
this.deleteEvent(baseId) this.deleteEvent(baseId)
return return
} }
base.repeatCount = String(newRc) base.startDate = nextStartStr
} // keep same days length
} if (numericCount !== Infinity) base.recur.count = String(Math.max(1, numericCount - 1))
this.events.set(baseId, { ...base, isSpanning: base.days > 1 })
this.notifyEventsChanged()
},
const newEnd = new Date(newStart) deleteSingleOccurrence(ctx) {
newEnd.setDate(newEnd.getDate() + spanDays) const { baseId, occurrenceIndex } = ctx || {}
base.startDate = toLocalString(newStart) if (occurrenceIndex == null) return
base.endDate = toLocalString(newEnd) const base = this.getEventById(baseId)
// old occurrence expansion removed (series handled differently now) if (!base) return
const originalRepeatCount = base.repeatCount if (!base.recur) {
// Always cap original series at the split occurrence index (occurrences 0..index-1) if (occurrenceIndex === 0) this.deleteEvent(baseId)
// Keep its weekday pattern unchanged. return
this._terminateRepeatSeriesAtIndex(baseId, index)
let newRepeatCount = 'unlimited'
if (originalRepeatCount !== 'unlimited') {
const originalCount = parseInt(originalRepeatCount, 10)
if (!isNaN(originalCount)) {
const remaining = originalCount - index
// remaining occurrences go to new series; ensure at least 1 (the dragged occurrence itself)
newRepeatCount = remaining > 0 ? String(remaining) : '1'
} }
} else { if (occurrenceIndex === 0) {
// Original was unlimited: original now capped, new stays unlimited this.deleteFirstOccurrence(baseId)
newRepeatCount = 'unlimited' return
} }
const snapshot = { ...base }
// Handle weekdays for weekly repeats snapshot.recur = snapshot.recur ? { ...snapshot.recur } : null
let newRepeatWeekdays = base.repeatWeekdays base.recur.count = occurrenceIndex
if (base.repeat === 'weeks' && base.repeatWeekdays) { const nextStartStr = getOccurrenceDate(snapshot, occurrenceIndex + 1, DEFAULT_TZ)
const newStartDate = new Date(fromLocalString(startDate)) if (!nextStartStr) return
let dayShift = 0 const originalNumeric =
if (grabbedWeekday != null) { snapshot.recur.count === 'unlimited' ? Infinity : parseInt(snapshot.recur.count, 10)
// Rotate so that the grabbed weekday maps to the new start weekday let remainingCount = 'unlimited'
dayShift = newStartDate.getDay() - grabbedWeekday if (originalNumeric !== Infinity) {
} else { const rem = originalNumeric - (occurrenceIndex + 1)
// Fallback: rotate by difference between new and original start weekday if (rem <= 0) return
const originalStartDate = new Date(fromLocalString(base.startDate)) remainingCount = String(rem)
dayShift = newStartDate.getDay() - originalStartDate.getDay()
} }
if (dayShift !== 0) { this.createEvent({
const rotatedWeekdays = [false, false, false, false, false, false, false] title: snapshot.title,
for (let i = 0; i < 7; i++) { startDate: nextStartStr,
if (base.repeatWeekdays[i]) { days: snapshot.days,
let nd = (i + dayShift) % 7 colorId: snapshot.colorId,
if (nd < 0) nd += 7 recur: snapshot.recur
rotatedWeekdays[nd] = true ? {
freq: snapshot.recur.freq,
interval: snapshot.recur.interval,
count: remainingCount,
weekdays: snapshot.recur.weekdays ? [...snapshot.recur.weekdays] : null,
} }
} : null,
newRepeatWeekdays = rotatedWeekdays
}
}
const newId = this.createEvent({
title: base.title,
startDate,
endDate,
colorId: base.colorId,
repeat: base.repeat,
repeatCount: newRepeatCount,
repeatWeekdays: newRepeatWeekdays,
}) })
return newId this.notifyEventsChanged()
}, },
_snapshotBaseEvent(eventId) { deleteFromOccurrence(ctx) {
// Return a shallow snapshot of any instance for metadata const { baseId, occurrenceIndex } = ctx
for (const [, eventList] of this.events) { const base = this.getEventById(baseId)
const e = eventList.find((x) => x.id === eventId) if (!base || !base.recur) return
if (e) return { ...e } if (occurrenceIndex === 0) {
this.deleteEvent(baseId)
return
} }
return null this._terminateRepeatSeriesAtIndex(baseId, occurrenceIndex)
this.notifyEventsChanged()
}, },
_removeEventFromAllDatesById(eventId) { setEventRange(eventId, newStartStr, newEndStr, { mode = 'auto', rotatePattern = true } = {}) {
for (const [dateStr, list] of this.events) { const snapshot = this.events.get(eventId)
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)
}
},
// expandRepeats removed: no physical occurrence expansion
// Adjust start/end range of a base event (non-generated) and reindex occurrences
setEventRange(eventId, newStartStr, newEndStr, { mode = 'auto' } = {}) {
const snapshot = this._findEventInAnyList(eventId)
if (!snapshot) return if (!snapshot) return
// Calculate current duration in days (inclusive) const prevStart = fromLocalString(snapshot.startDate, DEFAULT_TZ)
const prevStart = new Date(fromLocalString(snapshot.startDate)) const prevDurationDays = (snapshot.days || 1) - 1
const prevEnd = new Date(fromLocalString(snapshot.endDate)) const newStart = fromLocalString(newStartStr, DEFAULT_TZ)
const prevDurationDays = Math.max( const newEnd = fromLocalString(newEndStr, DEFAULT_TZ)
0, const proposedDurationDays = Math.max(0, differenceInCalendarDays(newEnd, newStart))
Math.round((prevEnd - prevStart) / (24 * 60 * 60 * 1000)),
)
const newStart = new Date(fromLocalString(newStartStr))
const newEnd = new Date(fromLocalString(newEndStr))
const proposedDurationDays = Math.max(
0,
Math.round((newEnd - newStart) / (24 * 60 * 60 * 1000)),
)
let finalDurationDays = prevDurationDays let finalDurationDays = prevDurationDays
if (mode === 'resize-left' || mode === 'resize-right') { if (mode === 'resize-left' || mode === 'resize-right')
finalDurationDays = proposedDurationDays finalDurationDays = proposedDurationDays
}
snapshot.startDate = newStartStr snapshot.startDate = newStartStr
snapshot.endDate = toLocalString( snapshot.days = finalDurationDays + 1
new Date(
new Date(fromLocalString(newStartStr)).setDate(
new Date(fromLocalString(newStartStr)).getDate() + finalDurationDays,
),
),
)
// Rotate weekly recurrence pattern when moving (not resizing) so selected weekdays track shift
if ( if (
mode === 'move' && rotatePattern &&
snapshot.isRepeating && (mode === 'move' || mode === 'resize-left') &&
snapshot.repeat === 'weeks' && snapshot.recur &&
Array.isArray(snapshot.repeatWeekdays) snapshot.recur.freq === 'weeks' &&
Array.isArray(snapshot.recur.weekdays)
) { ) {
const oldDow = prevStart.getDay() const oldDow = prevStart.getDay()
const newDow = newStart.getDay() const newDow = newStart.getDay()
const shift = newDow - oldDow const shift = newDow - oldDow
if (shift !== 0) { if (shift !== 0) {
const rotated = [false, false, false, false, false, false, false] snapshot.recur.weekdays = this._rotateWeekdayPattern(snapshot.recur.weekdays, shift)
for (let i = 0; i < 7; i++) {
if (snapshot.repeatWeekdays[i]) {
let ni = (i + shift) % 7
if (ni < 0) ni += 7
rotated[ni] = true
} }
} }
snapshot.repeatWeekdays = rotated this.events.set(eventId, { ...snapshot, isSpanning: snapshot.days > 1 })
} this.notifyEventsChanged()
}
// Reindex
this._removeEventFromAllDatesById(eventId)
this._addEventToDateRangeWithId(eventId, snapshot, snapshot.startDate, snapshot.endDate)
// no expansion
}, },
// Split a repeating series at a given occurrence index; returns new series id splitMoveVirtualOccurrence(baseId, occurrenceDateStr, newStartStr, newEndStr) {
splitRepeatSeries(baseId, occurrenceIndex, newStartStr, newEndStr, _grabbedWeekday) { const base = this.events.get(baseId)
const base = this._findEventInAnyList(baseId) if (!base || !base.recur) return
if (!base || !base.isRepeating) return null const originalCountRaw = base.recur.count
// Capture original repeatCount BEFORE truncation const occurrenceDate = fromLocalString(occurrenceDateStr, DEFAULT_TZ)
const originalCountRaw = base.repeatCount const baseStart = fromLocalString(base.startDate, DEFAULT_TZ)
// Truncate base to keep occurrences before split point (indices 0..occurrenceIndex-1) // If series effectively has <=1 occurrence, treat as simple move (no split) and flatten
let totalOccurrences = Infinity
if (originalCountRaw !== 'unlimited') {
const parsed = parseInt(originalCountRaw, 10)
if (!isNaN(parsed)) totalOccurrences = parsed
}
if (totalOccurrences <= 1) {
// Flatten to non-repeating if not already
if (base.recur) {
base.recur = null
this.events.set(baseId, { ...base })
}
this.setEventRange(baseId, newStartStr, newEndStr, { mode: 'move', rotatePattern: true })
return baseId
}
if (occurrenceDate <= baseStart) {
this.setEventRange(baseId, newStartStr, newEndStr, { mode: 'move' })
return baseId
}
let keptOccurrences = 0
if (base.recur.freq === 'weeks') {
const interval = base.recur.interval || 1
const pattern = base.recur.weekdays || []
if (!pattern.some(Boolean)) return
const WEEK_MS = 7 * 86400000
const blockStartBase = getMondayOfISOWeek(baseStart)
function isAligned(d) {
const blk = getMondayOfISOWeek(d)
const diff = Math.floor((blk - blockStartBase) / WEEK_MS)
return diff % interval === 0
}
let cursor = new Date(baseStart)
while (cursor < occurrenceDate) {
if (pattern[cursor.getDay()] && isAligned(cursor)) keptOccurrences++
cursor = addDays(cursor, 1)
}
} else if (base.recur.freq === 'months') {
const diffMonths =
(occurrenceDate.getFullYear() - baseStart.getFullYear()) * 12 +
(occurrenceDate.getMonth() - baseStart.getMonth())
const interval = base.recur.interval || 1
if (diffMonths <= 0 || diffMonths % interval !== 0) return
keptOccurrences = diffMonths
} else {
return
}
this._terminateRepeatSeriesAtIndex(baseId, keptOccurrences)
// After truncation compute base kept count
const truncated = this.events.get(baseId)
if (
truncated &&
truncated.recur &&
truncated.recur.count &&
truncated.recur.count !== 'unlimited'
) {
// keptOccurrences already reflects number before split; adjust not needed further
}
let remainingCount = 'unlimited'
if (originalCountRaw !== 'unlimited') {
const total = parseInt(originalCountRaw, 10)
if (!isNaN(total)) {
const rem = total - keptOccurrences
if (rem <= 0) return
remainingCount = String(rem)
}
}
let weekdays = base.recur.weekdays
if (base.recur.freq === 'weeks' && Array.isArray(base.recur.weekdays)) {
const origWeekday = occurrenceDate.getDay()
const newWeekday = fromLocalString(newStartStr, DEFAULT_TZ).getDay()
const shift = newWeekday - origWeekday
if (shift !== 0) {
weekdays = this._rotateWeekdayPattern(base.recur.weekdays, shift)
}
}
const newId = this.createEvent({
title: base.title,
startDate: newStartStr,
days: base.days,
colorId: base.colorId,
recur: {
freq: base.recur.freq,
interval: base.recur.interval,
count: remainingCount,
weekdays,
},
})
// Flatten base if single occurrence now
if (truncated && truncated.recur) {
const baseCountNum =
truncated.recur.count === 'unlimited' ? Infinity : parseInt(truncated.recur.count, 10)
if (baseCountNum <= 1) {
truncated.recur = null
this.events.set(baseId, { ...truncated })
}
}
// Flatten new if single occurrence only
const newly = this.events.get(newId)
if (newly && newly.recur) {
const newCountNum =
newly.recur.count === 'unlimited' ? Infinity : parseInt(newly.recur.count, 10)
if (newCountNum <= 1) {
newly.recur = null
this.events.set(newId, { ...newly })
}
}
this.notifyEventsChanged()
return newId
},
splitRepeatSeries(baseId, occurrenceIndex, newStartStr, _newEndStr) {
const base = this.events.get(baseId)
if (!base || !base.recur) return null
const originalCountRaw = base.recur.count
this._terminateRepeatSeriesAtIndex(baseId, occurrenceIndex) this._terminateRepeatSeriesAtIndex(baseId, occurrenceIndex)
// Compute new series repeatCount (remaining occurrences starting at occurrenceIndex)
let newSeriesCount = 'unlimited' let newSeriesCount = 'unlimited'
if (originalCountRaw !== 'unlimited') { if (originalCountRaw !== 'unlimited') {
const originalNum = parseInt(originalCountRaw, 10) const originalNum = parseInt(originalCountRaw, 10)
@ -440,64 +428,54 @@ export const useCalendarStore = defineStore('calendar', {
newSeriesCount = String(Math.max(1, remaining)) newSeriesCount = String(Math.max(1, remaining))
} }
} }
const newId = this.createEvent({ return this.createEvent({
title: base.title, title: base.title,
startDate: newStartStr, startDate: newStartStr,
endDate: newEndStr, days: base.days,
colorId: base.colorId, colorId: base.colorId,
repeat: base.repeat, recur: base.recur
repeatInterval: base.repeatInterval, ? {
repeatCount: newSeriesCount, freq: base.recur.freq,
repeatWeekdays: base.repeatWeekdays, interval: base.recur.interval,
count: newSeriesCount,
weekdays: base.recur.weekdays ? [...base.recur.weekdays] : null,
}
: null,
}) })
return newId
},
_reindexBaseEvent(eventId, snapshot, startDate, endDate) {
if (!snapshot) return
this._removeEventFromAllDatesById(eventId)
this._addEventToDateRangeWithId(eventId, snapshot, startDate, endDate)
}, },
_terminateRepeatSeriesAtIndex(baseId, index) { _terminateRepeatSeriesAtIndex(baseId, index) {
// Cap repeatCount to 'index' total occurrences (0-based index means index occurrences before split) const ev = this.events.get(baseId)
for (const [, list] of this.events) { if (!ev || !ev.recur) return
for (const ev of list) { if (ev.recur.count === 'unlimited') {
if (ev.id === baseId && ev.isRepeating) { ev.recur.count = String(index)
if (ev.repeatCount === 'unlimited') {
ev.repeatCount = String(index)
} else { } else {
const rc = parseInt(ev.repeatCount, 10) const rc = parseInt(ev.recur.count, 10)
if (!isNaN(rc)) ev.repeatCount = String(Math.min(rc, index)) if (!isNaN(rc)) ev.recur.count = String(Math.min(rc, index))
}
}
}
} }
this.notifyEventsChanged()
}, },
_findEventInAnyList(eventId) {
for (const [, eventList] of this.events) {
const found = eventList.find((e) => e.id === eventId)
if (found) return found
}
return null
}, },
persist: {
_addEventToDateRange(event) { key: 'calendar-store',
const startDate = fromLocalString(event.startDate) storage: localStorage,
const endDate = fromLocalString(event.endDate) paths: ['today', 'config', 'events'],
const cur = new Date(startDate) serializer: {
serialize(value) {
while (cur <= endDate) { return JSON.stringify(value, (_k, v) => {
const dateStr = toLocalString(cur) if (v instanceof Map) return { __map: true, data: [...v] }
if (!this.events.has(dateStr)) { if (v instanceof Set) return { __set: true, data: [...v] }
this.events.set(dateStr, []) return v
} })
this.events.get(dateStr).push({ ...event, isSpanning: event.startDate < event.endDate }) },
cur.setDate(cur.getDate() + 1) deserialize(value) {
} const revived = JSON.parse(value, (_k, v) => {
if (v && v.__map) return new Map(v.data)
if (v && v.__set) return new Set(v.data)
return v
})
return revived
},
}, },
// NOTE: legacy dynamic getEventById for synthetic occurrences removed.
}, },
}) })

View File

@ -1,4 +1,14 @@
// date-utils.js — Date handling utilities for the calendar // date-utils.js — Restored & clean utilities (date-fns + timezone aware)
import * as dateFns from 'date-fns'
import { fromZonedTime, toZonedTime } from 'date-fns-tz'
const DEFAULT_TZ = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
// Re-exported iso helpers (keep the same exported names used elsewhere)
const getISOWeek = dateFns.getISOWeek
const getISOWeekYear = dateFns.getISOWeekYear
// Constants
const monthAbbr = [ const monthAbbr = [
'jan', 'jan',
'feb', 'feb',
@ -13,201 +23,342 @@ const monthAbbr = [
'nov', 'nov',
'dec', 'dec',
] ]
const DAY_MS = 86400000 const MIN_YEAR = 100 // less than 100 is interpreted as 19xx
const WEEK_MS = 7 * DAY_MS const MAX_YEAR = 9999
// Core helpers ------------------------------------------------------------
/** /**
* Get ISO week information for a given date * Construct a date at local midnight in the specified IANA timezone.
* @param {Date} date - The date to get week info for * Returns a native Date whose wall-clock components in that zone are (Y, M, D 00:00:00).
* @returns {Object} Object containing week number and year
*/ */
const isoWeekInfo = (date) => { function makeTZDate(year, monthIndex, day, timeZone = DEFAULT_TZ) {
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())) const iso = `${String(year).padStart(4, '0')}-${String(monthIndex + 1).padStart(2, '0')}-${String(
const day = d.getUTCDay() || 7 day,
d.setUTCDate(d.getUTCDate() + 4 - day) ).padStart(2, '0')}`
const year = d.getUTCFullYear() const utcDate = fromZonedTime(`${iso}T00:00:00`, timeZone)
const yearStart = new Date(Date.UTC(year, 0, 1)) return toZonedTime(utcDate, timeZone)
const diffDays = Math.floor((d - yearStart) / DAY_MS) + 1
return { week: Math.ceil(diffDays / 7), year }
} }
/** /**
* Convert a Date object to a local date string (YYYY-MM-DD format) * Alias constructor for timezone-specific calendar date (semantic sugar over makeTZDate).
* @param {Date} date - The date to convert (defaults to new Date())
* @returns {string} Date string in YYYY-MM-DD format
*/ */
function toLocalString(date = new Date()) { const TZDate = (year, monthIndex, day, timeZone = DEFAULT_TZ) =>
const pad = (n) => String(Math.floor(Math.abs(n))).padStart(2, '0') makeTZDate(year, monthIndex, day, timeZone)
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
/**
* Construct a UTC-based date/time (wrapper for Date.UTC for consistency).
*/
const UTCDate = (year, monthIndex, day, hour = 0, minute = 0, second = 0, ms = 0) =>
new Date(Date.UTC(year, monthIndex, day, hour, minute, second, ms))
function toLocalString(date = new Date(), timeZone = DEFAULT_TZ) {
return dateFns.format(toZonedTime(date, timeZone), 'yyyy-MM-dd')
} }
/** function fromLocalString(dateString, timeZone = DEFAULT_TZ) {
* Convert a local date string (YYYY-MM-DD) to a Date object if (!dateString) return makeTZDate(1970, 0, 1, timeZone)
* @param {string} dateString - Date string in YYYY-MM-DD format const parsed = dateFns.parseISO(dateString)
* @returns {Date} Date object const utcDate = fromZonedTime(`${dateString}T00:00:00`, timeZone)
*/ return toZonedTime(utcDate, timeZone) || parsed
function fromLocalString(dateString) {
const [year, month, day] = dateString.split('-').map(Number)
return new Date(year, month - 1, day)
} }
/** function getMondayOfISOWeek(date, timeZone = DEFAULT_TZ) {
* Get the index of Monday for a given date (0-6, where Monday = 0) const d = toZonedTime(date, timeZone)
* @param {Date} d - The date const dow = (dateFns.getDay(d) + 6) % 7 // Monday=0
* @returns {number} Monday index (0-6) return dateFns.addDays(dateFns.startOfDay(d), -dow)
*/ }
const mondayIndex = (d) => (d.getDay() + 6) % 7
/** const mondayIndex = (d) => (dateFns.getDay(d) + 6) % 7
* Pad a number with leading zeros to make it 2 digits
* @param {number} n - Number to pad // Count how many days in [startDate..endDate] match the boolean `pattern` array
* @returns {string} Padded string function countPatternDaysInInterval(startDate, endDate, patternArr) {
*/ const days = dateFns.eachDayOfInterval({
start: dateFns.startOfDay(startDate),
end: dateFns.startOfDay(endDate),
})
return days.reduce((c, d) => c + (patternArr[dateFns.getDay(d)] ? 1 : 0), 0)
}
// Recurrence: Weekly ------------------------------------------------------
function _getRecur(event) {
return event?.recur ?? null
}
function getWeeklyOccurrenceIndex(event, dateStr, timeZone = DEFAULT_TZ) {
const recur = _getRecur(event)
if (!recur || recur.freq !== 'weeks') return null
const pattern = recur.weekdays || []
if (!pattern.some(Boolean)) return null
const target = fromLocalString(dateStr, timeZone)
const baseStart = fromLocalString(event.startDate, timeZone)
if (target < baseStart) return null
const dow = dateFns.getDay(target)
if (!pattern[dow]) return null // target not active
const interval = recur.interval || 1
const baseBlockStart = getMondayOfISOWeek(baseStart, timeZone)
const currentBlockStart = getMondayOfISOWeek(target, timeZone)
// Number of weeks between block starts (each block start is a Monday)
const weekDiff = dateFns.differenceInCalendarWeeks(currentBlockStart, baseBlockStart)
if (weekDiff < 0 || weekDiff % interval !== 0) return null
const baseDow = dateFns.getDay(baseStart)
const baseCountsAsPattern = !!pattern[baseDow]
// Same ISO week as base: count pattern days from baseStart up to target (inclusive)
if (weekDiff === 0) {
let n = countPatternDaysInInterval(baseStart, target, pattern) - 1
if (!baseCountsAsPattern) n += 1
const maxCount = recur.count === 'unlimited' ? Infinity : parseInt(recur.count, 10)
return n < 0 || n >= maxCount ? null : n
}
const baseWeekEnd = dateFns.addDays(baseBlockStart, 6)
// Count pattern days in the first (possibly partial) week from baseStart..baseWeekEnd
const firstWeekCount = countPatternDaysInInterval(baseStart, baseWeekEnd, pattern)
const alignedWeeksBetween = weekDiff / interval - 1
const fullPatternWeekCount = pattern.filter(Boolean).length
const middleWeeksCount = alignedWeeksBetween > 0 ? alignedWeeksBetween * fullPatternWeekCount : 0
// Count pattern days in the current (possibly partial) week from currentBlockStart..target
const currentWeekCount = countPatternDaysInInterval(currentBlockStart, target, pattern)
let n = firstWeekCount + middleWeeksCount + currentWeekCount - 1
if (!baseCountsAsPattern) n += 1
const maxCount = recur.count === 'unlimited' ? Infinity : parseInt(recur.count, 10)
return n >= maxCount ? null : n
}
// Recurrence: Monthly -----------------------------------------------------
function getMonthlyOccurrenceIndex(event, dateStr, timeZone = DEFAULT_TZ) {
const recur = _getRecur(event)
if (!recur || recur.freq !== 'months') return null
const baseStart = fromLocalString(event.startDate, timeZone)
const d = fromLocalString(dateStr, timeZone)
const diffMonths = dateFns.differenceInCalendarMonths(d, baseStart)
if (diffMonths < 0) return null
const interval = recur.interval || 1
if (diffMonths % interval !== 0) return null
const baseDay = dateFns.getDate(baseStart)
const effectiveDay = Math.min(baseDay, dateFns.getDaysInMonth(d))
if (dateFns.getDate(d) !== effectiveDay) return null
const n = diffMonths / interval
const maxCount = recur.count === 'unlimited' ? Infinity : parseInt(recur.count, 10)
return n >= maxCount ? null : n
}
function getOccurrenceIndex(event, dateStr, timeZone = DEFAULT_TZ) {
const recur = _getRecur(event)
if (!recur) return null
if (dateStr < event.startDate) return null
if (recur.freq === 'weeks') return getWeeklyOccurrenceIndex(event, dateStr, timeZone)
if (recur.freq === 'months') return getMonthlyOccurrenceIndex(event, dateStr, timeZone)
return null
}
// Reverse lookup: given a recurrence index (0-based) return the occurrence start date string.
// Returns null if the index is out of range or the event is not repeating.
function getWeeklyOccurrenceDate(event, occurrenceIndex, timeZone = DEFAULT_TZ) {
const recur = _getRecur(event)
if (!recur || recur.freq !== 'weeks') return null
if (occurrenceIndex < 0 || !Number.isInteger(occurrenceIndex)) return null
const maxCount = recur.count === 'unlimited' ? Infinity : parseInt(recur.count, 10)
if (occurrenceIndex >= maxCount) return null
const pattern = recur.weekdays || []
if (!pattern.some(Boolean)) return null
const interval = recur.interval || 1
const baseStart = fromLocalString(event.startDate, timeZone)
if (occurrenceIndex === 0) return toLocalString(baseStart, timeZone)
const baseWeekMonday = getMondayOfISOWeek(baseStart, timeZone)
const baseDow = dateFns.getDay(baseStart)
const baseCountsAsPattern = !!pattern[baseDow]
// Adjust index if base weekday is not part of the pattern (pattern occurrences shift by +1)
let occ = occurrenceIndex
if (!baseCountsAsPattern) occ -= 1
if (occ < 0) return null
// Sorted list of active weekday indices
const patternDays = []
for (let d = 0; d < 7; d++) if (pattern[d]) patternDays.push(d)
// First (possibly partial) week: only pattern days >= baseDow and >= baseStart date
const firstWeekDates = []
for (const d of patternDays) {
if (d < baseDow) continue
const date = dateFns.addDays(baseWeekMonday, d)
if (date < baseStart) continue
firstWeekDates.push(date)
}
const F = firstWeekDates.length
if (occ < F) {
return toLocalString(firstWeekDates[occ], timeZone)
}
const remaining = occ - F
const P = patternDays.length
if (P === 0) return null
// Determine aligned week group (k >= 1) in which the remaining-th occurrence lies
const k = Math.floor(remaining / P) + 1 // 1-based aligned week count after base week
const indexInWeek = remaining % P
const dow = patternDays[indexInWeek]
const occurrenceDate = dateFns.addDays(baseWeekMonday, k * interval * 7 + dow)
return toLocalString(occurrenceDate, timeZone)
}
function getMonthlyOccurrenceDate(event, occurrenceIndex, timeZone = DEFAULT_TZ) {
const recur = _getRecur(event)
if (!recur || recur.freq !== 'months') return null
if (occurrenceIndex < 0 || !Number.isInteger(occurrenceIndex)) return null
const maxCount = recur.count === 'unlimited' ? Infinity : parseInt(recur.count, 10)
if (occurrenceIndex >= maxCount) return null
const interval = recur.interval || 1
const baseStart = fromLocalString(event.startDate, timeZone)
const targetMonthOffset = occurrenceIndex * interval
const monthDate = dateFns.addMonths(baseStart, targetMonthOffset)
// Adjust day for shorter months (clamp like forward logic)
const baseDay = dateFns.getDate(baseStart)
const daysInTargetMonth = dateFns.getDaysInMonth(monthDate)
const day = Math.min(baseDay, daysInTargetMonth)
const actual = makeTZDate(dateFns.getYear(monthDate), dateFns.getMonth(monthDate), day, timeZone)
return toLocalString(actual, timeZone)
}
function getOccurrenceDate(event, occurrenceIndex, timeZone = DEFAULT_TZ) {
const recur = _getRecur(event)
if (!recur) return null
if (recur.freq === 'weeks') return getWeeklyOccurrenceDate(event, occurrenceIndex, timeZone)
if (recur.freq === 'months') return getMonthlyOccurrenceDate(event, occurrenceIndex, timeZone)
return null
}
function getVirtualOccurrenceEndDate(event, occurrenceStartDate, timeZone = DEFAULT_TZ) {
const spanDays = Math.max(0, (event.days || 1) - 1)
const occurrenceStart = fromLocalString(occurrenceStartDate, timeZone)
return toLocalString(dateFns.addDays(occurrenceStart, spanDays), timeZone)
}
// Utility formatting & localization ---------------------------------------
const pad = (n) => String(n).padStart(2, '0') const pad = (n) => String(n).padStart(2, '0')
/** function daysInclusive(aStr, bStr, timeZone = DEFAULT_TZ) {
* Calculate number of days between two date strings (inclusive) const a = fromLocalString(aStr, timeZone)
* @param {string} aStr - First date string (YYYY-MM-DD) const b = fromLocalString(bStr, timeZone)
* @param {string} bStr - Second date string (YYYY-MM-DD) return (
* @returns {number} Number of days inclusive Math.abs(dateFns.differenceInCalendarDays(dateFns.startOfDay(a), dateFns.startOfDay(b))) + 1
*/ )
function daysInclusive(aStr, bStr) {
const a = fromLocalString(aStr)
const b = fromLocalString(bStr)
const A = new Date(a.getFullYear(), a.getMonth(), a.getDate()).getTime()
const B = new Date(b.getFullYear(), b.getMonth(), b.getDate()).getTime()
return Math.floor(Math.abs(B - A) / DAY_MS) + 1
} }
/** function addDaysStr(str, n, timeZone = DEFAULT_TZ) {
* Add days to a date string return toLocalString(dateFns.addDays(fromLocalString(str, timeZone), n), timeZone)
* @param {string} str - Date string in YYYY-MM-DD format
* @param {number} n - Number of days to add (can be negative)
* @returns {string} New date string
*/
function addDaysStr(str, n) {
const d = fromLocalString(str)
d.setDate(d.getDate() + n)
return toLocalString(d)
} }
/** function getLocalizedWeekdayNames(timeZone = DEFAULT_TZ) {
* Get localized weekday names starting from Monday const monday = makeTZDate(2025, 0, 6, timeZone) // a Monday
* @returns {Array<string>} Array of localized weekday names return Array.from({ length: 7 }, (_, i) =>
*/ new Intl.DateTimeFormat(undefined, { weekday: 'short', timeZone }).format(
function getLocalizedWeekdayNames() { dateFns.addDays(monday, i),
const res = [] ),
const base = new Date(2025, 0, 6) // A Monday )
for (let i = 0; i < 7; i++) {
const d = new Date(base)
d.setDate(base.getDate() + i)
res.push(d.toLocaleDateString(undefined, { weekday: 'short' }))
}
return res
} }
/**
* Get the locale's first day of the week (0=Sunday, 1=Monday, etc.)
* @returns {number} First day of the week (0-6)
*/
function getLocaleFirstDay() { function getLocaleFirstDay() {
try { const day = new Intl.Locale(navigator.language).weekInfo?.firstDay ?? 1
return new Intl.Locale(navigator.language).weekInfo.firstDay % 7 return day % 7
} catch {
return 1 // Default to Monday if locale info not available
}
} }
/**
* Get the locale's weekend days as an array of booleans (Sunday=index 0)
* @returns {Array<boolean>} Array where true indicates a weekend day
*/
function getLocaleWeekendDays() { function getLocaleWeekendDays() {
try { const wk = new Set(new Intl.Locale(navigator.language).weekInfo?.weekend ?? [6, 7])
const localeWeekend = new Intl.Locale(navigator.language).weekInfo.weekend return Array.from({ length: 7 }, (_, i) => wk.has(1 + ((i + 6) % 7)))
const dayidx = new Set(localeWeekend)
return Array.from({ length: 7 }, (_, i) => dayidx.has(i || 7))
} catch {
return [true, false, false, false, false, false, true] // Default to Saturday/Sunday weekend
}
} }
/**
* Reorder a 7-element array based on the first day of the week
* @param {Array} days - Array of 7 elements (Sunday=index 0)
* @param {number} firstDay - First day of the week (0=Sunday, 1=Monday, etc.)
* @returns {Array} Reordered array
*/
function reorderByFirstDay(days, firstDay) { function reorderByFirstDay(days, firstDay) {
return Array.from({ length: 7 }, (_, i) => days[(i + firstDay) % 7]) return Array.from({ length: 7 }, (_, i) => days[(i + firstDay) % 7])
} }
/** function getLocalizedMonthName(idx, short = false, timeZone = DEFAULT_TZ) {
* Get localized month name const d = makeTZDate(2025, idx, 1, timeZone)
* @param {number} idx - Month index (0-11) return new Intl.DateTimeFormat(undefined, { month: short ? 'short' : 'long', timeZone }).format(d)
* @param {boolean} short - Whether to return short name
* @returns {string} Localized month name
*/
function getLocalizedMonthName(idx, short = false) {
const d = new Date(2025, idx, 1)
return d.toLocaleDateString(undefined, { month: short ? 'short' : 'long' })
} }
/** function formatDateRange(startDate, endDate, timeZone = DEFAULT_TZ) {
* Format a date range for display const a = toLocalString(startDate, timeZone)
* @param {Date} startDate - Start date const b = toLocalString(endDate, timeZone)
* @param {Date} endDate - End date if (a === b) return a
* @returns {string} Formatted date range string const [ay, am] = a.split('-')
*/ const [by, bm, bd] = b.split('-')
function formatDateRange(startDate, endDate) { if (ay === by && am === bm) return `${a}/${bd}`
if (toLocalString(startDate) === toLocalString(endDate)) return toLocalString(startDate) if (ay === by) return `${a}/${bm}-${bd}`
const startISO = toLocalString(startDate) return `${a}/${b}`
const endISO = toLocalString(endDate)
const [sy, sm] = startISO.split('-')
const [ey, em, ed] = endISO.split('-')
if (sy === ey && sm === em) return `${startISO}/${ed}`
if (sy === ey) return `${startISO}/${em}-${ed}`
return `${startISO}/${endISO}`
} }
/**
* Compute lunar phase symbol for the four main phases on a given date.
* Returns one of: 🌑 (new), 🌓 (first quarter), 🌕 (full), 🌗 (last quarter), or '' otherwise.
* Uses an approximate algorithm with a fixed epoch.
*/
function lunarPhaseSymbol(date) { function lunarPhaseSymbol(date) {
// Reference new moon: 2000-01-06 18:14 UTC (J2000 era), often used in approximations // Reference new moon (J2000 era) used for approximate phase calculations
const ref = Date.UTC(2000, 0, 6, 18, 14, 0) const ref = UTCDate(2000, 0, 6, 18, 14, 0)
const synodic = 29.530588853 // days const obs = new Date(date)
// Use UTC noon of given date to reduce timezone edge effects obs.setHours(12, 0, 0, 0)
const dUTC = Date.UTC(date.getFullYear(), date.getMonth(), date.getDate(), 12, 0, 0) const synodic = 29.530588853 // mean synodic month length in days
const daysSince = (dUTC - ref) / DAY_MS const daysSince = dateFns.differenceInMinutes(obs, ref) / 60 / 24
const phase = (((daysSince / synodic) % 1) + 1) % 1 const phase = (((daysSince / synodic) % 1) + 1) % 1 // normalize to [0,1)
const phases = [ const phases = [
{ t: 0.0, s: '🌑' }, // New Moon { t: 0.0, s: '🌑' }, // New
{ t: 0.25, s: '🌓' }, // First Quarter { t: 0.25, s: '🌓' }, // First Quarter
{ t: 0.5, s: '🌕' }, // Full Moon { t: 0.5, s: '🌕' }, // Full
{ t: 0.75, s: '🌗' }, // Last Quarter { t: 0.75, s: '🌗' }, // Last Quarter
] ]
// threshold in days from exact phase to still count for this date const thresholdDays = 0.5 // within ~12h of exact phase
const thresholdDays = 0.5 // ±12 hours
for (const p of phases) { for (const p of phases) {
let delta = Math.abs(phase - p.t) let delta = Math.abs(phase - p.t)
if (delta > 0.5) delta = 1 - delta if (delta > 0.5) delta = 1 - delta // wrap shortest arc
if (delta * synodic <= thresholdDays) return p.s if (delta * synodic <= thresholdDays) return p.s
} }
return '' return ''
} }
// Export all functions and constants // Exports -----------------------------------------------------------------
/**
* Format date as short localized string (e.g., "Jan 15")
*/
function formatDateShort(date) {
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }).replace(/, /, ' ')
}
/**
* Format date as long localized string with optional year (e.g., "Mon Jan 15" or "Mon Jan 15, 2025")
*/
function formatDateLong(date, includeYear = false) {
const opts = {
weekday: 'short',
month: 'short',
day: 'numeric',
...(includeYear ? { year: 'numeric' } : {}),
}
return date.toLocaleDateString(undefined, opts)
}
/**
* Format date as today string (e.g., "Monday\nJanuary 15")
*/
function formatTodayString(date) {
const formatted = date
.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' })
.replace(/,? /, '\n')
return formatted.charAt(0).toUpperCase() + formatted.slice(1)
}
export { export {
// constants
monthAbbr, monthAbbr,
DAY_MS, MIN_YEAR,
WEEK_MS, MAX_YEAR,
isoWeekInfo, DEFAULT_TZ,
// core tz helpers
makeTZDate,
toLocalString, toLocalString,
fromLocalString, fromLocalString,
// recurrence
getMondayOfISOWeek,
mondayIndex, mondayIndex,
getOccurrenceIndex,
getOccurrenceDate,
getVirtualOccurrenceEndDate,
// formatting & localization
pad, pad,
daysInclusive, daysInclusive,
addDaysStr, addDaysStr,
@ -217,5 +368,14 @@ export {
reorderByFirstDay, reorderByFirstDay,
getLocalizedMonthName, getLocalizedMonthName,
formatDateRange, formatDateRange,
formatDateShort,
formatDateLong,
formatTodayString,
lunarPhaseSymbol, lunarPhaseSymbol,
// iso helpers re-export
getISOWeek,
getISOWeekYear,
// constructors
TZDate,
UTCDate,
} }

179
src/utils/holidays.js Normal file
View File

@ -0,0 +1,179 @@
// holidays.js — Holiday utilities using date-holidays package
import Holidays from 'date-holidays'
let holidaysInstance = null
let currentCountry = null
let currentState = null
let currentRegion = null
let holidayCache = new Map()
let yearCache = new Map()
/**
* Initialize holidays for a specific country/region
* @param {string} country - Country code (e.g., 'US', 'GB', 'DE')
* @param {string} [state] - State/province code (e.g., 'CA' for California)
* @param {string} [region] - Region code
*/
export function initializeHolidays(country, state = null, region = null) {
if (!country) {
console.warn('No country provided for holiday initialization')
holidaysInstance = null
return false
}
try {
holidaysInstance = new Holidays(country, state, region)
currentCountry = country
currentState = state
currentRegion = region
holidayCache.clear()
yearCache.clear()
return true
} catch (error) {
console.warn('Failed to initialize holidays for', country, state, region, error)
holidaysInstance = null
return false
}
}
/**
* Get holidays for a specific year
* @param {number} year - The year to get holidays for
* @returns {Array} Array of holiday objects
*/
export function getHolidaysForYear(year) {
if (!holidaysInstance) {
return []
}
if (yearCache.has(year)) {
return yearCache.get(year)
}
try {
const holidays = holidaysInstance.getHolidays(year)
yearCache.set(year, holidays)
return holidays
} catch (error) {
console.warn('Failed to get holidays for year', year, error)
return []
}
}
/**
* Get holiday for a specific date
* @param {string|Date} date - Date in YYYY-MM-DD format or Date object
* @returns {Object|null} Holiday object or null if no holiday
*/
export function getHolidayForDate(date) {
if (!holidaysInstance) {
return null
}
const cacheKey = typeof date === 'string' ? date : date.toISOString().split('T')[0]
if (holidayCache.has(cacheKey)) {
return holidayCache.get(cacheKey)
}
try {
let dateObj
if (typeof date === 'string') {
const [year, month, day] = date.split('-').map(Number)
dateObj = new Date(year, month - 1, day)
} else {
dateObj = date
}
const year = dateObj.getFullYear()
const holidays = getHolidaysForYear(year)
const holiday = holidays.find((h) => {
const holidayDate = new Date(h.date)
return (
holidayDate.getFullYear() === dateObj.getFullYear() &&
holidayDate.getMonth() === dateObj.getMonth() &&
holidayDate.getDate() === dateObj.getDate()
)
})
const result = holiday || null
holidayCache.set(cacheKey, result)
return result
} catch (error) {
console.warn('Failed to get holiday for date', date, error)
return null
}
}
/**
* Check if a date is a holiday
* @param {string|Date} date - Date in YYYY-MM-DD format or Date object
* @returns {boolean} True if the date is a holiday
*/
export function isHoliday(date) {
return getHolidayForDate(date) !== null
}
/**
* Get available countries for holidays
* @returns {Array} Array of country codes
*/
export function getAvailableCountries() {
try {
const holidays = new Holidays()
const countries = holidays.getCountries()
// The getCountries method might return an object, convert to array of keys
if (countries && typeof countries === 'object') {
return Array.isArray(countries) ? countries : Object.keys(countries)
}
return ['US', 'GB', 'DE', 'FR', 'CA', 'AU'] // Fallback
} catch (error) {
console.warn('Failed to get available countries', error)
return ['US', 'GB', 'DE', 'FR', 'CA', 'AU'] // Fallback to common countries
}
}
/**
* Get available states/regions for a country
* @param {string} country - Country code
* @returns {Array} Array of state/region codes
*/
export function getAvailableStates(country) {
try {
if (!country) return []
const holidays = new Holidays()
const states = holidays.getStates(country)
// The getStates method might return an object, convert to array of keys
if (states && typeof states === 'object') {
return Array.isArray(states) ? states : Object.keys(states)
}
return []
} catch (error) {
console.warn('Failed to get available states for', country, error)
return []
}
}
/**
* Get holiday configuration info
* @returns {Object} Current holiday configuration
*/
export function getHolidayConfig() {
return {
country: currentCountry,
state: currentState,
region: currentRegion,
initialized: !!holidaysInstance,
}
}
// Initialize with US holidays by default
initializeHolidays('US')