Major new version #2
@ -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": {
|
||||||
|
42
src/App.vue
42
src/App.vue
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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 */
|
||||||
}
|
}
|
||||||
|
@ -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 {
|
||||||
|
264
src/components/BaseDialog.vue
Normal file
264
src/components/BaseDialog.vue
Normal 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>
|
@ -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>
|
|
@ -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>
|
||||||
|
@ -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>
|
|
@ -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);
|
||||||
|
@ -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>
|
||||||
|
@ -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
@ -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;
|
||||||
|
210
src/components/HeaderControls.vue
Normal file
210
src/components/HeaderControls.vue
Normal 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>
|
@ -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 {
|
||||||
|
@ -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;
|
||||||
|
309
src/components/SettingsDialog.vue
Normal file
309
src/components/SettingsDialog.vue
Normal 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>
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
|
||||||
|
@ -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')
|
||||||
|
200
src/plugins/calendarHistory.js
Normal file
200
src/plugins/calendarHistory.js
Normal 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 }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
57
src/plugins/calendarUndoNormalize.js
Normal file
57
src/plugins/calendarUndoNormalize.js
Normal 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
24
src/plugins/persist.js
Normal 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 {}
|
||||||
|
})
|
||||||
|
}
|
331
src/plugins/scrollManager.js
Normal file
331
src/plugins/scrollManager.js
Normal 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
400
src/plugins/virtualWeeks.js
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
@ -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.
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
@ -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
179
src/utils/holidays.js
Normal 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')
|
Loading…
x
Reference in New Issue
Block a user