Compare commits
	
		
			15 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 258d0ba02c | ||
|   | c134d8875c | ||
|   | dca3e21843 | ||
|   | d11c551636 | ||
|   | eaa55c94fd | ||
|   | 0d4094826d | ||
|   | 983826b5a6 | ||
|   | 3a902a9dfa | ||
|   | 0dfccb7b34 | ||
|   | f20a54da57 | ||
|   | b3b19832b4 | ||
|   | 151566ba22 | ||
|   | 7816ccd196 | ||
|   | dee8ce5079 | ||
|   | abc7aba20f | 
| @@ -1,4 +1,4 @@ | ||||
| /* Color tokens */ | ||||
| /* Light mode & common */ | ||||
| :root { | ||||
|   --panel: #ffffff; | ||||
|   --panel-alt: #f6f8fa; | ||||
| @@ -8,19 +8,17 @@ | ||||
|   --strong: #000; | ||||
|   --muted: #6a6f76; | ||||
|   --muted-alt: #9aa2ad; | ||||
|   --accent: #2563eb; /* blue */ | ||||
|   --accent: #2563eb; | ||||
|   --accent-soft: #dbeafe; | ||||
|   --accent-hover: #1d4ed8; | ||||
|   --danger: #dc2626; | ||||
|   --danger-hover: #b91c1c; | ||||
|   --weekend: #888; | ||||
|   --weekend: #555; | ||||
|   --firstday: #000; | ||||
|   --select: #aaf; | ||||
|   --shadow: #fff; | ||||
|   --label-bg: #fafbfe; | ||||
|   --label-bg-rgb: 250, 251, 254; | ||||
|  | ||||
|   /* Holiday colors */ | ||||
|   --holiday: #da0; | ||||
|   --holiday-label: var(--strong); | ||||
|  | ||||
| @@ -35,73 +33,11 @@ | ||||
|   /* Vue component color mappings */ | ||||
|   --bg: var(--panel); | ||||
|   --border-color: #ddd; | ||||
|  | ||||
|   /* Event transparency */ | ||||
|   --event-alpha: 0.7; | ||||
| } | ||||
|  | ||||
| /* Month tints (light) */ | ||||
| .dec { | ||||
|   background: hsl(220 50% 95%); | ||||
| } | ||||
| .jan { | ||||
|   background: hsl(220 50% 92%); | ||||
| } | ||||
| .feb { | ||||
|   background: hsl(220 50% 95%); | ||||
| } | ||||
| .mar { | ||||
|   background: hsl(125 60% 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 */ | ||||
| .event-color-0 { | ||||
|   background: hsl(0, 0%, 85%); | ||||
| } /* lightest grey */ | ||||
| .event-color-1 { | ||||
|   background: hsl(0, 0%, 75%); | ||||
| } /* light grey */ | ||||
| .event-color-2 { | ||||
|   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) */ | ||||
| @media (prefers-color-scheme: dark) { | ||||
|   :root { | ||||
|     --panel: #121417; | ||||
| @@ -138,67 +74,61 @@ | ||||
|     /* Holiday colors (dark mode) */ | ||||
|     --holiday: #ffc107; | ||||
|     --holiday-label: #fff8e1; | ||||
|   } | ||||
|  | ||||
|   .dec { | ||||
|     background: hsl(220 50% 8%); | ||||
|   } | ||||
|   .jan { | ||||
|     background: hsl(220 50% 6%); | ||||
|   } | ||||
|   .feb { | ||||
|     background: hsl(220 50% 8%); | ||||
|   } | ||||
|   .mar { | ||||
|     background: hsl(125 60% 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%); | ||||
|   } | ||||
|     --weekend: #aaa; | ||||
|  | ||||
|   .event-color-0 { | ||||
|     background: hsl(0, 0%, 50%); | ||||
|   } /* lightest grey */ | ||||
|   .event-color-1 { | ||||
|     background: hsl(0, 0%, 40%); | ||||
|   } /* light grey */ | ||||
|   .event-color-2 { | ||||
|     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 */ | ||||
|   } | ||||
| } | ||||
|  | ||||
| /* Month tints (light) */ | ||||
| .dec { background: hsl(220 50% 77%) } | ||||
| .jan { background: hsl(220 50% 60%) } | ||||
| .feb { background: hsl(220 50% 77%) } | ||||
| .mar { background: hsl(130 40% 85%) } | ||||
| .apr { background: hsl(130 65% 75%) } | ||||
| .may { background: hsl(130 80% 65%) } | ||||
| .jun { background: hsl(50 85% 70%) } | ||||
| .jul { background: hsl(50 85% 85%) } | ||||
| .aug { background: hsl(50 85% 70%) } | ||||
| .sep { background: hsl(22 100% 75%) } | ||||
| .oct { background: hsl(22 40% 65%) } | ||||
| .nov { background: hsl(22 15% 55%) } | ||||
|  | ||||
| @media (prefers-color-scheme: dark) { | ||||
|   .dec { background: hsl(220 50% 10%) } | ||||
|   .jan { background: hsl(220 50% 4%) } | ||||
|   .feb { background: hsl(220 50% 10%) } | ||||
|   .mar { background: hsl(130 60% 3%) } | ||||
|   .apr { background: hsl(130 60% 6%) } | ||||
|   .may { background: hsl(130 60% 10%) } | ||||
|   .jun { background: hsl(50 85% 8%) } | ||||
|   .jul { background: hsl(50 85% 12%) } | ||||
|   .aug { background: hsl(50 85% 8%) } | ||||
|   .sep { background: hsl(22 100% 10%) } | ||||
|   .oct { background: hsl(22 90% 6%) } | ||||
|   .nov { background: hsl(22 80% 3%) } | ||||
| } | ||||
|  | ||||
| /* Light mode — gray shades and colors */ | ||||
| .event-color-0 { background: hsla(0, 0%, 85%, var(--event-alpha)); } /* lightest grey */ | ||||
| .event-color-1 { background: hsla(0, 0%, 75%, var(--event-alpha)); } /* light grey */ | ||||
| .event-color-2 { background: hsla(0, 0%, 65%, var(--event-alpha)); } /* medium grey */ | ||||
| .event-color-3 { background: hsla(0, 0%, 55%, var(--event-alpha)); } /* dark grey */ | ||||
| .event-color-4 { background: hsla(0, 100%, 70%, var(--event-alpha)); } /* red */ | ||||
| .event-color-5 { background: hsla(90, 100%, 50%, var(--event-alpha)); } /* green - darker for perceptional purposes */ | ||||
| .event-color-6 { background: hsla(220, 100%, 70%, var(--event-alpha)); } /* blue */ | ||||
| .event-color-7 { background: hsla(280, 100%, 70%, var(--event-alpha)); } /* purple */ | ||||
|  | ||||
| /* Dark-mode event colors are grouped right after the light-mode equivalents */ | ||||
| @media (prefers-color-scheme: dark) { | ||||
|   .event-color-0 { background: hsla(0, 0%, 50%, var(--event-alpha)); } /* lightest grey */ | ||||
|   .event-color-1 { background: hsla(0, 0%, 40%, var(--event-alpha)); } /* light grey */ | ||||
|   .event-color-2 { background: hsla(0, 0%, 30%, var(--event-alpha)); } /* medium grey */ | ||||
|   .event-color-3 { background: hsla(0, 0%, 20%, var(--event-alpha)); } /* dark grey */ | ||||
|   .event-color-4 { background: hsla(0, 80%, 40%, var(--event-alpha)); } /* red */ | ||||
|   .event-color-5 { background: hsla(90, 80%, 30%, var(--event-alpha)); } /* green - darker for perceptional purposes */ | ||||
|   .event-color-6 { background: hsla(220, 80%, 40%, var(--event-alpha)); } /* blue */ | ||||
|   .event-color-7 { background: hsla(280, 80%, 40%, var(--event-alpha)); } /* purple */ | ||||
| } | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -82,17 +82,6 @@ header { | ||||
| #calendar-content { | ||||
|   position: relative; | ||||
| } | ||||
| /* Week row: label + 7-day grid + jogwheel column */ | ||||
| .week-row { | ||||
|   display: grid; | ||||
|   grid-template-columns: var(--week-w) repeat(7, var(--day-w)) var(--month-w); | ||||
|   position: relative; | ||||
|   overflow: visible; | ||||
|   height: var(--row-h); | ||||
|   scroll-snap-align: start; | ||||
|   width: 100%; | ||||
| } | ||||
|  | ||||
| /* Label cells */ | ||||
| .year-label, | ||||
| .week-label { | ||||
| @@ -109,8 +98,8 @@ header { | ||||
| } | ||||
| /* 7-day grid inside each week row */ | ||||
| .week-row > .days-grid { | ||||
|   grid-column: 2 / span 7; | ||||
|   display: grid; | ||||
|   grid-column: 2 / span 7; | ||||
|   grid-template-columns: repeat(7, 1fr); | ||||
|   grid-auto-rows: 1fr; | ||||
|   position: relative; | ||||
|   | ||||
| @@ -1,8 +1,16 @@ | ||||
| <script setup> | ||||
| import { computed } from 'vue' | ||||
| import { formatDateCompact, fromLocalString } from '@/utils/date' | ||||
|  | ||||
| const props = defineProps({ | ||||
|   day: Object, | ||||
|   dragging: { type: Boolean, default: false }, | ||||
| }) | ||||
|  | ||||
| const formattedDate = computed(() => { | ||||
|   const date = fromLocalString(props.day.date) | ||||
|   return formatDateCompact(date) | ||||
| }) | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
| @@ -21,6 +29,7 @@ const props = defineProps({ | ||||
|     ]" | ||||
|     :data-date="props.day.date" | ||||
|   > | ||||
|     <span class="compact-date">{{ formattedDate }}</span> | ||||
|     <h1 class="day-number">{{ props.day.displayText }}</h1> | ||||
|     <span v-if="props.day.lunarPhase" class="lunar-phase">{{ props.day.lunarPhase }}</span> | ||||
|     <div v-if="props.day.holiday" class="holiday-info" dir="auto" :title="props.day.holiday.name"> | ||||
| @@ -32,102 +41,118 @@ const props = defineProps({ | ||||
| <style scoped> | ||||
| .cell { | ||||
|   position: relative; | ||||
|   border-inline-end: 1px solid var(--border-color); | ||||
|   border-bottom: 1px solid var(--border-color); | ||||
|   user-select: none; | ||||
|   display: grid; | ||||
|   /* 3 columns: day number, flexible space, lunar phase */ | ||||
|   grid-template-columns: min-content 1fr min-content; | ||||
|   /* 3 rows: header, flexible filler, holiday label */ | ||||
|   grid-template-rows: auto 1fr auto; | ||||
|   /* Named grid areas (only ones actually used) */ | ||||
|   /* Updated grid for centered day number */ | ||||
|   grid-template-columns: 1fr; | ||||
|   grid-template-rows: 1fr auto; | ||||
|   /* Named grid areas */ | ||||
|   grid-template-areas: | ||||
|     'day-number . lunar-phase' | ||||
|     'day-number . lunar-phase' | ||||
|     'holiday-info holiday-info holiday-info'; | ||||
|   /* Explicit areas mainly for clarity */ | ||||
|   grid-auto-flow: row; | ||||
|     'day-number' | ||||
|     'holiday-info'; | ||||
|   padding: 0.25em; | ||||
|   overflow: hidden; | ||||
|   overflow: visible; | ||||
|   width: 100%; | ||||
|   height: var(--row-h); | ||||
|   font-weight: 700; | ||||
|   transition: background-color 0.15s ease; | ||||
|   align-items: start; | ||||
|   align-items: center; | ||||
|   justify-items: center; | ||||
| } | ||||
| .cell h1.day-number { | ||||
|   margin: 0; | ||||
|   padding: 0; | ||||
|   min-width: 1.5em; | ||||
|   font-size: 1em; | ||||
|   font-weight: 700; | ||||
|   position: absolute; | ||||
|   font-size: 5vmin; | ||||
|   font-weight: 800; | ||||
|   color: var(--ink); | ||||
|   transition: background-color 0.15s ease; | ||||
|   grid-area: day-number; | ||||
|   transition: all 0.15s ease; | ||||
| } | ||||
| .cell.firstday h1.day-number { | ||||
|   font-weight: 400; | ||||
| } | ||||
| .cell.weekend h1.day-number { | ||||
|   color: var(--weekend); | ||||
| } | ||||
| .cell.firstday h1.day-number { | ||||
|   color: var(--firstday); | ||||
|   text-shadow: 0 0 0.1em var(--strong); | ||||
| } | ||||
| .cell.today h1.day-number { | ||||
|   border-radius: 2em; | ||||
|   background: var(--today); | ||||
|   border: 0.2em solid var(--today); | ||||
|   margin: -0.2em; | ||||
|   color: var(--strong); | ||||
|   font-weight: bold; | ||||
| } | ||||
| .cell.selected { | ||||
|   filter: hue-rotate(180deg); | ||||
| .cell.today::before { | ||||
|   content: ''; | ||||
|   position: absolute; | ||||
|   top: 50%; | ||||
|   left: 50%; | ||||
|   transform: translate(-50%, -50%); | ||||
|   width: calc(100% + .2rem); | ||||
|   height: calc(100% + .2rem); | ||||
|   border-radius: 1rem; | ||||
|   background: transparent; | ||||
|   border: 0.3em solid var(--today); | ||||
|   z-index: 15; | ||||
|   pointer-events: none; | ||||
| } | ||||
| .cell.selected h1.day-number { | ||||
|   color: var(--strong); | ||||
|   opacity: 0.3; | ||||
|   filter: brightness(1.2); | ||||
| } | ||||
| .cell.holiday { | ||||
| .cell { | ||||
|   background-image: linear-gradient( | ||||
|     135deg, | ||||
|     var(--holiday-grad-start, rgba(255, 255, 255, 0.5)) 0%, | ||||
|     var(--holiday-grad-start, rgba(255, 255, 255, 0.3)) 0%, | ||||
|     var(--holiday-grad-end, rgba(255, 255, 255, 0)) 70% | ||||
|   ); | ||||
| } | ||||
| @media (prefers-color-scheme: dark) { | ||||
|   .cell.holiday { | ||||
|   .cell { | ||||
|     background-image: linear-gradient( | ||||
|       135deg, | ||||
|       var(--holiday-grad-start, rgba(255, 255, 255, 0.1)) 0%, | ||||
|       var(--holiday-grad-start, rgba(255, 255, 255, 0.05)) 0%, | ||||
|       var(--holiday-grad-end, rgba(255, 255, 255, 0)) 70% | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| .cell.holiday h1.day-number { | ||||
|   /* Slight emphasis without forcing a specific hue */ | ||||
|   color: var(--holiday); | ||||
|   text-shadow: 0 0 0.3em rgba(255, 255, 255, 0.4); | ||||
| } | ||||
| .lunar-phase { | ||||
|   grid-area: lunar-phase; | ||||
|   align-self: start; | ||||
|   justify-self: end; | ||||
|   margin-top: 0.5em; | ||||
|   margin-inline-end: 0.2em; | ||||
|   position: absolute; | ||||
|   inset-block-start: 0.5em; | ||||
|   inset-inline-end: 0.2em; | ||||
|   font-size: 0.8em; | ||||
|   opacity: 0.7; | ||||
| } | ||||
|  | ||||
| .compact-date { | ||||
|   position: absolute; | ||||
|   top: 0.25em; | ||||
|   left: 0.25em; | ||||
|   inset-inline-end: 1rem; /* Space for lunar phase */ | ||||
|   font-weight: 400; | ||||
|   color: var(--ink); | ||||
|   line-height: 1; | ||||
|   pointer-events: none; | ||||
| } | ||||
|  | ||||
| .cell.weekend .compact-date { | ||||
|   color: var(--weekend); | ||||
| } | ||||
| .cell.firstday .compact-date { | ||||
|   color: var(--firstday); | ||||
| } | ||||
| .cell.today .compact-date { | ||||
|   color: var(--strong); | ||||
| } | ||||
| .cell.selected .compact-date { | ||||
|   color: var(--strong); | ||||
| } | ||||
|  | ||||
| .holiday-info { | ||||
|   grid-area: holiday-info; | ||||
|   align-self: end; | ||||
|   overflow: hidden; | ||||
|   text-overflow: ellipsis; | ||||
|   white-space: nowrap; | ||||
|   color: var(--holiday-label); | ||||
|   font-size: clamp(1.2vw, 0.6em, 1em); | ||||
|   line-height: 1; | ||||
|   max-width: 100%; | ||||
|   color: var(--holiday); | ||||
|   font-size: 1em; | ||||
|   font-weight: 400; | ||||
|   line-height: 1.0; | ||||
|   padding-inline: 0.15em; | ||||
|   padding-block-end: 0.05em; | ||||
|   padding-block: 0; | ||||
|   pointer-events: auto; | ||||
| } | ||||
| </style> | ||||
|   | ||||
| @@ -1,9 +1,10 @@ | ||||
| <script setup> | ||||
| import { ref, onMounted, onBeforeUnmount, computed, watch, nextTick } from 'vue' | ||||
| import { ref, onMounted, onBeforeUnmount, computed, watch } from 'vue' | ||||
| import { useCalendarStore } from '@/stores/CalendarStore' | ||||
| import CalendarHeader from '@/components/CalendarHeader.vue' | ||||
| import CalendarWeek from '@/components/CalendarWeek.vue' | ||||
| import HeaderControls from '@/components/HeaderControls.vue' | ||||
| import Jogwheel from '@/components/Jogwheel.vue' | ||||
| import { | ||||
|   createScrollManager, | ||||
|   createWeekColumnScrollManager, | ||||
| @@ -25,7 +26,9 @@ function openCreateEventDialog(eventData) { | ||||
|   // Capture baseline before dialog opens (new event creation flow) | ||||
|   try { | ||||
|     calendarStore.$history?._baselineIfNeeded?.(true) | ||||
|   } catch {} | ||||
|   } catch { | ||||
|     /* noop */ | ||||
|   } | ||||
|   const selectionData = { startDate: eventData.startDate, dayCount: eventData.dayCount } | ||||
|   setTimeout(() => eventDialogRef.value?.openCreateDialog(selectionData), 30) | ||||
| } | ||||
| @@ -33,7 +36,9 @@ function openEditEventDialog(eventClickPayload) { | ||||
|   // Capture baseline before editing existing event | ||||
|   try { | ||||
|     calendarStore.$history?._baselineIfNeeded?.(true) | ||||
|   } catch {} | ||||
|   } catch { | ||||
|     /* noop */ | ||||
|   } | ||||
|   eventDialogRef.value?.openEditDialog(eventClickPayload) | ||||
| } | ||||
| const viewport = ref(null) | ||||
| @@ -41,6 +46,26 @@ const viewportHeight = ref(600) | ||||
| const rowHeight = ref(64) | ||||
| const rowProbe = ref(null) | ||||
| let rowProbeObserver = null | ||||
|  | ||||
| // Scrolling blur effect | ||||
| const blurAmount = ref(0) // pixels | ||||
| let _lastBlurPos = 0 | ||||
| let _blurFrame = null | ||||
|  | ||||
| function _updateMotionBlur() { | ||||
|   const pos = scrollTop.value || 0 | ||||
|   if (_lastBlurPos) { | ||||
|     blurAmount.value = 0.5 * Math.abs(pos - _lastBlurPos) | ||||
|   } | ||||
|   _lastBlurPos = pos | ||||
|   _blurFrame = requestAnimationFrame(_updateMotionBlur) | ||||
| } | ||||
|  | ||||
| const viewportBlurStyle = computed(() => { | ||||
|   return blurAmount.value > 0 | ||||
|     ? { filter: 'url(#cal-vert-blur)', willChange: 'filter' } | ||||
|     : { filter: 'none' } | ||||
| }) | ||||
| const baseDate = computed(() => new Date(1970, 0, 4 + calendarStore.config.first_day)) | ||||
| const selection = ref({ startDate: null, dayCount: 0 }) | ||||
| const isDragging = ref(false) | ||||
| @@ -207,7 +232,7 @@ watch( | ||||
|     calendarStore.config.holidays.state, | ||||
|     calendarStore.config.holidays.region, | ||||
|   ], | ||||
|   (_newVals, _oldVals) => { | ||||
|   () => { | ||||
|     // If weeks already built, just refresh holiday info | ||||
|     if (visibleWeeks.value.length) { | ||||
|       refreshHolidays('config-change') | ||||
| @@ -382,6 +407,10 @@ onMounted(() => { | ||||
|   onBeforeUnmount(() => { | ||||
|     clearInterval(timer) | ||||
|   }) | ||||
|  | ||||
|   // Start motion blur loop | ||||
|   _lastBlurPos = scrollTop.value || 0 | ||||
|   _blurFrame = requestAnimationFrame(_updateMotionBlur) | ||||
| }) | ||||
|  | ||||
| onBeforeUnmount(() => { | ||||
| @@ -393,9 +422,12 @@ onBeforeUnmount(() => { | ||||
|     try { | ||||
|       rowProbeObserver.unobserve(rowProbe.value) | ||||
|       rowProbeObserver.disconnect() | ||||
|     } catch (e) {} | ||||
|     } catch { | ||||
|       /* noop */ | ||||
|     } | ||||
|   } | ||||
|   document.removeEventListener('pointerlockchange', handlePointerLockChange) | ||||
|   if (_blurFrame) cancelAnimationFrame(_blurFrame) | ||||
| }) | ||||
|  | ||||
| const handleDayMouseDown = (d) => { | ||||
| @@ -430,7 +462,9 @@ function scrollToEventStart(startDate, smooth = true) { | ||||
|     const dateObj = fromLocalString(startDate, DEFAULT_TZ) | ||||
|     const weekIndex = getWeekIndex(dateObj) | ||||
|     scrollToWeekCentered(weekIndex, 'search-jump', smooth) | ||||
|   } catch {} | ||||
|   } catch { | ||||
|     /* noop */ | ||||
|   } | ||||
| } | ||||
| function handleHeaderSearchPreview(result) { | ||||
|   if (!result) return | ||||
| @@ -497,6 +531,15 @@ window.addEventListener('resize', () => { | ||||
| <template> | ||||
|   <div class="calendar-view-root" :dir="rtl && 'rtl'"> | ||||
|     <div ref="rowProbe" class="row-height-probe" aria-hidden="true"></div> | ||||
|     <!-- Inline SVG filter for vertical motion blur --> | ||||
|     <svg width="0" height="0" aria-hidden="true" focusable="false" class="motion-blur-defs"> | ||||
|       <defs> | ||||
|         <!-- stdDeviation: x y; keep a tiny epsilon on X so some browsers don't drop the filter entirely --> | ||||
|         <filter id="cal-vert-blur" color-interpolation-filters="sRGB" x="-10%" width="120%" y="-10%" height="120%"> | ||||
|           <feGaussianBlur :stdDeviation="`${0.001} ${blurAmount.toFixed(2)}`" edgeMode="duplicate" /> | ||||
|         </filter> | ||||
|       </defs> | ||||
|     </svg> | ||||
|     <div class="wrap"> | ||||
|       <HeaderControls | ||||
|         :reference-date="centerVisibleDateStr" | ||||
| @@ -511,14 +554,19 @@ window.addEventListener('resize', () => { | ||||
|         @year-change="handleHeaderYearChange" | ||||
|       /> | ||||
|       <div class="calendar-container"> | ||||
|         <div class="calendar-viewport" ref="viewport"> | ||||
|         <div class="calendar-viewport" ref="viewport" :style="viewportBlurStyle"> | ||||
|           <div class="calendar-content" :style="{ height: contentHeight + 'px' }"> | ||||
|             <div | ||||
|               class="weeks-wrapper" | ||||
|               :style="{ | ||||
|                 transform: `translateY(${visibleWeeks.length ? visibleWeeks[0].top : 0}px)`, | ||||
|               }" | ||||
|             > | ||||
|               <CalendarWeek | ||||
|                 v-for="week in visibleWeeks" | ||||
|                 :key="week.virtualWeek" | ||||
|                 :week="week" | ||||
|                 :dragging="isDragging" | ||||
|               :style="{ top: week.top + 'px' }" | ||||
|                 @day-mousedown="handleDayMouseDown" | ||||
|                 @day-mouseenter="handleDayMouseEnter" | ||||
|                 @day-mouseup="handleDayMouseUp" | ||||
| @@ -526,17 +574,22 @@ window.addEventListener('resize', () => { | ||||
|                 @event-click="handleEventClick" | ||||
|               /> | ||||
|             </div> | ||||
|           </div> | ||||
|           <div class="month-column-area" :style="{ height: contentHeight + 'px' }"> | ||||
|             <div class="month-labels-container" :style="{ height: '100%' }"> | ||||
|               <template v-for="monthWeek in visibleWeeks" :key="monthWeek.virtualWeek + '-month'"> | ||||
|               <div | ||||
|                 class="month-labels-wrapper" | ||||
|                 :style="{ | ||||
|                   transform: `translateY(${visibleWeeks.length ? visibleWeeks[0].top : 0}px)`, | ||||
|                   gridTemplateRows: `repeat(${visibleWeeks.length}, var(--row-h))`, | ||||
|                 }" | ||||
|               > | ||||
|                 <template v-for="(monthWeek, i) in visibleWeeks" :key="monthWeek.virtualWeek + '-month'"> | ||||
|                   <div | ||||
|                     v-if="monthWeek && monthWeek.monthLabel" | ||||
|                     class="month-label" | ||||
|                     :class="monthWeek.monthLabel?.monthClass" | ||||
|                   :style="{ | ||||
|                     height: `calc(var(--row-h) * ${monthWeek.monthLabel?.weeksSpan || 1})`, | ||||
|                     top: (monthWeek.top || 0) + 'px', | ||||
|                   }" | ||||
|                     :style="{ gridRow: `${i + 1} / span ${monthWeek.monthLabel?.weeksSpan || 1}` }" | ||||
|                     @pointerdown="handleMonthScrollPointerDown" | ||||
|                     @touchstart.prevent="handleMonthScrollTouchStart" | ||||
|                     @wheel="handleMonthScrollWheel" | ||||
| @@ -550,6 +603,15 @@ window.addEventListener('resize', () => { | ||||
|             </div> | ||||
|           </div> | ||||
|         </div> | ||||
|         <!-- Jogwheel overlay captures drag + wheel over month name column --> | ||||
|         <Jogwheel | ||||
|           :total-virtual-weeks="totalVirtualWeeks" | ||||
|           :row-height="rowHeight" | ||||
|           :viewport-height="viewportHeight" | ||||
|           :scroll-top="scrollTop" | ||||
|           @scroll-to="(v) => setScrollTop(v, 'jogwheel')" | ||||
|         /> | ||||
|       </div> | ||||
|       <EventDialog ref="eventDialogRef" :selection="{ startDate: null, dayCount: 0 }" /> | ||||
|     </div> | ||||
|   </div> | ||||
| @@ -604,6 +666,13 @@ header h1 { | ||||
|   width: 100%; | ||||
| } | ||||
|  | ||||
| .weeks-wrapper { | ||||
|   position: absolute; | ||||
|   inset: 0 auto auto 0; | ||||
|   width: 100%; | ||||
|   will-change: transform; | ||||
| } | ||||
|  | ||||
| .month-column-area { | ||||
|   position: relative; | ||||
|   cursor: ns-resize; | ||||
| @@ -615,18 +684,24 @@ header h1 { | ||||
|   height: 100%; | ||||
| } | ||||
|  | ||||
| .month-label { | ||||
| .month-labels-wrapper { | ||||
|   position: absolute; | ||||
|   inset-inline-start: 0; | ||||
|   inset: 0 auto auto 0; | ||||
|   width: 100%; | ||||
|   background-image: linear-gradient(to bottom, rgba(186, 186, 186, 0.3), rgba(186, 186, 186, 0.2)); | ||||
|   will-change: transform; | ||||
|   display: grid; | ||||
|   grid-auto-flow: row; | ||||
| } | ||||
|  | ||||
| .month-label { | ||||
|   width: 100%; | ||||
|   opacity: 0.8; | ||||
|   font-size: 2em; | ||||
|   font-weight: 700; | ||||
|   color: var(--muted); | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   justify-content: center; | ||||
|   z-index: 15; | ||||
|   z-index: 5; | ||||
|   overflow: hidden; | ||||
|   cursor: ns-resize; | ||||
|   user-select: none; | ||||
|   | ||||
| @@ -33,20 +33,10 @@ const handleDayTouchStart = (dateStr) => { | ||||
| const handleEventClick = (payload) => { | ||||
|   emit('event-click', payload) | ||||
| } | ||||
|  | ||||
| // Only apply upside-down rotation (bottomup) for Latin script month labels | ||||
| function shouldRotateMonth(label) { | ||||
|   if (!label) return false | ||||
|   try { | ||||
|     return /\p{Script=Latin}/u.test(label) | ||||
|   } catch (e) { | ||||
|     return /[A-Za-z\u00C0-\u024F\u1E00-\u1EFF]/u.test(label) | ||||
|   } | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|   <div class="week-row" :style="{ top: `${props.week.top}px` }"> | ||||
|   <div class="week-row"> | ||||
|     <div class="week-label">W{{ props.week.weekNumber }}</div> | ||||
|     <div class="days-grid"> | ||||
|       <CalendarDay | ||||
| @@ -68,7 +58,6 @@ function shouldRotateMonth(label) { | ||||
| .week-row { | ||||
|   display: grid; | ||||
|   grid-template-columns: var(--week-w) repeat(7, 1fr); | ||||
|   position: absolute; | ||||
|   height: var(--row-h); | ||||
|   width: 100%; | ||||
| } | ||||
|   | ||||
| @@ -599,15 +599,15 @@ const recurrenceSummary = computed(() => { | ||||
|         <div class="line compact"> | ||||
|           <Numeric | ||||
|             v-model="displayInterval" | ||||
|             :prefix-values="[{ value: 1, display: 'Every' }]" | ||||
|             :prefix-values="[{ value: 1, display: 'All' }]" | ||||
|             :min="2" | ||||
|             number-prefix="Every " | ||||
|             aria-label="Interval" | ||||
|           /> | ||||
|           <select v-model="displayFrequency" class="freq-select"> | ||||
|             <option value="weeks">{{ displayInterval === 1 ? 'week' : 'weeks' }}</option> | ||||
|             <option value="months">{{ displayInterval === 1 ? 'month' : 'months' }}</option> | ||||
|             <option value="years">{{ displayInterval === 1 ? 'year' : 'years' }}</option> | ||||
|             <option value="weeks">{{ 'weeks' }}</option> | ||||
|             <option value="months">{{ 'months' }}</option> | ||||
|             <option value="years">{{ 'years' }}</option> | ||||
|           </select> | ||||
|           <Numeric | ||||
|             class="occ-stepper" | ||||
|   | ||||
| @@ -3,8 +3,12 @@ | ||||
|     <div | ||||
|       v-for="seg in eventSegments" | ||||
|       :key="'seg-' + seg.startIdx + '-' + seg.endIdx" | ||||
|       :class="['segment-grid', { compress: isSegmentCompressed(seg) }]" | ||||
|       :style="segmentStyle(seg)" | ||||
|       class="segment-grid" | ||||
|       :style="{ | ||||
|         ...segmentStyle(seg), | ||||
|         '--segment-row-height': getSegmentRowHeight(seg), | ||||
|         height: getSegmentTotalHeight(seg) | ||||
|       }" | ||||
|     > | ||||
|       <div | ||||
|         v-for="span in seg.events" | ||||
| @@ -179,8 +183,14 @@ function segmentKey(seg) { | ||||
|   return seg.startIdx + '-' + seg.endIdx | ||||
| } | ||||
|  | ||||
| function isSegmentCompressed(seg) { | ||||
|   return !!segmentCompression.value[segmentKey(seg)] | ||||
| function getSegmentRowHeight(seg) { | ||||
|   const data = segmentCompression.value[segmentKey(seg)] | ||||
|   return data && typeof data.rowHeight === 'number' ? `${data.rowHeight}px` : '1.5em' | ||||
| } | ||||
|  | ||||
| function getSegmentTotalHeight(seg) { | ||||
|   const data = segmentCompression.value[segmentKey(seg)] | ||||
|   return data && typeof data.totalHeight === 'number' ? `${data.totalHeight}px` : 'auto' | ||||
| } | ||||
|  | ||||
| function recomputeCompression() { | ||||
| @@ -190,13 +200,36 @@ function recomputeCompression() { | ||||
|   if (!available) return | ||||
|   const cs = getComputedStyle(el) | ||||
|   const fontSize = parseFloat(cs.fontSize) || 16 | ||||
|   const baseRowPx = fontSize * 1.5 // desired row height (matches CSS 1.5em) | ||||
|   const baseRowPx = fontSize // desired row height (matches CSS 1.5em) | ||||
|   const marginTop = 0 // already applied outside height | ||||
|   const usable = Math.max(0, available - marginTop) | ||||
|   const nextMap = {} | ||||
|  | ||||
|   for (const seg of eventSegments.value) { | ||||
|     const desired = (seg.rowsCount || 1) * baseRowPx | ||||
|     nextMap[segmentKey(seg)] = desired > usable | ||||
|     const rowCount = seg.rowsCount || 1 | ||||
|     const desired = rowCount * baseRowPx | ||||
|     const needsScaling = desired > usable | ||||
|  | ||||
|     // Row height may be reduced to fit segment within available vertical space | ||||
|     let finalRowHeight = baseRowPx | ||||
|     if (needsScaling) { | ||||
|       const scaledRowHeight = usable / rowCount | ||||
|       finalRowHeight = Math.min(scaledRowHeight, baseRowPx) | ||||
|     } | ||||
|  | ||||
|     // Event-level scaling not applied for horizontal fitting in this task | ||||
|     const segmentData = { | ||||
|       rowHeight: finalRowHeight, | ||||
|       totalHeight: needsScaling ? usable : desired, | ||||
|       events: {} | ||||
|     } | ||||
|  | ||||
|     // Populate per-event map (reserved for future use) | ||||
|     for (const event of seg.events) { | ||||
|       segmentData.events[event.id + '-' + (event.n || 0)] = {} | ||||
|     } | ||||
|  | ||||
|     nextMap[segmentKey(seg)] = segmentData | ||||
|   } | ||||
|   segmentCompression.value = nextMap | ||||
| } | ||||
| @@ -537,36 +570,40 @@ function applyRangeDuringDrag(st, startDate, endDate) { | ||||
| } | ||||
| .segment-grid { | ||||
|   display: grid; | ||||
|   gap: 2px; | ||||
|   align-content: start; | ||||
|   pointer-events: none; | ||||
|   overflow: hidden; | ||||
|   grid-auto-columns: 1fr; | ||||
|   grid-auto-rows: 1.5em; | ||||
| } | ||||
| .segment-grid.compress { | ||||
|   grid-auto-rows: 1fr; | ||||
|   grid-auto-rows: var(--segment-row-height); | ||||
| } | ||||
|  | ||||
| .event-span { | ||||
|   padding: 0.1em 0.3em; | ||||
|   border-radius: 1em; | ||||
|   font-size: clamp(0.45em, 1.8vh, 0.75em); | ||||
|   font-weight: 600; | ||||
|   padding: 0; | ||||
|   border-radius: 1rem; | ||||
|   /* Font-size so that ascender+descender exactly fills the row height: | ||||
|     given total = asc+desc at 1em (hardcoded 1.15), font-size = rowHeight / total */ | ||||
|   font-size: calc(var(--segment-row-height, 1.5em) / 1.15); | ||||
|   font-weight: 500; | ||||
|   cursor: grab; | ||||
|   pointer-events: auto; | ||||
|   overflow: hidden; | ||||
|   text-overflow: ellipsis; | ||||
|   white-space: nowrap; | ||||
|   /* Use unitless 1 so line box = font-size; combined with computed font-size above, | ||||
|     this makes the text box (asc+desc) fill the available row height */ | ||||
|   line-height: 1; | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   /* Vertically anchor to top so baselines align across rows; we'll center text vertically by | ||||
|     using cap/descender metrics inside the child */ | ||||
|   align-items: flex-start; | ||||
|   justify-content: center; | ||||
|   position: relative; | ||||
|   user-select: none; | ||||
|   z-index: 1; | ||||
|   z-index: 10; | ||||
|   text-align: center; | ||||
|   /* Ensure touch pointer events aren't turned into a scroll gesture; needed for reliable drag on mobile */ | ||||
|   touch-action: none; | ||||
|   backdrop-filter: blur(.05rem); | ||||
|   max-width: 100%; | ||||
| } | ||||
|  | ||||
| .event-span.cont-prev { | ||||
| @@ -579,17 +616,21 @@ function applyRangeDuringDrag(st, startDate, endDate) { | ||||
|   border-bottom-right-radius: 0; | ||||
| } | ||||
|  | ||||
| /* Inner title wrapper ensures proper ellipsis within flex/grid constraints */ | ||||
| .event-title { | ||||
|   display: block; | ||||
|   flex: 1 1 0%; | ||||
|   flex: 0 1 auto; | ||||
|   min-width: 0; | ||||
|   width: 100%; | ||||
|   height: 100%; | ||||
|   overflow: hidden; | ||||
|   text-overflow: ellipsis; | ||||
|   white-space: nowrap; | ||||
|   text-align: center; | ||||
|   pointer-events: none; | ||||
|   position: relative; | ||||
|   z-index: 1; | ||||
|   max-width: 100%; | ||||
|   line-height: inherit; | ||||
| } | ||||
|  | ||||
| /* Resize handles */ | ||||
| @@ -597,7 +638,7 @@ function applyRangeDuringDrag(st, startDate, endDate) { | ||||
|   position: absolute; | ||||
|   top: 0; | ||||
|   bottom: 0; | ||||
|   width: 6px; | ||||
|   width: 1rem; | ||||
|   background: transparent; | ||||
|   z-index: 2; | ||||
|   cursor: ew-resize; | ||||
|   | ||||
| @@ -8,6 +8,17 @@ | ||||
|           @activate="handleSearchActivate" | ||||
|           @preview="(r) => emit('search-preview', r)" | ||||
|         /> | ||||
|         <div | ||||
|           class="current-time" | ||||
|           aria-label="Current time (click to go to today)" | ||||
|           role="button" | ||||
|           tabindex="-1" | ||||
|           @click="goToToday" | ||||
|           @keydown.enter="goToToday" | ||||
|           @keydown.space.prevent="goToToday" | ||||
|         > | ||||
|           {{ timeString }} | ||||
|         </div> | ||||
|         <div class="today-date" @click="goToToday">{{ todayString }}</div> | ||||
|         <button | ||||
|           type="button" | ||||
| @@ -62,11 +73,40 @@ import SettingsDialog from '@/components/SettingsDialog.vue' | ||||
|  | ||||
| const calendarStore = useCalendarStore() | ||||
|  | ||||
| // Today label: derive from local ticking clock so it flips right at midnight | ||||
| const todayString = computed(() => { | ||||
|   const d = new Date(calendarStore.now) | ||||
|   const d = new Date(localNowMs?.value ?? Date.now()) | ||||
|   return formatTodayString(d) | ||||
| }) | ||||
|  | ||||
| // Local ticking clock: update every second without thrashing global store | ||||
| const localNowMs = ref(Date.now()) | ||||
| let clockTimer = null | ||||
|  | ||||
| onMounted(() => { | ||||
|   // Start a 1s ticker for the header clock (independent from store's minute tick) | ||||
|   clockTimer = setInterval(() => { | ||||
|     localNowMs.value = Date.now() | ||||
|   }, 1000) | ||||
| }) | ||||
|  | ||||
| onBeforeUnmount(() => { | ||||
|   if (clockTimer) clearInterval(clockTimer) | ||||
| }) | ||||
|  | ||||
| // Current time (24h, NBSP padding for single-digit hours, with day/night emoji) | ||||
| const timeString = computed(() => { | ||||
|   const d = new Date(localNowMs.value) | ||||
|   const h = d.getHours() | ||||
|   const m = d.getMinutes() | ||||
|   const hh = h < 10 ? '\u00A0' + h : String(h) | ||||
|   const mm = m < 10 ? '0' + m : String(m) | ||||
|   // Day at 6-18, otherwise night (TODO: sunrise/sunset) | ||||
|   const isDay = h >= 6 && h < 18 | ||||
|   const emoji = isDay ? '🌞' : '🌙' | ||||
|   return `${hh}:${mm}${emoji}` | ||||
| }) | ||||
|  | ||||
| const emit = defineEmits(['go-to-today', 'search-activate', 'search-preview']) | ||||
| const { referenceDate = null } = defineProps({ referenceDate: { type: String, default: null } }) | ||||
|  | ||||
| @@ -98,7 +138,9 @@ function openSettings() { | ||||
|   // Capture baseline before opening settings | ||||
|   try { | ||||
|     calendarStore.$history?._baselineIfNeeded?.(true) | ||||
|   } catch {} | ||||
|   } catch { | ||||
|     /* no-op */ | ||||
|   } | ||||
|   settingsDialog.value?.open() | ||||
| } | ||||
|  | ||||
| @@ -156,22 +198,13 @@ onBeforeUnmount(() => { | ||||
|   position: relative; | ||||
|   display: flex; | ||||
|   align-items: flex-start; | ||||
|   gap: 0.75rem; | ||||
|   padding: 0.4rem 0.5rem 0 0.5rem; | ||||
| } | ||||
| .header-controls { | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   gap: 0.75rem; | ||||
|   width: 100%; | ||||
|   padding-inline-end: 2rem; | ||||
| } | ||||
| .header-controls :deep(.search-bar) { | ||||
|   flex: 1 1 clamp(14rem, 40vw, 30rem); | ||||
|   max-width: clamp(18rem, 40vw, 30rem); | ||||
|   min-width: 12rem; | ||||
|   margin-inline-end: auto; | ||||
| } | ||||
| .toggle-btn { | ||||
|   position: fixed; | ||||
|   top: 0; | ||||
| @@ -276,4 +309,23 @@ onBeforeUnmount(() => { | ||||
|   text-align: center; | ||||
|   margin-inline-end: 2rem; | ||||
| } | ||||
|  | ||||
| .current-time { | ||||
|   font-family: ui-monospace, SF Mono, Consolas, Monaco, "Cascadia Mono", "Segoe UI Mono", "Roboto Mono", "Ubuntu Mono", "Source Code Pro", "Fira Mono", "Droid Sans Mono", "Courier New", monospace; | ||||
|   font-size: 3.6em; | ||||
|   white-space: nowrap; | ||||
|   text-align: center; | ||||
|   cursor: pointer; | ||||
| } | ||||
|  | ||||
| .current-time:hover, | ||||
| .current-time:focus-visible { | ||||
|   color: var(--strong); | ||||
| } | ||||
|  | ||||
| @media (max-width: 700px) { | ||||
|   .current-time { | ||||
|     display: none; | ||||
|   } | ||||
| } | ||||
| </style> | ||||
|   | ||||
| @@ -1,15 +1,7 @@ | ||||
| <template> | ||||
|   <div class="jogwheel-viewport" ref="jogwheelViewport" @scroll="handleJogwheelScroll"> | ||||
|     <div | ||||
|       class="jogwheel-content" | ||||
|       ref="jogwheelContent" | ||||
|       :style="{ height: jogwheelHeight + 'px' }" | ||||
|     ></div> | ||||
|   </div> | ||||
| </template> | ||||
| <template><div class="jogwheel-viewport" ref="jogwheelViewport" /></template> | ||||
|  | ||||
| <script setup> | ||||
| import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue' | ||||
| import { ref, onMounted, onBeforeUnmount } from 'vue' | ||||
|  | ||||
| const props = defineProps({ | ||||
|   totalVirtualWeeks: { type: Number, required: true }, | ||||
| @@ -21,160 +13,66 @@ const props = defineProps({ | ||||
| const emit = defineEmits(['scroll-to']) | ||||
|  | ||||
| const jogwheelViewport = ref(null) | ||||
| const jogwheelContent = 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 | ||||
| let lastClientY = null | ||||
|  | ||||
| // Jogwheel content height is 1/10th of main calendar | ||||
| const jogwheelHeight = computed(() => { | ||||
|   return (props.totalVirtualWeeks * props.rowHeight) / 10 | ||||
| }) | ||||
| const SPEED_DRAG = 4 | ||||
|  | ||||
| const handleJogwheelScroll = () => { | ||||
|   if (syncLock.value === 'jogwheel') return | ||||
|   syncFromJogwheel() | ||||
| } | ||||
| const WEEKS_PER_MONTH = 30.4375 / 7 | ||||
| const MONTH_SCROLL = () => props.rowHeight * WEEKS_PER_MONTH | ||||
| const ANIM_DURATION = 420 // ms | ||||
| let animActive = false | ||||
| let animFrom = 0 | ||||
| let animTo = 0 | ||||
| let animStart = 0 | ||||
| let animFrame = null | ||||
|  | ||||
| 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() | ||||
| } | ||||
| // Drag momentum (independent from month-step animation) | ||||
| let dragMomentumActive = false | ||||
| let dragMomentumFrame = null | ||||
| let dragMomentumVelocity = 0 | ||||
| let dragMomentumPos = 0 | ||||
| const DRAG_FRICTION_PER_MS = 0.0018 | ||||
| const DRAG_MIN_V = 0.03 | ||||
| let dragSamples = [] // { t, s } sampled scroll positions during drag | ||||
|  | ||||
| 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 MIN_WHEEL_ABS = 2 | ||||
| function easeOutCubic(t){return 1-Math.pow(1-t,3)} | ||||
|  | ||||
| function clampScroll(x) { | ||||
|   const maxScroll = Math.max(0, props.totalVirtualWeeks * props.rowHeight - props.viewportHeight) | ||||
|   if (desired > maxScroll) desired = maxScroll | ||||
|   emit('scroll-to', desired) | ||||
|   e.preventDefault() | ||||
|   if (x < 0) return 0 | ||||
|   if (x > maxScroll) return maxScroll | ||||
|   return x | ||||
| } | ||||
|  | ||||
| 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 animateTo(target){target=clampScroll(target);const now=performance.now();if(animActive){const p=Math.min(1,(now-animStart)/ANIM_DURATION);animFrom=animFrom+(animTo-animFrom)*easeOutCubic(p);animTo=target;animStart=now;}else{animFrom=props.scrollTop;animTo=target;animStart=now;animActive=true;animFrame=requestAnimationFrame(stepAnim);return}if(!animFrame)animFrame=requestAnimationFrame(stepAnim)} | ||||
| function stepAnim(){if(!animActive)return;const t=Math.min(1,(performance.now()-animStart)/ANIM_DURATION);const val=animFrom+(animTo-animFrom)*easeOutCubic(t);emit('scroll-to',clampScroll(val));if(t>=1){animActive=false;animFrame=null;return}animFrame=requestAnimationFrame(stepAnim)} | ||||
|  | ||||
| function handlePointerLockChange() { | ||||
|   pointerLocked = document.pointerLockElement === jogwheelViewport.value | ||||
|   if (!pointerLocked && isDragging.value) { | ||||
|     // Pointer lock lost (Esc) -> end drag gracefully | ||||
|     onDragMouseUp(new MouseEvent('mouseup')) | ||||
|   } | ||||
| } | ||||
| function onDragMouseDown(e){if(e.button!==0)return;if(animActive){const now=performance.now();const p=Math.min(1,(now-animStart)/ANIM_DURATION);const cur=animFrom+(animTo-animFrom)*easeOutCubic(p);animActive=false;animFrame&&cancelAnimationFrame(animFrame);animFrame=null;emit('scroll-to',clampScroll(cur));}cancelDragMomentum();isDragging.value=true;mainStartScroll=props.scrollTop;accumDelta=0;lastClientY=e.clientY;dragSamples=[{t:performance.now(),s:mainStartScroll}];if(jogwheelViewport.value&&jogwheelViewport.value.requestPointerLock)jogwheelViewport.value.requestPointerLock();window.addEventListener('mousemove',onDragMouseMove,{passive:false});window.addEventListener('mouseup',onDragMouseUp,{passive:false});e.preventDefault()} | ||||
|  | ||||
| onMounted(() => { | ||||
|   if (jogwheelViewport.value) { | ||||
|     jogwheelViewport.value.addEventListener('mousedown', onDragMouseDown) | ||||
|   } | ||||
|   document.addEventListener('pointerlockchange', handlePointerLockChange) | ||||
| }) | ||||
| function onDragMouseMove(e){if(!isDragging.value) return;let dy=typeof e.movementY==='number'?e.movementY:0;if(!pointerLocked){if(lastClientY!=null)dy=e.clientY-lastClientY;lastClientY=e.clientY;}accumDelta+=dy;let desired=mainStartScroll-accumDelta*SPEED_DRAG;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);dragSamples.push({t:performance.now(),s:desired});e.preventDefault()} | ||||
|  | ||||
| onBeforeUnmount(() => { | ||||
|   if (jogwheelViewport.value) { | ||||
|     jogwheelViewport.value.removeEventListener('mousedown', onDragMouseDown) | ||||
|   } | ||||
|   window.removeEventListener('mousemove', onDragMouseMove) | ||||
|   window.removeEventListener('mouseup', onDragMouseUp) | ||||
|   document.removeEventListener('pointerlockchange', handlePointerLockChange) | ||||
| }) | ||||
| function onDragMouseUp(e){if(!isDragging.value)return;isDragging.value=false;lastClientY=null;window.removeEventListener('mousemove',onDragMouseMove);window.removeEventListener('mouseup',onDragMouseUp);if(pointerLocked&&document.exitPointerLock)document.exitPointerLock();const v=computeDragVelocity();dragSamples=[];if(Math.abs(v)>=DRAG_MIN_V)startDragMomentum(v);e.preventDefault()} | ||||
|  | ||||
| const syncFromJogwheel = () => { | ||||
|   if (!jogwheelViewport.value || !jogwheelContent.value) return | ||||
| function handlePointerLockChange(){pointerLocked=document.pointerLockElement===jogwheelViewport.value;if(!pointerLocked&&isDragging.value)onDragMouseUp(new MouseEvent('mouseup'))} | ||||
|  | ||||
|   syncLock.value = 'main' | ||||
| onMounted(()=>{if(jogwheelViewport.value){jogwheelViewport.value.addEventListener('mousedown',onDragMouseDown);jogwheelViewport.value.addEventListener('wheel',onWheel,{passive:false,capture:true});}document.addEventListener('pointerlockchange',handlePointerLockChange)}) | ||||
|  | ||||
|   const jogScrollable = Math.max( | ||||
|     0, | ||||
|     jogwheelContent.value.scrollHeight - jogwheelViewport.value.clientHeight, | ||||
|   ) | ||||
|   const mainScrollable = Math.max( | ||||
|     0, | ||||
|     props.totalVirtualWeeks * props.rowHeight - props.viewportHeight, | ||||
|   ) | ||||
| onBeforeUnmount(()=>{if(jogwheelViewport.value){jogwheelViewport.value.removeEventListener('mousedown',onDragMouseDown);jogwheelViewport.value.removeEventListener('wheel',onWheel);}window.removeEventListener('mousemove',onDragMouseMove);window.removeEventListener('mouseup',onDragMouseUp);document.removeEventListener('pointerlockchange',handlePointerLockChange)}) | ||||
|  | ||||
|   if (jogScrollable > 0) { | ||||
|     const ratio = jogwheelViewport.value.scrollTop / jogScrollable | ||||
| function onWheel(e){if(e.ctrlKey)return;e.preventDefault();e.stopPropagation();cancelDragMomentum();const dy=e.deltaY;if(Math.abs(dy)<MIN_WHEEL_ABS)return;const dir=dy>0?1:-1;const base=animActive?animTo:props.scrollTop;animateTo(base+dir*MONTH_SCROLL())} | ||||
|  | ||||
|     // Emit scroll event to parent to update main viewport | ||||
|     emit('scroll-to', ratio * mainScrollable) | ||||
|   } | ||||
| // Keep API stable for parent components (previously exposed) | ||||
| function syncFromMain(){};defineExpose({syncFromMain}) | ||||
|  | ||||
|   setTimeout(() => { | ||||
|     if (syncLock.value === 'main') syncLock.value = null | ||||
|   }, 50) | ||||
| } | ||||
|  | ||||
| const syncFromMain = (mainScrollTop) => { | ||||
|   if (!jogwheelViewport.value || !jogwheelContent.value) return | ||||
|   if (syncLock.value === 'main') return | ||||
|  | ||||
|   syncLock.value = 'jogwheel' | ||||
|  | ||||
|   const mainScrollable = Math.max( | ||||
|     0, | ||||
|     props.totalVirtualWeeks * props.rowHeight - props.viewportHeight, | ||||
|   ) | ||||
|   const jogScrollable = Math.max( | ||||
|     0, | ||||
|     jogwheelContent.value.scrollHeight - jogwheelViewport.value.clientHeight, | ||||
|   ) | ||||
|  | ||||
|   if (mainScrollable > 0) { | ||||
|     const ratio = mainScrollTop / mainScrollable | ||||
|     jogwheelViewport.value.scrollTop = ratio * jogScrollable | ||||
|   } | ||||
|  | ||||
|   setTimeout(() => { | ||||
|     if (syncLock.value === 'jogwheel') syncLock.value = null | ||||
|   }, 50) | ||||
| } | ||||
|  | ||||
| // Watch for main calendar scroll changes | ||||
| watch( | ||||
|   () => props.scrollTop, | ||||
|   (newScrollTop) => { | ||||
|     syncFromMain(newScrollTop) | ||||
|   }, | ||||
| ) | ||||
|  | ||||
| defineExpose({ | ||||
|   syncFromMain, | ||||
| }) | ||||
| // ---- Drag Momentum Helpers ---- | ||||
| function cancelDragMomentum(){if(!dragMomentumActive)return;dragMomentumActive=false;dragMomentumFrame&&cancelAnimationFrame(dragMomentumFrame);dragMomentumFrame=null} | ||||
| function computeDragVelocity(){if(dragSamples.length<2)return 0;const now=performance.now();const cutoff=now-80;while(dragSamples.length&&dragSamples[0].t<cutoff)dragSamples.shift();if(dragSamples.length<2)return 0;const first=dragSamples[0],last=dragSamples[dragSamples.length-1],dt=last.t-first.t;if(dt<=8)return 0;return (last.s-first.s)/dt} | ||||
| function startDragMomentum(v){cancelDragMomentum();dragMomentumVelocity=v;dragMomentumPos=props.scrollTop;if(!isFinite(v)||Math.abs(v)<DRAG_MIN_V)return;dragMomentumActive=true;let lastTs=performance.now();const stepM=()=>{if(!dragMomentumActive)return;const now=performance.now(),dt=now-lastTs;lastTs=now;if(dt<=0){dragMomentumFrame=requestAnimationFrame(stepM);return}dragMomentumVelocity*=Math.exp(-DRAG_FRICTION_PER_MS*dt);dragMomentumPos=clampScroll(dragMomentumPos+dragMomentumVelocity*dt);const maxScroll=Math.max(0,props.totalVirtualWeeks*props.rowHeight-props.viewportHeight);if((dragMomentumPos<=0&&dragMomentumVelocity<0)||(dragMomentumPos>=maxScroll&&dragMomentumVelocity>0))dragMomentumVelocity=0;emit('scroll-to',dragMomentumPos);if(Math.abs(dragMomentumVelocity)<DRAG_MIN_V*0.6){cancelDragMomentum();return}dragMomentumFrame=requestAnimationFrame(stepM)};dragMomentumFrame=requestAnimationFrame(stepM)} | ||||
| </script> | ||||
|  | ||||
| <style scoped> | ||||
| @@ -184,19 +82,12 @@ defineExpose({ | ||||
|   inset-inline-end: 0; | ||||
|   bottom: 0; | ||||
|   width: var(--month-w); | ||||
|   overflow-y: auto; | ||||
|   overflow-x: hidden; | ||||
|   scrollbar-width: none; | ||||
|   /* Transparent interactive overlay */ | ||||
|   overflow: hidden; | ||||
|   z-index: 20; | ||||
|   cursor: ns-resize; | ||||
|   overscroll-behavior: contain; | ||||
| } | ||||
|  | ||||
| .jogwheel-viewport::-webkit-scrollbar { | ||||
|   display: none; | ||||
| } | ||||
|  | ||||
| .jogwheel-content { | ||||
|   position: relative; | ||||
|   width: 100%; | ||||
| } | ||||
| .jogwheel-viewport::-webkit-scrollbar { display: none; } | ||||
| </style> | ||||
|   | ||||
| @@ -4,8 +4,8 @@ | ||||
|       ref="searchInputRef" | ||||
|       v-model="searchQuery" | ||||
|       type="search" | ||||
|       placeholder="Date or event..." | ||||
|       aria-label="Search date and events" | ||||
|       placeholder="Date or Event..." | ||||
|       aria-label="Search dates, holidays and events" | ||||
|       @keydown="handleSearchKeydown" | ||||
|     /> | ||||
|     <ul | ||||
| @@ -22,8 +22,8 @@ | ||||
|         role="option" | ||||
|         @mousedown.prevent="selectResult(i)" | ||||
|       > | ||||
|         <span class="title">{{ r.title }}</span> | ||||
|         <span class="date">{{ r.startDate }}</span> | ||||
|         <span class="title">{{ r.title }}</span | ||||
|         ><span class="date">{{ r.startDate }}</span> | ||||
|       </li> | ||||
|     </ul> | ||||
|     <div v-else-if="searchQuery.trim() && !searchResults.length" class="search-empty"> | ||||
| @@ -33,7 +33,7 @@ | ||||
| </template> | ||||
|  | ||||
| <script setup> | ||||
| import { ref, watch, nextTick, computed, defineExpose } from 'vue' | ||||
| import { ref, watch, nextTick, computed, defineExpose, onUnmounted, onMounted } from 'vue' | ||||
| import { useCalendarStore } from '@/stores/CalendarStore' | ||||
| import { | ||||
|   fromLocalString, | ||||
| @@ -44,10 +44,11 @@ import { | ||||
|   getMondayOfISOWeek, | ||||
|   formatTodayString, | ||||
|   makeTZDate, | ||||
|   getISOWeek, | ||||
| } from '@/utils/date' | ||||
| import { addDays } from 'date-fns' | ||||
| import { getDate as getRecurDate, getNearestOccurrence } from '@/utils/events' | ||||
| import * as dateFns from 'date-fns' | ||||
| import { getDate as getNearestOccurrence } from '@/utils/events' | ||||
| import { getHolidaysForYear } from '@/utils/holidays' | ||||
|  | ||||
| const emit = defineEmits(['activate', 'preview']) | ||||
| const props = defineProps({ referenceDate: { type: String, default: null } }) | ||||
| @@ -57,57 +58,122 @@ const searchQuery = ref('') | ||||
| const searchResults = ref([]) | ||||
| const searchIndex = ref(0) | ||||
| const searchInputRef = ref(null) | ||||
| let previewTimer = null | ||||
|  | ||||
| function buildSearchResults() { | ||||
| // Accent-insensitive lowercasing | ||||
| const norm = (s) => | ||||
|   s | ||||
|     .normalize('NFD') | ||||
|     .replace(/\p{Diacritic}/gu, '') | ||||
|     .toLowerCase() | ||||
| let lastQuery = '' | ||||
| let frozenRefStr = null // reference date frozen at last query change | ||||
| const YEAR_SCAN_OFFSETS = [-4, -3, -2, -1, 0, 1, 2, 3, 4] | ||||
| function buildSearchResults(queryChanged = false) { | ||||
|   const raw = searchQuery.value.trim() | ||||
|   const q = raw.toLowerCase() | ||||
|   if (!q) { | ||||
|   if (!raw) { | ||||
|     searchResults.value = [] | ||||
|     searchIndex.value = 0 | ||||
|     lastQuery = raw | ||||
|     return | ||||
|   } | ||||
|   const listAll = raw === '*' | ||||
|   const search = norm(raw) | ||||
|   const out = [] | ||||
|   // Reference date: prefer viewport anchor (date-only) else 'now'. Normalize to midnight local. | ||||
|   let refStr = props.referenceDate || calendarStore.today || calendarStore.now | ||||
|   // If it's full ISO (with time), slice date portion. | ||||
|   if (refStr.includes('T')) refStr = refStr.slice(0, 10) | ||||
|   let refStrLive = props.referenceDate || calendarStore.today || calendarStore.now | ||||
|   if (refStrLive.includes('T')) refStrLive = refStrLive.slice(0, 10) | ||||
|   if (queryChanged || !frozenRefStr) frozenRefStr = refStrLive | ||||
|   const refStr = frozenRefStr | ||||
|   const nowDate = fromLocalString(refStr, DEFAULT_TZ) | ||||
|   for (const ev of calendarStore.events.values()) { | ||||
|     const title = (ev.title || '').trim() | ||||
|     if (!title) continue | ||||
|     if (!(listAll || title.toLowerCase().includes(q))) continue | ||||
|     const title = '⚜️ ' + (ev.title || '').trim() | ||||
|     if (!(listAll || norm(title).includes(search))) continue | ||||
|     let displayStart = ev.startDate | ||||
|     if (ev.recur) { | ||||
|       const nearest = getNearestOccurrence(ev, refStr, DEFAULT_TZ) | ||||
|       if (nearest && nearest.dateStr) displayStart = nearest.dateStr | ||||
|       if (nearest?.dateStr) displayStart = nearest.dateStr | ||||
|     } | ||||
|     out.push({ id: ev.id, title, startDate: displayStart }) | ||||
|   } | ||||
|   if (calendarStore.config?.holidays?.enabled) { | ||||
|     try { | ||||
|       calendarStore._ensureHolidaysInitialized?.() | ||||
|       const refYear = nowDate.getFullYear() | ||||
|       const yearWindow = YEAR_SCAN_OFFSETS.map((o) => refYear + o) | ||||
|       const bestByName = Object.create(null) | ||||
|       for (const yr of yearWindow) { | ||||
|         for (const h of getHolidaysForYear(yr) || []) { | ||||
|           const name = (h.name || '').trim().split(/\s*\/\s*/)[0] | ||||
|           if (!name) continue | ||||
|           if (!listAll && !norm(name).includes(search)) continue | ||||
|           let dateObj | ||||
|           try { | ||||
|             dateObj = new Date(h.date) | ||||
|           } catch { | ||||
|             dateObj = null | ||||
|           } | ||||
|           if (!dateObj || isNaN(dateObj)) continue | ||||
|           const diff = Math.abs(dateObj - nowDate) | ||||
|           const key = name.toLowerCase() | ||||
|           const prev = bestByName[key] | ||||
|           if (!prev || diff < prev.diff) bestByName[key] = { name, dateObj, diff } | ||||
|         } | ||||
|       } | ||||
|       for (const key in bestByName) { | ||||
|         const { name, dateObj } = bestByName[key] | ||||
|         const dateStr = toLocalString(dateObj, DEFAULT_TZ) | ||||
|         out.push({ | ||||
|           id: '__holiday__' + dateStr + ':' + key, | ||||
|           title: `✨ ${name}`, | ||||
|           startDate: dateStr, | ||||
|           _holiday: true, | ||||
|           _dupeKey: '__holiday__' + dateStr + ':' + key, | ||||
|         }) | ||||
|       } | ||||
|     } catch (e) { | ||||
|       if (process.env.NODE_ENV !== 'production') console.debug('[Search] holiday search skipped', e) | ||||
|     } | ||||
|   } | ||||
|   if (queryChanged) { | ||||
|     out.sort((a, b) => (a.startDate < b.startDate ? -1 : a.startDate > b.startDate ? 1 : 0)) | ||||
|   // Inject Go To Date option if query matches a date pattern (first item) | ||||
|   const gotoDateStr = parseGoToDateCandidate(raw) | ||||
|   } else if (searchResults.value.length) { | ||||
|     const order = new Map(searchResults.value.map((r, i) => [r.id, i])) | ||||
|     out.sort((a, b) => { | ||||
|       const ai = order.has(a.id) ? order.get(a.id) : 1e9 | ||||
|       const bi = order.has(b.id) ? order.get(b.id) : 1e9 | ||||
|       if (ai !== bi) return ai - bi | ||||
|       return a.startDate < b.startDate ? -1 : a.startDate > b.startDate ? 1 : 0 | ||||
|     }) | ||||
|   } | ||||
|   const gotoDateStr = parseGoToDateCandidate(raw, refStr) | ||||
|   if (gotoDateStr) { | ||||
|     const dateObj = fromLocalString(gotoDateStr, DEFAULT_TZ) | ||||
|     const label = formatTodayString(dateObj).replace(/\n+/g, ' ') | ||||
|     out.unshift({ id: '__goto__' + gotoDateStr, title: label, startDate: gotoDateStr, _goto: true }) | ||||
|     out.unshift({ | ||||
|       id: '__goto__' + gotoDateStr, | ||||
|       title: '📅 ' + formatTodayString(dateObj), | ||||
|       startDate: gotoDateStr, | ||||
|       _goto: true, | ||||
|     }) | ||||
|   } | ||||
|   searchResults.value = out | ||||
|   if (searchIndex.value >= out.length) searchIndex.value = 0 | ||||
|   lastQuery = raw | ||||
| } | ||||
|  | ||||
| watch(searchQuery, buildSearchResults) | ||||
| watch(searchQuery, (nv, ov) => { | ||||
|   buildSearchResults(nv.trim() !== lastQuery) | ||||
| }) | ||||
| watch( | ||||
|   () => calendarStore.events, | ||||
|   () => { | ||||
|     if (searchQuery.value.trim()) buildSearchResults() | ||||
|     if (searchQuery.value.trim()) buildSearchResults(false) | ||||
|   }, | ||||
|   { deep: true }, | ||||
| ) | ||||
| watch( | ||||
|   () => props.referenceDate, | ||||
|   () => { | ||||
|     if (searchQuery.value.trim()) buildSearchResults() | ||||
|     if (searchQuery.value.trim()) buildSearchResults(false) | ||||
|   }, | ||||
| ) | ||||
|  | ||||
| @@ -124,19 +190,38 @@ function navigate(delta) { | ||||
|   const n = searchResults.value.length | ||||
|   if (!n) return | ||||
|   searchIndex.value = (searchIndex.value + delta + n) % n | ||||
|   // Ensure active item is visible | ||||
|   const r = searchResults.value[searchIndex.value] | ||||
|   if (r) emit('preview', r) | ||||
|   if (r) { | ||||
|     const el = document.getElementById('sr-' + r.id) | ||||
|     if (el) el.scrollIntoView({ block: 'nearest' }) | ||||
|   } | ||||
|   if (previewTimer) clearTimeout(previewTimer) | ||||
|   if (r) | ||||
|     previewTimer = setTimeout(() => { | ||||
|       if (r === searchResults.value[searchIndex.value]) emit('preview', r) | ||||
|     }, 200) | ||||
| } | ||||
| function selectResult(idx) { | ||||
|   searchIndex.value = idx | ||||
|   const r = searchResults.value[searchIndex.value] | ||||
|   if (r) { | ||||
|     if (previewTimer) { | ||||
|       clearTimeout(previewTimer) | ||||
|       previewTimer = null | ||||
|     } | ||||
|     emit('activate', r) | ||||
|     // Clear query after activation (auto-close handled by parent visibility) | ||||
|     searchQuery.value = '' | ||||
|   } | ||||
| } | ||||
| function handleSearchKeydown(e) { | ||||
|   if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === 'f') { | ||||
|     e.preventDefault() | ||||
|     e.stopPropagation() | ||||
|     focusSearch(true) | ||||
|     return | ||||
|   } | ||||
|   if (e.key === 'ArrowDown') { | ||||
|     e.preventDefault() | ||||
|     navigate(1) | ||||
| @@ -163,122 +248,220 @@ const activeResultId = computed(() => { | ||||
| }) | ||||
|  | ||||
| defineExpose({ focusSearch }) | ||||
| onUnmounted(() => { | ||||
|   if (previewTimer) clearTimeout(previewTimer) | ||||
| }) | ||||
| // global Ctrl/Cmd+F -> search | ||||
| let globalFindHandler = null | ||||
| onMounted(() => { | ||||
|   globalFindHandler = (e) => { | ||||
|     if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === 'f') { | ||||
|       e.preventDefault() | ||||
|       e.stopPropagation() | ||||
|       focusSearch(true) | ||||
|     } | ||||
|   } | ||||
|   window.addEventListener('keydown', globalFindHandler, { capture: true }) | ||||
| }) | ||||
| onUnmounted(() => { | ||||
|   if (globalFindHandler) { | ||||
|     window.removeEventListener('keydown', globalFindHandler, { capture: true }) | ||||
|     globalFindHandler = null | ||||
|   } | ||||
| }) | ||||
|  | ||||
| function parseGoToDateCandidate(input) { | ||||
| function parseGoToDateCandidate(input, refStr) { | ||||
|   const s = input.trim() | ||||
|   if (!s) return null | ||||
|   const today = new Date() | ||||
|   const currentYear = today.getFullYear() | ||||
|   const base = refStr ? fromLocalString(refStr, DEFAULT_TZ) : new Date(), | ||||
|     baseYear = base.getFullYear() | ||||
|   // now/today -> system date | ||||
|   if (/^(now|today)$/i.test(s)) { | ||||
|     const sys = new Date() | ||||
|     return toLocalString( | ||||
|       makeTZDate(sys.getFullYear(), sys.getMonth(), sys.getDate(), DEFAULT_TZ), | ||||
|       DEFAULT_TZ, | ||||
|     ) | ||||
|   } | ||||
|   const localized = Array.from({ length: 12 }, (_, i) => getLocalizedMonthName(i)) | ||||
|   function monthFromToken(tok) { | ||||
|   const monthFromToken = (tok) => { | ||||
|     if (!tok) return null | ||||
|     const t = tok.toLowerCase() | ||||
|     if (/^\d{1,2}$/.test(t)) { | ||||
|       const n = +t | ||||
|     const tNorm = norm(tok.trim()) | ||||
|     if (/^\d{1,2}$/.test(tok)) { | ||||
|       const n = +tok | ||||
|       return n >= 1 && n <= 12 ? n : null | ||||
|     } | ||||
|     for (let i = 0; i < 12; i++) { | ||||
|       const ab = monthAbbr[i] | ||||
|       if (t === ab || t === ab.slice(0, 3)) return i + 1 | ||||
|       if (norm(monthAbbr[i]) === tNorm || norm(monthAbbr[i].slice(0, 3)) === tNorm) return i + 1 | ||||
|     } | ||||
|     for (let i = 0; i < 12; i++) { | ||||
|       const full = localized[i].toLowerCase() | ||||
|       if (t === full || full.startsWith(t)) return i + 1 | ||||
|       const full = norm(localized[i]) | ||||
|       if (full === tNorm || full.startsWith(tNorm)) return i + 1 | ||||
|     } | ||||
|     return null | ||||
|   } | ||||
|   // ISO full date or year-month (defaults day=1) | ||||
|   let mIsoFull = s.match(/^(\d{4})-(\d{2})-(\d{2})$/) | ||||
|   if (mIsoFull) { | ||||
|     const y = +mIsoFull[1], | ||||
|       m = +mIsoFull[2], | ||||
|       d = +mIsoFull[3] | ||||
|     const dt = makeTZDate(y, m - 1, d, DEFAULT_TZ) | ||||
|     return toLocalString(dt, DEFAULT_TZ) | ||||
|   // month token -> 15th of nearest year | ||||
|   const soleMonth = s.match(/^(\p{L}+)[.,;:]?$/u) | ||||
|   if (soleMonth) { | ||||
|     const rawMonthTok = soleMonth[1] | ||||
|     const m = monthFromToken(rawMonthTok) | ||||
|     if (m) { | ||||
|       let bestYear = baseYear | ||||
|       let best = Infinity | ||||
|       for (const cand of YEAR_SCAN_OFFSETS.map((o) => baseYear + o)) { | ||||
|         const mid = new Date(cand, m - 1, 15) | ||||
|         const diff = Math.abs(mid - base) | ||||
|         if (diff < best) { | ||||
|           best = diff | ||||
|           bestYear = cand | ||||
|         } | ||||
|   let mIsoMonth = s.match(/^(\d{4})[-/](\d{2})$/) | ||||
|   if (mIsoMonth) { | ||||
|     const y = +mIsoMonth[1], | ||||
|       m = +mIsoMonth[2] | ||||
|     const dt = makeTZDate(y, m - 1, 1, DEFAULT_TZ) | ||||
|     return toLocalString(dt, DEFAULT_TZ) | ||||
|       } | ||||
|   // ISO week | ||||
|   const mWeek = s.match(/^(\d{4})-W(\d{1,2})$/i) | ||||
|   if (mWeek) { | ||||
|     const wy = +mWeek[1], | ||||
|       w = +mWeek[2] | ||||
|     if (w >= 1 && w <= 53) { | ||||
|       const jan4 = new Date(Date.UTC(wy, 0, 4)) | ||||
|       const target = addDays(jan4, (w - 1) * 7) | ||||
|       return toLocalString(makeTZDate(bestYear, m - 1, 15, DEFAULT_TZ), DEFAULT_TZ) | ||||
|     } | ||||
|   } | ||||
|   const isoFull = s.match(/^(\d{4})-(\d{2})-(\d{2})$/) | ||||
|   if (isoFull) { | ||||
|     const y = +isoFull[1], | ||||
|       mm = +isoFull[2], | ||||
|       d = +isoFull[3] | ||||
|     return toLocalString(makeTZDate(y, mm - 1, d, DEFAULT_TZ), DEFAULT_TZ) | ||||
|   } | ||||
|   // wNN -> Monday of nearest ISO week | ||||
|   const weekOnly = s.match(/^w(\d{1,2})[.,;:]?$/i) | ||||
|   if (weekOnly) { | ||||
|     const wk = +weekOnly[1] | ||||
|     if (wk >= 1 && wk <= 53) { | ||||
|       const has53Weeks = (year) => getISOWeek(makeTZDate(year, 11, 28, DEFAULT_TZ)) === 53 | ||||
|       let bestYear = baseYear, | ||||
|         bestDiff = Infinity, | ||||
|         bestDate = null | ||||
|       for (const off of YEAR_SCAN_OFFSETS) { | ||||
|         const cand = baseYear + off | ||||
|         if (wk === 53 && !has53Weeks(cand)) continue | ||||
|         const jan4 = makeTZDate(cand, 0, 4, DEFAULT_TZ) | ||||
|         const target = addDays(jan4, (wk - 1) * 7) | ||||
|         const monday = getMondayOfISOWeek(target) | ||||
|       return toLocalString(monday, DEFAULT_TZ) | ||||
|         const diff = Math.abs(monday - base) | ||||
|         if (diff < bestDiff) { | ||||
|           bestDiff = diff | ||||
|           bestYear = cand | ||||
|           bestDate = monday | ||||
|         } | ||||
|       } | ||||
|       if (bestDate) return toLocalString(bestDate, DEFAULT_TZ) | ||||
|     } | ||||
|   } | ||||
|   const isoMonth = s.match(/^(\d{4})[-/](\d{2})$/) | ||||
|   if (isoMonth) { | ||||
|     const y = +isoMonth[1], | ||||
|       mm = +isoMonth[2] | ||||
|     return toLocalString(makeTZDate(y, mm - 1, 15, DEFAULT_TZ), DEFAULT_TZ) | ||||
|   } | ||||
|   // year+week variants | ||||
|   let isoWeek = s.match(/^(\d{4})[-/]?w(\d{1,2})$/i) | ||||
|   if (!isoWeek) isoWeek = s.match(/^w(\d{1,2})[-/]?(\d{4})$/i) | ||||
|   if (!isoWeek) isoWeek = s.match(/^(\d{4})\s+w(\d{1,2})$/i) | ||||
|   if (!isoWeek) isoWeek = s.match(/^w(\d{1,2})\s+(\d{4})$/i) | ||||
|   if (isoWeek) { | ||||
|     const wy = +isoWeek[1] | ||||
|     const w = +isoWeek[2] | ||||
|     if (w >= 1 && w <= 53) { | ||||
|       if (w === 53 && getISOWeek(makeTZDate(wy, 11, 28, DEFAULT_TZ)) !== 53) return null | ||||
|       const jan4 = makeTZDate(wy, 0, 4, DEFAULT_TZ) | ||||
|       const target = addDays(jan4, (w - 1) * 7) | ||||
|       return toLocalString(getMondayOfISOWeek(target), DEFAULT_TZ) | ||||
|     } | ||||
|     return null | ||||
|   } | ||||
|   // Dotted: day.month[.year] or day.month. (trailing dot) or day.month.year. | ||||
|   let d = null, | ||||
|     m = null, | ||||
|     y = null | ||||
|   let mDot = s.match(/^(\d{1,2})\.([A-Za-z]+|\d{1,2})(?:\.(\d{4}))?\.?$/) | ||||
|   if (mDot) { | ||||
|     d = +mDot[1] | ||||
|     m = monthFromToken(mDot[2]) | ||||
|     y = mDot[3] ? +mDot[3] : currentYear | ||||
|     y = null, | ||||
|     yearExplicit = false | ||||
|   const dot = s.match(/^(\d{1,2})\.([\p{L}]+|\d{1,2})(?:\.(\d{4}))?\.?$/u) | ||||
|   if (dot) { | ||||
|     d = +dot[1] | ||||
|     m = monthFromToken(dot[2]) | ||||
|     if (dot[3]) { | ||||
|       y = +dot[3] | ||||
|       yearExplicit = true | ||||
|     } | ||||
|   } | ||||
|   if (m == null) { | ||||
|     const usFull = s.match(/^([\p{L}]+|\d{1,2})\/(\d{1,2})\/(\d{4})$/u) | ||||
|     if (usFull) { | ||||
|       m = monthFromToken(usFull[1]) | ||||
|       d = +usFull[2] | ||||
|       y = +usFull[3] | ||||
|       yearExplicit = true | ||||
|     } else { | ||||
|     // Slash month/day(/year) (month accepts names); year optional -> current year | ||||
|     let mUSFull = s.match(/^([A-Za-z]+|\d{1,2})\/(\d{1,2})\/(\d{4})$/) | ||||
|     if (mUSFull) { | ||||
|       m = monthFromToken(mUSFull[1]) | ||||
|       d = +mUSFull[2] | ||||
|       y = +mUSFull[3] | ||||
|     } else { | ||||
|       let mUSShort = s.match(/^([A-Za-z]+|\d{1,2})\/(\d{1,2})$/) | ||||
|       if (mUSShort) { | ||||
|         m = monthFromToken(mUSShort[1]) | ||||
|         d = +mUSShort[2] | ||||
|         y = currentYear | ||||
|       const usShort = s.match(/^([\p{L}]+|\d{1,2})\/(\d{1,2})$/u) | ||||
|       if (usShort) { | ||||
|         m = monthFromToken(usShort[1]) | ||||
|         d = +usShort[2] | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|   // Free-form with spaces: tokens containing month names and numbers | ||||
|   if (!y && !m && !d) { | ||||
|   if (m == null) { | ||||
|     const tokens = s.split(/[ ,]+/).filter(Boolean) | ||||
|     if (tokens.length >= 2 && tokens.length <= 3) { | ||||
|       // Prefer a token with letters as month over numeric month | ||||
|       let monthIdx = tokens.findIndex((t) => /[a-zA-Z]/.test(t) && monthFromToken(t) != null) | ||||
|       let monthIdx = tokens.findIndex((t) => /\p{L}/u.test(t) && monthFromToken(t) != null) | ||||
|       if (monthIdx === -1) monthIdx = tokens.findIndex((t) => monthFromToken(t) != null) | ||||
|       if (monthIdx !== -1) { | ||||
|         const monthTok = tokens[monthIdx] | ||||
|         const monthTokIsNum = /^\d{1,2}$/.test(monthTok) | ||||
|         const hasNonMonthLetter = tokens.some( | ||||
|           (t, i) => i !== monthIdx && /\p{L}/u.test(t) && monthFromToken(t) == null, | ||||
|         ) | ||||
|         const otherNumeric = tokens.some((t, i) => i !== monthIdx && /^\d{1,2}$/.test(t)) | ||||
|         if (monthTokIsNum && hasNonMonthLetter && !otherNumeric) { | ||||
|           monthIdx = -1 | ||||
|         } | ||||
|       } | ||||
|       if (monthIdx !== -1) { | ||||
|         m = monthFromToken(tokens[monthIdx]) | ||||
|         const others = tokens.filter((_t, i) => i !== monthIdx) | ||||
|         const others = tokens.filter((_, i) => i !== monthIdx) | ||||
|         let dayExplicit = false | ||||
|         for (const rawTok of others) { | ||||
|           const tok = rawTok.replace(/^[.,;:]+|[.,;:]+$/g, '') // trim punctuation | ||||
|           const tok = rawTok.replace(/^[.,;:]+|[.,;:]+$/g, '') | ||||
|           if (!tok) continue | ||||
|           if (/^\d+$/.test(tok)) { | ||||
|             const num = +tok | ||||
|             if (num > 100) { | ||||
|               y = num | ||||
|               yearExplicit = true | ||||
|             } else if (!d) { | ||||
|               d = num | ||||
|               dayExplicit = true | ||||
|             } | ||||
|           } else if (!y && /^\d{4}[.,;:]?$/.test(tok)) { | ||||
|             // salvage year with trailing punctuation | ||||
|             const num = parseInt(tok, 10) | ||||
|             if (num > 1000) y = num | ||||
|             if (num > 1000) { | ||||
|               y = num | ||||
|               yearExplicit = true | ||||
|             } | ||||
|           } | ||||
|         if (!y) y = currentYear | ||||
|         // Only default day=1 if user didn't provide any day-ish numeric token | ||||
|         if (!d && !dayExplicit) d = 1 | ||||
|         } | ||||
|         if (!d && !dayExplicit) d = 15 | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|   if (m != null && d != null && !yearExplicit) { | ||||
|     let bestYear = baseYear, | ||||
|       bestDiff = Infinity | ||||
|     for (const cand of YEAR_SCAN_OFFSETS.map((o) => baseYear + o)) { | ||||
|       const dt = new Date(cand, m - 1, d) | ||||
|       if (dt.getMonth() !== m - 1) continue | ||||
|       const diff = Math.abs(dt - base) | ||||
|       if (diff < bestDiff) { | ||||
|         bestDiff = diff | ||||
|         bestYear = cand | ||||
|       } | ||||
|     } | ||||
|     y = bestYear | ||||
|   } | ||||
|   if (y != null && m != null && d != null) { | ||||
|     if (y < 1000 || y > 9999 || m < 1 || m > 12 || d < 1 || d > 31) return null | ||||
|     const dt = makeTZDate(y, m - 1, d, DEFAULT_TZ) | ||||
|     return toLocalString(dt, DEFAULT_TZ) | ||||
|     return toLocalString(makeTZDate(y, m - 1, d, DEFAULT_TZ), DEFAULT_TZ) | ||||
|   } | ||||
|   return null | ||||
| } | ||||
| @@ -286,20 +469,18 @@ function parseGoToDateCandidate(input) { | ||||
|  | ||||
| <style scoped> | ||||
| .search-bar { | ||||
|   flex: 0 1 20rem; | ||||
|   margin-inline: auto; /* center with equal free-space on both sides */ | ||||
|   position: relative; | ||||
|   min-width: 14rem; | ||||
|   flex: 1 1 clamp(14rem, 40vw, 30rem); | ||||
|   max-width: clamp(18rem, 40vw, 30rem); | ||||
|   min-width: 12rem; | ||||
| } | ||||
| .search-bar input { | ||||
|   width: 100%; | ||||
|   padding: 0.32rem 0.5rem; | ||||
|   padding-inline-start: 2.05rem; /* increased space for icon */ | ||||
|   border-radius: 0.45rem; | ||||
|   border: 1px solid color-mix(in srgb, var(--muted) 35%, transparent); | ||||
|   background: color-mix(in srgb, var(--panel) 88%, transparent); | ||||
|   font: inherit; | ||||
|   font-size: 0.8rem; | ||||
|   line-height: 1.1; | ||||
|   color: var(--ink); | ||||
|   outline: none; | ||||
| @@ -308,6 +489,18 @@ function parseGoToDateCandidate(input) { | ||||
|     box-shadow 0.15s ease, | ||||
|     background 0.2s; | ||||
| } | ||||
| .search-bar::before { | ||||
|   content: '🔍'; | ||||
|   position: absolute; | ||||
|   inset-inline-start: 0.55rem; | ||||
|   top: 50%; | ||||
|   transform: translateY(-50%); | ||||
|   font-size: 0.85rem; | ||||
|   pointer-events: none; | ||||
|   opacity: 0.75; | ||||
|   line-height: 1; | ||||
|   filter: saturate(0.8); | ||||
| } | ||||
| .search-bar input:focus-visible { | ||||
|   border-color: color-mix(in srgb, var(--accent, #4b7) 70%, transparent); | ||||
|   box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent, #4b7) 45%, transparent); | ||||
|   | ||||
| @@ -192,6 +192,17 @@ function formatTodayString(date) { | ||||
|   return formatted.charAt(0).toUpperCase() + formatted.slice(1) | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Format date as compact string for day cell corner (e.g., "Mon 15 Jan") | ||||
|  */ | ||||
| function formatDateCompact(date) { | ||||
|   return date.toLocaleDateString(undefined, {  | ||||
|     weekday: 'short',  | ||||
|     day: 'numeric',  | ||||
|     month: 'short' | ||||
|   }) | ||||
| } | ||||
|  | ||||
| export { | ||||
|   // constants | ||||
|   monthAbbr, | ||||
| @@ -218,6 +229,7 @@ export { | ||||
|   formatDateRange, | ||||
|   formatDateShort, | ||||
|   formatDateLong, | ||||
|   formatDateCompact, | ||||
|   formatTodayString, | ||||
|   lunarPhaseSymbol, | ||||
|   // iso helpers re-export | ||||
|   | ||||
		Reference in New Issue
	
	Block a user