Compare commits
	
		
			20 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | e210babe29 | ||
|  | 31c5551535 | ||
|  | 9b2354fd91 | ||
|  | 43aa8db650 | ||
|  | debeececaf | ||
|  | 258d0ba02c | ||
|  | c134d8875c | ||
|  | dca3e21843 | ||
|  | d11c551636 | ||
|  | eaa55c94fd | ||
|  | 0d4094826d | ||
|  | 983826b5a6 | ||
|  | 3a902a9dfa | ||
|  | 0dfccb7b34 | ||
|  | f20a54da57 | ||
|  | b3b19832b4 | ||
|  | 151566ba22 | ||
|  | 7816ccd196 | ||
|  | dee8ce5079 | ||
|  | abc7aba20f | 
| @@ -37,8 +37,6 @@ onMounted(() => { | |||||||
|   document.addEventListener('keydown', handleGlobalKey, { passive: false }) |   document.addEventListener('keydown', handleGlobalKey, { passive: false }) | ||||||
|   // Set document language via shared util |   // Set document language via shared util | ||||||
|   if (lang) document.documentElement.setAttribute('lang', lang) |   if (lang) document.documentElement.setAttribute('lang', lang) | ||||||
|   // Initialize title |  | ||||||
|   document.title = formatTodayString(new Date(calendarStore.now)) |  | ||||||
| }) | }) | ||||||
|  |  | ||||||
| onBeforeUnmount(() => { | onBeforeUnmount(() => { | ||||||
| @@ -49,7 +47,7 @@ onBeforeUnmount(() => { | |||||||
| watch( | watch( | ||||||
|   () => calendarStore.now, |   () => calendarStore.now, | ||||||
|   (val) => { |   (val) => { | ||||||
|     document.title = formatTodayString(new Date(val)) |     document.title = formatTodayString(new Date(val), "short", "short") | ||||||
|   }, |   }, | ||||||
|   { immediate: false }, |   { immediate: false }, | ||||||
| ) | ) | ||||||
|   | |||||||
| @@ -1,4 +1,4 @@ | |||||||
| /* Color tokens */ | /* Light mode & common */ | ||||||
| :root { | :root { | ||||||
|   --panel: #ffffff; |   --panel: #ffffff; | ||||||
|   --panel-alt: #f6f8fa; |   --panel-alt: #f6f8fa; | ||||||
| @@ -8,19 +8,17 @@ | |||||||
|   --strong: #000; |   --strong: #000; | ||||||
|   --muted: #6a6f76; |   --muted: #6a6f76; | ||||||
|   --muted-alt: #9aa2ad; |   --muted-alt: #9aa2ad; | ||||||
|   --accent: #2563eb; /* blue */ |   --accent: #2563eb; | ||||||
|   --accent-soft: #dbeafe; |   --accent-soft: #dbeafe; | ||||||
|   --accent-hover: #1d4ed8; |   --accent-hover: #1d4ed8; | ||||||
|   --danger: #dc2626; |   --danger: #dc2626; | ||||||
|   --danger-hover: #b91c1c; |   --danger-hover: #b91c1c; | ||||||
|   --weekend: #888; |   --weekend: #555; | ||||||
|   --firstday: #000; |   --firstday: #000; | ||||||
|   --select: #aaf; |   --select: #aaf; | ||||||
|   --shadow: #fff; |   --shadow: #fff; | ||||||
|   --label-bg: #fafbfe; |   --label-bg: #fafbfe; | ||||||
|   --label-bg-rgb: 250, 251, 254; |   --label-bg-rgb: 250, 251, 254; | ||||||
|  |  | ||||||
|   /* Holiday colors */ |  | ||||||
|   --holiday: #da0; |   --holiday: #da0; | ||||||
|   --holiday-label: var(--strong); |   --holiday-label: var(--strong); | ||||||
|  |  | ||||||
| @@ -35,73 +33,11 @@ | |||||||
|   /* Vue component color mappings */ |   /* Vue component color mappings */ | ||||||
|   --bg: var(--panel); |   --bg: var(--panel); | ||||||
|   --border-color: #ddd; |   --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) { | @media (prefers-color-scheme: dark) { | ||||||
|   :root { |   :root { | ||||||
|     --panel: #121417; |     --panel: #121417; | ||||||
| @@ -138,67 +74,61 @@ | |||||||
|     /* Holiday colors (dark mode) */ |     /* Holiday colors (dark mode) */ | ||||||
|     --holiday: #ffc107; |     --holiday: #ffc107; | ||||||
|     --holiday-label: #fff8e1; |     --holiday-label: #fff8e1; | ||||||
|   } |  | ||||||
|  |  | ||||||
|   .dec { |     --weekend: #aaa; | ||||||
|     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%); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   .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 { | #calendar-content { | ||||||
|   position: relative; |   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 */ | /* Label cells */ | ||||||
| .year-label, | .year-label, | ||||||
| .week-label { | .week-label { | ||||||
| @@ -109,8 +98,8 @@ header { | |||||||
| } | } | ||||||
| /* 7-day grid inside each week row */ | /* 7-day grid inside each week row */ | ||||||
| .week-row > .days-grid { | .week-row > .days-grid { | ||||||
|   grid-column: 2 / span 7; |  | ||||||
|   display: grid; |   display: grid; | ||||||
|  |   grid-column: 2 / span 7; | ||||||
|   grid-template-columns: repeat(7, 1fr); |   grid-template-columns: repeat(7, 1fr); | ||||||
|   grid-auto-rows: 1fr; |   grid-auto-rows: 1fr; | ||||||
|   position: relative; |   position: relative; | ||||||
|   | |||||||
| @@ -1,26 +1,26 @@ | |||||||
| <script setup> | <script setup> | ||||||
|  | import { computed } from 'vue' | ||||||
|  | import { formatDateCompact, fromLocalString } from '@/utils/date' | ||||||
|  |  | ||||||
| const props = defineProps({ | const props = defineProps({ | ||||||
|   day: Object, |   day: Object, | ||||||
|   dragging: { type: Boolean, default: false }, |   dragging: { type: Boolean, default: false }, | ||||||
| }) | }) | ||||||
|  |  | ||||||
|  | const formattedDate = computed(() => { | ||||||
|  |   const date = fromLocalString(props.day.date) | ||||||
|  |   return formatDateCompact(date) | ||||||
|  | }) | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
| <template> | <template> | ||||||
|   <div |   <div | ||||||
|     class="cell" |     class="cell" | ||||||
|     :style="props.dragging ? 'touch-action:none;' : 'touch-action:pan-y;'" |     :style="props.dragging ? 'touch-action:none;' : 'touch-action:pan-y;'" | ||||||
|     :class="[ |     :class="[props.day.monthClass, { today: props.day.isToday, weekend: props.day.isWeekend, firstday: props.day.isFirstDay, selected: props.day.isSelected, holiday: props.day.isHoliday }]" | ||||||
|       props.day.monthClass, |  | ||||||
|       { |  | ||||||
|         today: props.day.isToday, |  | ||||||
|         weekend: props.day.isWeekend, |  | ||||||
|         firstday: props.day.isFirstDay, |  | ||||||
|         selected: props.day.isSelected, |  | ||||||
|         holiday: props.day.isHoliday, |  | ||||||
|       }, |  | ||||||
|     ]" |  | ||||||
|     :data-date="props.day.date" |     :data-date="props.day.date" | ||||||
|   > |   > | ||||||
|  |     <span class="compact-date">{{ formattedDate }}</span> | ||||||
|     <h1 class="day-number">{{ props.day.displayText }}</h1> |     <h1 class="day-number">{{ 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> | ||||||
|     <div v-if="props.day.holiday" class="holiday-info" dir="auto" :title="props.day.holiday.name"> |     <div v-if="props.day.holiday" class="holiday-info" dir="auto" :title="props.day.holiday.name"> | ||||||
| @@ -32,102 +32,136 @@ const props = defineProps({ | |||||||
| <style scoped> | <style scoped> | ||||||
| .cell { | .cell { | ||||||
|   position: relative; |   position: relative; | ||||||
|   border-inline-end: 1px solid var(--border-color); |  | ||||||
|   border-bottom: 1px solid var(--border-color); |  | ||||||
|   user-select: none; |   user-select: none; | ||||||
|   display: grid; |   display: grid; | ||||||
|   /* 3 columns: day number, flexible space, lunar phase */ |   grid-template-columns: 1fr; | ||||||
|   grid-template-columns: min-content 1fr min-content; |   grid-template-rows: 1fr auto; | ||||||
|   /* 3 rows: header, flexible filler, holiday label */ |  | ||||||
|   grid-template-rows: auto 1fr auto; |  | ||||||
|   /* Named grid areas (only ones actually used) */ |  | ||||||
|   grid-template-areas: |   grid-template-areas: | ||||||
|     'day-number . lunar-phase' |     'day-number' | ||||||
|     'day-number . lunar-phase' |     'holiday-info'; | ||||||
|     'holiday-info holiday-info holiday-info'; |  | ||||||
|   /* Explicit areas mainly for clarity */ |  | ||||||
|   grid-auto-flow: row; |  | ||||||
|   padding: 0.25em; |   padding: 0.25em; | ||||||
|   overflow: hidden; |   overflow: visible; | ||||||
|   width: 100%; |   width: 100%; | ||||||
|   height: var(--row-h); |   height: var(--row-h); | ||||||
|   font-weight: 700; |   font-weight: 700; | ||||||
|   transition: background-color 0.15s ease; |   transition: background-color 0.15s ease; | ||||||
|   align-items: start; |   align-items: center; | ||||||
|  |   justify-items: center; | ||||||
| } | } | ||||||
| .cell h1.day-number { | .cell h1.day-number { | ||||||
|   margin: 0; |   position: absolute; | ||||||
|   padding: 0; |   font-size: 5vmin; | ||||||
|   min-width: 1.5em; |   font-weight: 800; | ||||||
|   font-size: 1em; |  | ||||||
|   font-weight: 700; |  | ||||||
|   color: var(--ink); |   color: var(--ink); | ||||||
|   transition: background-color 0.15s ease; |   transition: all 0.15s ease; | ||||||
|   grid-area: day-number; | } | ||||||
|  | .cell.firstday h1.day-number { | ||||||
|  |   font-weight: 400; | ||||||
| } | } | ||||||
| .cell.weekend h1.day-number { | .cell.weekend h1.day-number { | ||||||
|   color: var(--weekend); |   color: var(--weekend); | ||||||
| } | } | ||||||
| .cell.firstday h1.day-number { | .cell.firstday h1.day-number { | ||||||
|   color: var(--firstday); |   color: var(--firstday); | ||||||
|   text-shadow: 0 0 0.1em var(--strong); |  | ||||||
| } | } | ||||||
| .cell.today h1.day-number { | .cell.today::before { | ||||||
|   border-radius: 2em; |   content: ''; | ||||||
|   background: var(--today); |   position: absolute; | ||||||
|   border: 0.2em solid var(--today); |   top: 50%; | ||||||
|   margin: -0.2em; |   left: 50%; | ||||||
|   color: var(--strong); |   transform: translate(-50%, -50%); | ||||||
|   font-weight: bold; |   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 { |  | ||||||
|   filter: hue-rotate(180deg); | /* Search highlight animation */ | ||||||
|  | .cell.search-highlight-flash::after { | ||||||
|  |   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(--strong); | ||||||
|  |   z-index: 16; | ||||||
|  |   pointer-events: none; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | .cell.search-highlight-flash::after { animation: search-highlight-flash 1.5s ease-out forwards; } | ||||||
|  |  | ||||||
|  | @keyframes search-highlight-flash {0%{opacity:0;transform:translate(-50%,-50%) scale(.8);border-width:.1em}15%{opacity:1;transform:translate(-50%,-50%) scale(1.05);border-width:.4em}30%{opacity:1;transform:translate(-50%,-50%) scale(1);border-width:.3em}100%{opacity:0;transform:translate(-50%,-50%) scale(1);border-width:.3em}} | ||||||
| .cell.selected h1.day-number { | .cell.selected h1.day-number { | ||||||
|   color: var(--strong); |   opacity: 0.3; | ||||||
|  |   filter: brightness(1.2); | ||||||
| } | } | ||||||
| .cell.holiday { | .cell { | ||||||
|   background-image: linear-gradient( |   background-image: linear-gradient( | ||||||
|     135deg, |     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% |     var(--holiday-grad-end, rgba(255, 255, 255, 0)) 70% | ||||||
|   ); |   ); | ||||||
| } | } | ||||||
| @media (prefers-color-scheme: dark) { | @media (prefers-color-scheme: dark) { | ||||||
|   .cell.holiday { |   .cell { | ||||||
|     background-image: linear-gradient( |     background-image: linear-gradient( | ||||||
|       135deg, |       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% |       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 { | .lunar-phase { | ||||||
|   grid-area: lunar-phase; |   grid-area: lunar-phase; | ||||||
|   align-self: start; |   position: absolute; | ||||||
|   justify-self: end; |   inset-block-start: 0.5em; | ||||||
|   margin-top: 0.5em; |   inset-inline-end: 0.2em; | ||||||
|   margin-inline-end: 0.2em; |  | ||||||
|   font-size: 0.8em; |   font-size: 0.8em; | ||||||
|   opacity: 0.7; |   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 { | .holiday-info { | ||||||
|   grid-area: holiday-info; |   grid-area: holiday-info; | ||||||
|   align-self: end; |   align-self: end; | ||||||
|   overflow: hidden; |   overflow: hidden; | ||||||
|   text-overflow: ellipsis; |   max-width: 100%; | ||||||
|   white-space: nowrap; |   color: var(--holiday); | ||||||
|   color: var(--holiday-label); |   font-size: 1em; | ||||||
|   font-size: clamp(1.2vw, 0.6em, 1em); |   font-weight: 400; | ||||||
|   line-height: 1; |   line-height: 1.0; | ||||||
|   padding-inline: 0.15em; |   padding-inline: 0.15em; | ||||||
|   padding-block-end: 0.05em; |   padding-block: 0; | ||||||
|   pointer-events: auto; |   pointer-events: auto; | ||||||
| } | } | ||||||
| </style> | </style> | ||||||
|   | |||||||
| @@ -1,9 +1,10 @@ | |||||||
| <script setup> | <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 { 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 HeaderControls from '@/components/HeaderControls.vue' | import HeaderControls from '@/components/HeaderControls.vue' | ||||||
|  | import Jogwheel from '@/components/Jogwheel.vue' | ||||||
| import { | import { | ||||||
|   createScrollManager, |   createScrollManager, | ||||||
|   createWeekColumnScrollManager, |   createWeekColumnScrollManager, | ||||||
| @@ -25,7 +26,9 @@ function openCreateEventDialog(eventData) { | |||||||
|   // Capture baseline before dialog opens (new event creation flow) |   // Capture baseline before dialog opens (new event creation flow) | ||||||
|   try { |   try { | ||||||
|     calendarStore.$history?._baselineIfNeeded?.(true) |     calendarStore.$history?._baselineIfNeeded?.(true) | ||||||
|   } catch {} |   } catch { | ||||||
|  |     /* noop */ | ||||||
|  |   } | ||||||
|   const selectionData = { startDate: eventData.startDate, dayCount: eventData.dayCount } |   const selectionData = { startDate: eventData.startDate, dayCount: eventData.dayCount } | ||||||
|   setTimeout(() => eventDialogRef.value?.openCreateDialog(selectionData), 30) |   setTimeout(() => eventDialogRef.value?.openCreateDialog(selectionData), 30) | ||||||
| } | } | ||||||
| @@ -33,7 +36,9 @@ function openEditEventDialog(eventClickPayload) { | |||||||
|   // Capture baseline before editing existing event |   // Capture baseline before editing existing event | ||||||
|   try { |   try { | ||||||
|     calendarStore.$history?._baselineIfNeeded?.(true) |     calendarStore.$history?._baselineIfNeeded?.(true) | ||||||
|   } catch {} |   } catch { | ||||||
|  |     /* noop */ | ||||||
|  |   } | ||||||
|   eventDialogRef.value?.openEditDialog(eventClickPayload) |   eventDialogRef.value?.openEditDialog(eventClickPayload) | ||||||
| } | } | ||||||
| const viewport = ref(null) | const viewport = ref(null) | ||||||
| @@ -41,6 +46,26 @@ const viewportHeight = ref(600) | |||||||
| const rowHeight = ref(64) | const rowHeight = ref(64) | ||||||
| const rowProbe = ref(null) | const rowProbe = ref(null) | ||||||
| let rowProbeObserver = 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 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) | ||||||
| @@ -172,11 +197,24 @@ function measureFromProbe() { | |||||||
| const { | const { | ||||||
|   getWeekIndex, |   getWeekIndex, | ||||||
|   getFirstDayForVirtualWeek, |   getFirstDayForVirtualWeek, | ||||||
|   goToToday, |  | ||||||
|   handleHeaderYearChange, |   handleHeaderYearChange, | ||||||
|   scrollToWeekCentered, |   scrollToWeekCentered, | ||||||
| } = vwm | } = vwm | ||||||
|  |  | ||||||
|  | function showDay(input) { | ||||||
|  |   const dateStr = input instanceof Date ? toLocalString(input, DEFAULT_TZ) : String(input) | ||||||
|  |   const weekIndex = getWeekIndex(fromLocalString(dateStr, DEFAULT_TZ)) | ||||||
|  |   scrollToWeekCentered(weekIndex, 'nav', true) | ||||||
|  |   const diff = Math.abs(weekIndex - centerVisibleWeek.value) | ||||||
|  |   const delay = Math.min(800, diff * 40) | ||||||
|  |   setTimeout(() => { | ||||||
|  |     const el = document.querySelector(`[data-date="${dateStr}"]`) | ||||||
|  |     if (!el) return | ||||||
|  |     el.classList.add('search-highlight-flash') | ||||||
|  |     setTimeout(() => el.classList.remove('search-highlight-flash'), 1500) | ||||||
|  |   }, delay) | ||||||
|  | } | ||||||
|  |  | ||||||
| // Reference date for search: center of the current viewport (virtual week at vertical midpoint) | // Reference date for search: center of the current viewport (virtual week at vertical midpoint) | ||||||
| const centerVisibleWeek = computed(() => { | const centerVisibleWeek = computed(() => { | ||||||
|   const midRow = (scrollTop.value + viewportHeight.value / 2) / rowHeight.value |   const midRow = (scrollTop.value + viewportHeight.value / 2) / rowHeight.value | ||||||
| @@ -207,7 +245,7 @@ watch( | |||||||
|     calendarStore.config.holidays.state, |     calendarStore.config.holidays.state, | ||||||
|     calendarStore.config.holidays.region, |     calendarStore.config.holidays.region, | ||||||
|   ], |   ], | ||||||
|   (_newVals, _oldVals) => { |   () => { | ||||||
|     // If weeks already built, just refresh holiday info |     // If weeks already built, just refresh holiday info | ||||||
|     if (visibleWeeks.value.length) { |     if (visibleWeeks.value.length) { | ||||||
|       refreshHolidays('config-change') |       refreshHolidays('config-change') | ||||||
| @@ -220,7 +258,6 @@ watch( | |||||||
|  |  | ||||||
| function startDrag(dateStr) { | function startDrag(dateStr) { | ||||||
|   dateStr = normalizeDate(dateStr) |   dateStr = normalizeDate(dateStr) | ||||||
|   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 } | ||||||
| @@ -333,23 +370,13 @@ function getDateFromCoordinates(clientX, clientY) { | |||||||
| } | } | ||||||
|  |  | ||||||
| function calculateSelection(anchorStr, otherStr) { | function calculateSelection(anchorStr, otherStr) { | ||||||
|   const limit = calendarStore.config.select_days |  | ||||||
|   const anchorDate = fromLocalString(anchorStr, DEFAULT_TZ) |   const anchorDate = fromLocalString(anchorStr, DEFAULT_TZ) | ||||||
|   const otherDate = fromLocalString(otherStr, DEFAULT_TZ) |   const otherDate = fromLocalString(otherStr, DEFAULT_TZ) | ||||||
|   const forward = otherDate >= anchorDate |   const forward = otherDate >= anchorDate | ||||||
|   const span = daysInclusive(anchorStr, otherStr) |   const span = daysInclusive(anchorStr, otherStr) | ||||||
|  |  | ||||||
|   if (span <= limit) { |   const startDate = forward ? anchorStr : otherStr | ||||||
|     const startDate = forward ? anchorStr : otherStr |   return { startDate, dayCount: span } | ||||||
|     return { startDate, dayCount: span } |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   if (forward) { |  | ||||||
|     return { startDate: anchorStr, dayCount: limit } |  | ||||||
|   } else { |  | ||||||
|     const startDate = addDaysStr(anchorStr, -(limit - 1), DEFAULT_TZ) |  | ||||||
|     return { startDate, dayCount: limit } |  | ||||||
|   } |  | ||||||
| } | } | ||||||
|  |  | ||||||
| onMounted(() => { | onMounted(() => { | ||||||
| @@ -382,6 +409,10 @@ onMounted(() => { | |||||||
|   onBeforeUnmount(() => { |   onBeforeUnmount(() => { | ||||||
|     clearInterval(timer) |     clearInterval(timer) | ||||||
|   }) |   }) | ||||||
|  |  | ||||||
|  |   // Start motion blur loop | ||||||
|  |   _lastBlurPos = scrollTop.value || 0 | ||||||
|  |   _blurFrame = requestAnimationFrame(_updateMotionBlur) | ||||||
| }) | }) | ||||||
|  |  | ||||||
| onBeforeUnmount(() => { | onBeforeUnmount(() => { | ||||||
| @@ -393,9 +424,12 @@ onBeforeUnmount(() => { | |||||||
|     try { |     try { | ||||||
|       rowProbeObserver.unobserve(rowProbe.value) |       rowProbeObserver.unobserve(rowProbe.value) | ||||||
|       rowProbeObserver.disconnect() |       rowProbeObserver.disconnect() | ||||||
|     } catch (e) {} |     } catch { | ||||||
|  |       /* noop */ | ||||||
|  |     } | ||||||
|   } |   } | ||||||
|   document.removeEventListener('pointerlockchange', handlePointerLockChange) |   document.removeEventListener('pointerlockchange', handlePointerLockChange) | ||||||
|  |   if (_blurFrame) cancelAnimationFrame(_blurFrame) | ||||||
| }) | }) | ||||||
|  |  | ||||||
| const handleDayMouseDown = (d) => { | const handleDayMouseDown = (d) => { | ||||||
| @@ -425,23 +459,14 @@ const handleEventClick = (payload) => { | |||||||
|   openEditEventDialog(payload) |   openEditEventDialog(payload) | ||||||
| } | } | ||||||
|  |  | ||||||
| function scrollToEventStart(startDate, smooth = true) { | function handleHeaderSearchPreview(r) { if (r) showDay(r.startDate) } | ||||||
|   try { | function handleHeaderSearchActivate(r) { | ||||||
|     const dateObj = fromLocalString(startDate, DEFAULT_TZ) |   if (!r) return | ||||||
|     const weekIndex = getWeekIndex(dateObj) |   showDay(r.startDate) | ||||||
|     scrollToWeekCentered(weekIndex, 'search-jump', smooth) |   if (!r._goto && !r._holiday) { | ||||||
|   } catch {} |     const ev = calendarStore.getEventById(r.id) | ||||||
| } |     if (ev) openEditEventDialog({ id: ev.id, event: ev }) | ||||||
| function handleHeaderSearchPreview(result) { |   } | ||||||
|   if (!result) return |  | ||||||
|   scrollToEventStart(result.startDate, true) |  | ||||||
| } |  | ||||||
| function handleHeaderSearchActivate(result) { |  | ||||||
|   if (!result) return |  | ||||||
|   scrollToEventStart(result.startDate, true) |  | ||||||
|   // Open edit dialog for the event |  | ||||||
|   const ev = calendarStore.getEventById(result.id) |  | ||||||
|   if (ev) openEditEventDialog({ id: ev.id, event: ev }) |  | ||||||
| } | } | ||||||
|  |  | ||||||
| // Heuristic: rotate month label (180deg) only for predominantly Latin text. | // Heuristic: rotate month label (180deg) only for predominantly Latin text. | ||||||
| @@ -497,10 +522,19 @@ window.addEventListener('resize', () => { | |||||||
| <template> | <template> | ||||||
|   <div class="calendar-view-root" :dir="rtl && 'rtl'"> |   <div class="calendar-view-root" :dir="rtl && 'rtl'"> | ||||||
|     <div ref="rowProbe" class="row-height-probe" aria-hidden="true"></div> |     <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"> |     <div class="wrap"> | ||||||
|       <HeaderControls |       <HeaderControls | ||||||
|         :reference-date="centerVisibleDateStr" |         :reference-date="centerVisibleDateStr" | ||||||
|         @go-to-today="goToToday" |         @go-to-today="() => showDay(calendarStore.today)" | ||||||
|         @search-preview="handleHeaderSearchPreview" |         @search-preview="handleHeaderSearchPreview" | ||||||
|         @search-activate="handleHeaderSearchActivate" |         @search-activate="handleHeaderSearchActivate" | ||||||
|       /> |       /> | ||||||
| @@ -511,44 +545,63 @@ window.addEventListener('resize', () => { | |||||||
|         @year-change="handleHeaderYearChange" |         @year-change="handleHeaderYearChange" | ||||||
|       /> |       /> | ||||||
|       <div class="calendar-container"> |       <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="calendar-content" :style="{ height: contentHeight + 'px' }"> | ||||||
|             <CalendarWeek |             <div | ||||||
|               v-for="week in visibleWeeks" |               class="weeks-wrapper" | ||||||
|               :key="week.virtualWeek" |               :style="{ | ||||||
|               :week="week" |                 transform: `translateY(${visibleWeeks.length ? visibleWeeks[0].top : 0}px)`, | ||||||
|               :dragging="isDragging" |               }" | ||||||
|               :style="{ top: week.top + 'px' }" |             > | ||||||
|               @day-mousedown="handleDayMouseDown" |               <CalendarWeek | ||||||
|               @day-mouseenter="handleDayMouseEnter" |                 v-for="week in visibleWeeks" | ||||||
|               @day-mouseup="handleDayMouseUp" |                 :key="week.virtualWeek" | ||||||
|               @day-touchstart="handleDayTouchStart" |                 :week="week" | ||||||
|               @event-click="handleEventClick" |                 :dragging="isDragging" | ||||||
|             /> |                 @day-mousedown="handleDayMouseDown" | ||||||
|  |                 @day-mouseenter="handleDayMouseEnter" | ||||||
|  |                 @day-mouseup="handleDayMouseUp" | ||||||
|  |                 @day-touchstart="handleDayTouchStart" | ||||||
|  |                 @event-click="handleEventClick" | ||||||
|  |               /> | ||||||
|  |             </div> | ||||||
|           </div> |           </div> | ||||||
|           <div class="month-column-area" :style="{ height: contentHeight + 'px' }"> |           <div class="month-column-area" :style="{ height: contentHeight + 'px' }"> | ||||||
|             <div class="month-labels-container" :style="{ height: '100%' }"> |             <div class="month-labels-container" :style="{ height: '100%' }"> | ||||||
|               <template v-for="monthWeek in visibleWeeks" :key="monthWeek.virtualWeek + '-month'"> |               <div | ||||||
|                 <div |                 class="month-labels-wrapper" | ||||||
|                   v-if="monthWeek && monthWeek.monthLabel" |                 :style="{ | ||||||
|                   class="month-label" |                   transform: `translateY(${visibleWeeks.length ? visibleWeeks[0].top : 0}px)`, | ||||||
|                   :class="monthWeek.monthLabel?.monthClass" |                   gridTemplateRows: `repeat(${visibleWeeks.length}, var(--row-h))`, | ||||||
|                   :style="{ |                 }" | ||||||
|                     height: `calc(var(--row-h) * ${monthWeek.monthLabel?.weeksSpan || 1})`, |               > | ||||||
|                     top: (monthWeek.top || 0) + 'px', |                 <template v-for="(monthWeek, i) in visibleWeeks" :key="monthWeek.virtualWeek + '-month'"> | ||||||
|                   }" |                   <div | ||||||
|                   @pointerdown="handleMonthScrollPointerDown" |                     v-if="monthWeek && monthWeek.monthLabel" | ||||||
|                   @touchstart.prevent="handleMonthScrollTouchStart" |                     class="month-label" | ||||||
|                   @wheel="handleMonthScrollWheel" |                     :class="monthWeek.monthLabel?.monthClass" | ||||||
|                 > |                     :style="{ gridRow: `${i + 1} / span ${monthWeek.monthLabel?.weeksSpan || 1}` }" | ||||||
|                   <span :class="{ bottomup: shouldRotateMonth(monthWeek.monthLabel?.text) }">{{ |                     @pointerdown="handleMonthScrollPointerDown" | ||||||
|                     monthWeek.monthLabel?.text || '' |                     @touchstart.prevent="handleMonthScrollTouchStart" | ||||||
|                   }}</span> |                     @wheel="handleMonthScrollWheel" | ||||||
|                 </div> |                   > | ||||||
|               </template> |                     <span :class="{ bottomup: shouldRotateMonth(monthWeek.monthLabel?.text) }">{{ | ||||||
|  |                       monthWeek.monthLabel?.text || '' | ||||||
|  |                     }}</span> | ||||||
|  |                   </div> | ||||||
|  |                 </template> | ||||||
|  |               </div> | ||||||
|             </div> |             </div> | ||||||
|           </div> |           </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> |       </div> | ||||||
|       <EventDialog ref="eventDialogRef" :selection="{ startDate: null, dayCount: 0 }" /> |       <EventDialog ref="eventDialogRef" :selection="{ startDate: null, dayCount: 0 }" /> | ||||||
|     </div> |     </div> | ||||||
| @@ -604,6 +657,13 @@ header h1 { | |||||||
|   width: 100%; |   width: 100%; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | .weeks-wrapper { | ||||||
|  |   position: absolute; | ||||||
|  |   inset: 0 auto auto 0; | ||||||
|  |   width: 100%; | ||||||
|  |   will-change: transform; | ||||||
|  | } | ||||||
|  |  | ||||||
| .month-column-area { | .month-column-area { | ||||||
|   position: relative; |   position: relative; | ||||||
|   cursor: ns-resize; |   cursor: ns-resize; | ||||||
| @@ -615,18 +675,24 @@ header h1 { | |||||||
|   height: 100%; |   height: 100%; | ||||||
| } | } | ||||||
|  |  | ||||||
| .month-label { | .month-labels-wrapper { | ||||||
|   position: absolute; |   position: absolute; | ||||||
|   inset-inline-start: 0; |   inset: 0 auto auto 0; | ||||||
|   width: 100%; |   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-size: 2em; | ||||||
|   font-weight: 700; |   font-weight: 700; | ||||||
|   color: var(--muted); |  | ||||||
|   display: flex; |   display: flex; | ||||||
|   align-items: center; |   align-items: center; | ||||||
|   justify-content: center; |   justify-content: center; | ||||||
|   z-index: 15; |   z-index: 5; | ||||||
|   overflow: hidden; |   overflow: hidden; | ||||||
|   cursor: ns-resize; |   cursor: ns-resize; | ||||||
|   user-select: none; |   user-select: none; | ||||||
|   | |||||||
| @@ -33,20 +33,10 @@ const handleDayTouchStart = (dateStr) => { | |||||||
| const handleEventClick = (payload) => { | const handleEventClick = (payload) => { | ||||||
|   emit('event-click', 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> | </script> | ||||||
|  |  | ||||||
| <template> | <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="week-label">W{{ props.week.weekNumber }}</div> | ||||||
|     <div class="days-grid"> |     <div class="days-grid"> | ||||||
|       <CalendarDay |       <CalendarDay | ||||||
| @@ -68,7 +58,6 @@ function shouldRotateMonth(label) { | |||||||
| .week-row { | .week-row { | ||||||
|   display: grid; |   display: grid; | ||||||
|   grid-template-columns: var(--week-w) repeat(7, 1fr); |   grid-template-columns: var(--week-w) repeat(7, 1fr); | ||||||
|   position: absolute; |  | ||||||
|   height: var(--row-h); |   height: var(--row-h); | ||||||
|   width: 100%; |   width: 100%; | ||||||
| } | } | ||||||
|   | |||||||
| @@ -599,15 +599,15 @@ const recurrenceSummary = computed(() => { | |||||||
|         <div class="line compact"> |         <div class="line compact"> | ||||||
|           <Numeric |           <Numeric | ||||||
|             v-model="displayInterval" |             v-model="displayInterval" | ||||||
|             :prefix-values="[{ value: 1, display: 'Every' }]" |             :prefix-values="[{ value: 1, display: 'All' }]" | ||||||
|             :min="2" |             :min="2" | ||||||
|             number-prefix="Every " |             number-prefix="Every " | ||||||
|             aria-label="Interval" |             aria-label="Interval" | ||||||
|           /> |           /> | ||||||
|           <select v-model="displayFrequency" class="freq-select"> |           <select v-model="displayFrequency" class="freq-select"> | ||||||
|             <option value="weeks">{{ displayInterval === 1 ? 'week' : 'weeks' }}</option> |             <option value="weeks">{{ 'weeks' }}</option> | ||||||
|             <option value="months">{{ displayInterval === 1 ? 'month' : 'months' }}</option> |             <option value="months">{{ 'months' }}</option> | ||||||
|             <option value="years">{{ displayInterval === 1 ? 'year' : 'years' }}</option> |             <option value="years">{{ 'years' }}</option> | ||||||
|           </select> |           </select> | ||||||
|           <Numeric |           <Numeric | ||||||
|             class="occ-stepper" |             class="occ-stepper" | ||||||
|   | |||||||
| @@ -3,8 +3,12 @@ | |||||||
|     <div |     <div | ||||||
|       v-for="seg in eventSegments" |       v-for="seg in eventSegments" | ||||||
|       :key="'seg-' + seg.startIdx + '-' + seg.endIdx" |       :key="'seg-' + seg.startIdx + '-' + seg.endIdx" | ||||||
|       :class="['segment-grid', { compress: isSegmentCompressed(seg) }]" |       class="segment-grid" | ||||||
|       :style="segmentStyle(seg)" |       :style="{ | ||||||
|  |         ...segmentStyle(seg), | ||||||
|  |         '--segment-row-height': getSegmentRowHeight(seg), | ||||||
|  |         height: getSegmentTotalHeight(seg) | ||||||
|  |       }" | ||||||
|     > |     > | ||||||
|       <div |       <div | ||||||
|         v-for="span in seg.events" |         v-for="span in seg.events" | ||||||
| @@ -179,8 +183,14 @@ function segmentKey(seg) { | |||||||
|   return seg.startIdx + '-' + seg.endIdx |   return seg.startIdx + '-' + seg.endIdx | ||||||
| } | } | ||||||
|  |  | ||||||
| function isSegmentCompressed(seg) { | function getSegmentRowHeight(seg) { | ||||||
|   return !!segmentCompression.value[segmentKey(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() { | function recomputeCompression() { | ||||||
| @@ -190,13 +200,36 @@ function recomputeCompression() { | |||||||
|   if (!available) return |   if (!available) return | ||||||
|   const cs = getComputedStyle(el) |   const cs = getComputedStyle(el) | ||||||
|   const fontSize = parseFloat(cs.fontSize) || 16 |   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 marginTop = 0 // already applied outside height | ||||||
|   const usable = Math.max(0, available - marginTop) |   const usable = Math.max(0, available - marginTop) | ||||||
|   const nextMap = {} |   const nextMap = {} | ||||||
|  |  | ||||||
|   for (const seg of eventSegments.value) { |   for (const seg of eventSegments.value) { | ||||||
|     const desired = (seg.rowsCount || 1) * baseRowPx |     const rowCount = seg.rowsCount || 1 | ||||||
|     nextMap[segmentKey(seg)] = desired > usable |     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 |   segmentCompression.value = nextMap | ||||||
| } | } | ||||||
| @@ -537,36 +570,40 @@ function applyRangeDuringDrag(st, startDate, endDate) { | |||||||
| } | } | ||||||
| .segment-grid { | .segment-grid { | ||||||
|   display: grid; |   display: grid; | ||||||
|   gap: 2px; |  | ||||||
|   align-content: start; |   align-content: start; | ||||||
|   pointer-events: none; |   pointer-events: none; | ||||||
|   overflow: hidden; |   overflow: hidden; | ||||||
|   grid-auto-columns: 1fr; |   grid-auto-columns: 1fr; | ||||||
|   grid-auto-rows: 1.5em; |   grid-auto-rows: var(--segment-row-height); | ||||||
| } |  | ||||||
| .segment-grid.compress { |  | ||||||
|   grid-auto-rows: 1fr; |  | ||||||
| } | } | ||||||
|  |  | ||||||
| .event-span { | .event-span { | ||||||
|   padding: 0.1em 0.3em; |   padding: 0; | ||||||
|   border-radius: 1em; |   border-radius: 1rem; | ||||||
|   font-size: clamp(0.45em, 1.8vh, 0.75em); |   /* Font-size so that ascender+descender exactly fills the row height: | ||||||
|   font-weight: 600; |     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; |   cursor: grab; | ||||||
|   pointer-events: auto; |   pointer-events: auto; | ||||||
|   overflow: hidden; |   overflow: hidden; | ||||||
|   text-overflow: ellipsis; |   text-overflow: ellipsis; | ||||||
|   white-space: nowrap; |   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; |   line-height: 1; | ||||||
|   display: flex; |   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; |   position: relative; | ||||||
|   user-select: none; |   user-select: none; | ||||||
|   z-index: 1; |   z-index: 10; | ||||||
|   text-align: center; |   text-align: center; | ||||||
|   /* Ensure touch pointer events aren't turned into a scroll gesture; needed for reliable drag on mobile */ |  | ||||||
|   touch-action: none; |   touch-action: none; | ||||||
|  |   backdrop-filter: blur(.05rem); | ||||||
|  |   max-width: 100%; | ||||||
| } | } | ||||||
|  |  | ||||||
| .event-span.cont-prev { | .event-span.cont-prev { | ||||||
| @@ -579,17 +616,21 @@ function applyRangeDuringDrag(st, startDate, endDate) { | |||||||
|   border-bottom-right-radius: 0; |   border-bottom-right-radius: 0; | ||||||
| } | } | ||||||
|  |  | ||||||
| /* Inner title wrapper ensures proper ellipsis within flex/grid constraints */ |  | ||||||
| .event-title { | .event-title { | ||||||
|   display: block; |   display: block; | ||||||
|   flex: 1 1 0%; |   flex: 0 1 auto; | ||||||
|   min-width: 0; |   min-width: 0; | ||||||
|   width: 100%; |   width: 100%; | ||||||
|  |   height: 100%; | ||||||
|   overflow: hidden; |   overflow: hidden; | ||||||
|   text-overflow: ellipsis; |   text-overflow: ellipsis; | ||||||
|   white-space: nowrap; |   white-space: nowrap; | ||||||
|   text-align: center; |   text-align: center; | ||||||
|   pointer-events: none; |   pointer-events: none; | ||||||
|  |   position: relative; | ||||||
|  |   z-index: 1; | ||||||
|  |   max-width: 100%; | ||||||
|  |   line-height: inherit; | ||||||
| } | } | ||||||
|  |  | ||||||
| /* Resize handles */ | /* Resize handles */ | ||||||
| @@ -597,7 +638,7 @@ function applyRangeDuringDrag(st, startDate, endDate) { | |||||||
|   position: absolute; |   position: absolute; | ||||||
|   top: 0; |   top: 0; | ||||||
|   bottom: 0; |   bottom: 0; | ||||||
|   width: 6px; |   width: 1rem; | ||||||
|   background: transparent; |   background: transparent; | ||||||
|   z-index: 2; |   z-index: 2; | ||||||
|   cursor: ew-resize; |   cursor: ew-resize; | ||||||
|   | |||||||
| @@ -2,12 +2,27 @@ | |||||||
|   <div class="header-controls-wrapper"> |   <div class="header-controls-wrapper"> | ||||||
|     <Transition name="header-controls" appear> |     <Transition name="header-controls" appear> | ||||||
|       <div v-if="isVisible" class="header-controls"> |       <div v-if="isVisible" class="header-controls"> | ||||||
|         <EventSearch |         <div class="search-with-spacer"> | ||||||
|           ref="eventSearchRef" |           <!-- Shrinkable spacer to align search with week label column; smoothly shrinks as needed --> | ||||||
|           :reference-date="referenceDate" |           <div class="pre-search-spacer" aria-hidden="true"></div> | ||||||
|           @activate="handleSearchActivate" |           <EventSearch | ||||||
|           @preview="(r) => emit('search-preview', r)" |             ref="eventSearchRef" | ||||||
|         /> |             :reference-date="referenceDate" | ||||||
|  |             @activate="handleSearchActivate" | ||||||
|  |             @preview="(r) => emit('search-preview', r)" | ||||||
|  |           /> | ||||||
|  |         </div> | ||||||
|  |         <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> |         <div class="today-date" @click="goToToday">{{ todayString }}</div> | ||||||
|         <button |         <button | ||||||
|           type="button" |           type="button" | ||||||
| @@ -62,11 +77,40 @@ import SettingsDialog from '@/components/SettingsDialog.vue' | |||||||
|  |  | ||||||
| const calendarStore = useCalendarStore() | const calendarStore = useCalendarStore() | ||||||
|  |  | ||||||
|  | // Today label: derive from local ticking clock so it flips right at midnight | ||||||
| const todayString = computed(() => { | const todayString = computed(() => { | ||||||
|   const d = new Date(calendarStore.now) |   const d = new Date(localNowMs?.value ?? Date.now()) | ||||||
|   return formatTodayString(d) |   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 emit = defineEmits(['go-to-today', 'search-activate', 'search-preview']) | ||||||
| const { referenceDate = null } = defineProps({ referenceDate: { type: String, default: null } }) | const { referenceDate = null } = defineProps({ referenceDate: { type: String, default: null } }) | ||||||
|  |  | ||||||
| @@ -98,7 +142,9 @@ function openSettings() { | |||||||
|   // Capture baseline before opening settings |   // Capture baseline before opening settings | ||||||
|   try { |   try { | ||||||
|     calendarStore.$history?._baselineIfNeeded?.(true) |     calendarStore.$history?._baselineIfNeeded?.(true) | ||||||
|   } catch {} |   } catch { | ||||||
|  |     /* no-op */ | ||||||
|  |   } | ||||||
|   settingsDialog.value?.open() |   settingsDialog.value?.open() | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -156,22 +202,35 @@ onBeforeUnmount(() => { | |||||||
|   position: relative; |   position: relative; | ||||||
|   display: flex; |   display: flex; | ||||||
|   align-items: flex-start; |   align-items: flex-start; | ||||||
|   gap: 0.75rem; |  | ||||||
|   padding: 0.4rem 0.5rem 0 0.5rem; |  | ||||||
| } | } | ||||||
| .header-controls { | .header-controls { | ||||||
|   display: flex; |   display: flex; | ||||||
|   align-items: center; |   align-items: center; | ||||||
|   gap: 0.75rem; |  | ||||||
|   width: 100%; |   width: 100%; | ||||||
|   padding-inline-end: 2rem; |   padding-inline-end: 2rem; | ||||||
|  |   gap: 1rem; | ||||||
| } | } | ||||||
| .header-controls :deep(.search-bar) { |  | ||||||
|   flex: 1 1 clamp(14rem, 40vw, 30rem); | /* Group search + spacer so outer gap doesn't create unwanted space */ | ||||||
|   max-width: clamp(18rem, 40vw, 30rem); | .search-with-spacer { | ||||||
|   min-width: 12rem; |   display: flex; | ||||||
|   margin-inline-end: auto; |   flex: 1; | ||||||
|  |   min-width: 0; | ||||||
|  |   align-items: stretch; | ||||||
| } | } | ||||||
|  | .search-with-spacer > .search-bar { | ||||||
|  |   flex: 1 1 auto; | ||||||
|  |   min-width: 6rem; /* allow spacer to give up space first */ | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .pre-search-spacer { | ||||||
|  |   flex: 0 1000 var(--week-w); | ||||||
|  |   width: var(--week-w); | ||||||
|  |   min-width: .5rem; | ||||||
|  |   pointer-events: none; | ||||||
|  |   transition: flex-basis 0.35s ease, width 0.35s ease; | ||||||
|  | } | ||||||
|  |  | ||||||
| .toggle-btn { | .toggle-btn { | ||||||
|   position: fixed; |   position: fixed; | ||||||
|   top: 0; |   top: 0; | ||||||
| @@ -274,6 +333,24 @@ onBeforeUnmount(() => { | |||||||
|   font-size: 1.5em; |   font-size: 1.5em; | ||||||
|   white-space: pre-line; |   white-space: pre-line; | ||||||
|   text-align: center; |   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: 770px) { | ||||||
|  |   .current-time { | ||||||
|  |     display: none; | ||||||
|  |   } | ||||||
| } | } | ||||||
| </style> | </style> | ||||||
|   | |||||||
| @@ -1,15 +1,7 @@ | |||||||
| <template> | <template><div class="jogwheel-viewport" ref="jogwheelViewport" /></template> | ||||||
|   <div class="jogwheel-viewport" ref="jogwheelViewport" @scroll="handleJogwheelScroll"> |  | ||||||
|     <div |  | ||||||
|       class="jogwheel-content" |  | ||||||
|       ref="jogwheelContent" |  | ||||||
|       :style="{ height: jogwheelHeight + 'px' }" |  | ||||||
|     ></div> |  | ||||||
|   </div> |  | ||||||
| </template> |  | ||||||
|  |  | ||||||
| <script setup> | <script setup> | ||||||
| import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue' | import { ref, onMounted, onBeforeUnmount } from 'vue' | ||||||
|  |  | ||||||
| const props = defineProps({ | const props = defineProps({ | ||||||
|   totalVirtualWeeks: { type: Number, required: true }, |   totalVirtualWeeks: { type: Number, required: true }, | ||||||
| @@ -21,160 +13,66 @@ const props = defineProps({ | |||||||
| const emit = defineEmits(['scroll-to']) | const emit = defineEmits(['scroll-to']) | ||||||
|  |  | ||||||
| const jogwheelViewport = ref(null) | const jogwheelViewport = ref(null) | ||||||
| const jogwheelContent = ref(null) |  | ||||||
| const syncLock = ref(null) |  | ||||||
| // Drag state (no momentum, 1:1 mapping) |  | ||||||
| const isDragging = ref(false) | const isDragging = ref(false) | ||||||
| let mainStartScroll = 0 | let mainStartScroll = 0 | ||||||
| let dragScale = 1 // mainScrollPixels per mouse pixel |  | ||||||
| let accumDelta = 0 | let accumDelta = 0 | ||||||
| let pointerLocked = false | let pointerLocked = false | ||||||
|  | let lastClientY = null | ||||||
|  |  | ||||||
| // Jogwheel content height is 1/10th of main calendar | const SPEED_DRAG = 4 | ||||||
| const jogwheelHeight = computed(() => { |  | ||||||
|   return (props.totalVirtualWeeks * props.rowHeight) / 10 |  | ||||||
| }) |  | ||||||
|  |  | ||||||
| const handleJogwheelScroll = () => { | const WEEKS_PER_MONTH = 30.4375 / 7 | ||||||
|   if (syncLock.value === 'jogwheel') return | const MONTH_SCROLL = () => props.rowHeight * WEEKS_PER_MONTH | ||||||
|   syncFromJogwheel() | const ANIM_DURATION = 420 // ms | ||||||
| } | let animActive = false | ||||||
|  | let animFrom = 0 | ||||||
|  | let animTo = 0 | ||||||
|  | let animStart = 0 | ||||||
|  | let animFrame = null | ||||||
|  |  | ||||||
| function onDragMouseDown(e) { | // Drag momentum (independent from month-step animation) | ||||||
|   if (e.button !== 0) return | let dragMomentumActive = false | ||||||
|   isDragging.value = true | let dragMomentumFrame = null | ||||||
|   mainStartScroll = props.scrollTop | let dragMomentumVelocity = 0 | ||||||
|   accumDelta = 0 | let dragMomentumPos = 0 | ||||||
|   // Precompute scale between jogwheel scrollable range and main scrollable range | const DRAG_FRICTION_PER_MS = 0.0018 | ||||||
|   const mainScrollable = Math.max( | const DRAG_MIN_V = 0.03 | ||||||
|     0, | let dragSamples = [] // { t, s } sampled scroll positions during drag | ||||||
|     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) { | const MIN_WHEEL_ABS = 2 | ||||||
|   if (!isDragging.value) return | function easeOutCubic(t){return 1-Math.pow(1-t,3)} | ||||||
|   const dy = pointerLocked ? e.movementY : e.clientY // movementY only valid in pointer lock |  | ||||||
|   accumDelta += dy | function clampScroll(x) { | ||||||
|   let desired = mainStartScroll - accumDelta * dragScale |  | ||||||
|   if (desired < 0) desired = 0 |  | ||||||
|   const maxScroll = Math.max(0, props.totalVirtualWeeks * props.rowHeight - props.viewportHeight) |   const maxScroll = Math.max(0, props.totalVirtualWeeks * props.rowHeight - props.viewportHeight) | ||||||
|   if (desired > maxScroll) desired = maxScroll |   if (x < 0) return 0 | ||||||
|   emit('scroll-to', desired) |   if (x > maxScroll) return maxScroll | ||||||
|   e.preventDefault() |   return x | ||||||
| } | } | ||||||
|  |  | ||||||
| function onDragMouseUp(e) { | 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)} | ||||||
|   if (!isDragging.value) return | 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)} | ||||||
|   isDragging.value = false |  | ||||||
|   window.removeEventListener('mousemove', onDragMouseMove) |  | ||||||
|   window.removeEventListener('mouseup', onDragMouseUp) |  | ||||||
|   if (pointerLocked && document.exitPointerLock) document.exitPointerLock() |  | ||||||
|   e.preventDefault() |  | ||||||
| } |  | ||||||
|  |  | ||||||
| function handlePointerLockChange() { | 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()} | ||||||
|   pointerLocked = document.pointerLockElement === jogwheelViewport.value |  | ||||||
|   if (!pointerLocked && isDragging.value) { |  | ||||||
|     // Pointer lock lost (Esc) -> end drag gracefully |  | ||||||
|     onDragMouseUp(new MouseEvent('mouseup')) |  | ||||||
|   } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| onMounted(() => { | 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()} | ||||||
|   if (jogwheelViewport.value) { |  | ||||||
|     jogwheelViewport.value.addEventListener('mousedown', onDragMouseDown) |  | ||||||
|   } |  | ||||||
|   document.addEventListener('pointerlockchange', handlePointerLockChange) |  | ||||||
| }) |  | ||||||
|  |  | ||||||
| onBeforeUnmount(() => { | 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()} | ||||||
|   if (jogwheelViewport.value) { |  | ||||||
|     jogwheelViewport.value.removeEventListener('mousedown', onDragMouseDown) |  | ||||||
|   } |  | ||||||
|   window.removeEventListener('mousemove', onDragMouseMove) |  | ||||||
|   window.removeEventListener('mouseup', onDragMouseUp) |  | ||||||
|   document.removeEventListener('pointerlockchange', handlePointerLockChange) |  | ||||||
| }) |  | ||||||
|  |  | ||||||
| const syncFromJogwheel = () => { | function handlePointerLockChange(){pointerLocked=document.pointerLockElement===jogwheelViewport.value;if(!pointerLocked&&isDragging.value)onDragMouseUp(new MouseEvent('mouseup'))} | ||||||
|   if (!jogwheelViewport.value || !jogwheelContent.value) return |  | ||||||
|  |  | ||||||
|   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( | 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)}) | ||||||
|     0, |  | ||||||
|     jogwheelContent.value.scrollHeight - jogwheelViewport.value.clientHeight, |  | ||||||
|   ) |  | ||||||
|   const mainScrollable = Math.max( |  | ||||||
|     0, |  | ||||||
|     props.totalVirtualWeeks * props.rowHeight - props.viewportHeight, |  | ||||||
|   ) |  | ||||||
|  |  | ||||||
|   if (jogScrollable > 0) { | 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())} | ||||||
|     const ratio = jogwheelViewport.value.scrollTop / jogScrollable |  | ||||||
|  |  | ||||||
|     // Emit scroll event to parent to update main viewport | // Keep API stable for parent components (previously exposed) | ||||||
|     emit('scroll-to', ratio * mainScrollable) | function syncFromMain(){};defineExpose({syncFromMain}) | ||||||
|   } |  | ||||||
|  |  | ||||||
|   setTimeout(() => { | // ---- Drag Momentum Helpers ---- | ||||||
|     if (syncLock.value === 'main') syncLock.value = null | function cancelDragMomentum(){if(!dragMomentumActive)return;dragMomentumActive=false;dragMomentumFrame&&cancelAnimationFrame(dragMomentumFrame);dragMomentumFrame=null} | ||||||
|   }, 50) | 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)} | ||||||
|  |  | ||||||
| const syncFromMain = (mainScrollTop) => { |  | ||||||
|   if (!jogwheelViewport.value || !jogwheelContent.value) return |  | ||||||
|   if (syncLock.value === 'main') return |  | ||||||
|  |  | ||||||
|   syncLock.value = 'jogwheel' |  | ||||||
|  |  | ||||||
|   const mainScrollable = Math.max( |  | ||||||
|     0, |  | ||||||
|     props.totalVirtualWeeks * props.rowHeight - props.viewportHeight, |  | ||||||
|   ) |  | ||||||
|   const jogScrollable = Math.max( |  | ||||||
|     0, |  | ||||||
|     jogwheelContent.value.scrollHeight - jogwheelViewport.value.clientHeight, |  | ||||||
|   ) |  | ||||||
|  |  | ||||||
|   if (mainScrollable > 0) { |  | ||||||
|     const ratio = mainScrollTop / mainScrollable |  | ||||||
|     jogwheelViewport.value.scrollTop = ratio * jogScrollable |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   setTimeout(() => { |  | ||||||
|     if (syncLock.value === 'jogwheel') syncLock.value = null |  | ||||||
|   }, 50) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // Watch for main calendar scroll changes |  | ||||||
| watch( |  | ||||||
|   () => props.scrollTop, |  | ||||||
|   (newScrollTop) => { |  | ||||||
|     syncFromMain(newScrollTop) |  | ||||||
|   }, |  | ||||||
| ) |  | ||||||
|  |  | ||||||
| defineExpose({ |  | ||||||
|   syncFromMain, |  | ||||||
| }) |  | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
| <style scoped> | <style scoped> | ||||||
| @@ -184,19 +82,12 @@ defineExpose({ | |||||||
|   inset-inline-end: 0; |   inset-inline-end: 0; | ||||||
|   bottom: 0; |   bottom: 0; | ||||||
|   width: var(--month-w); |   width: var(--month-w); | ||||||
|   overflow-y: auto; |   /* Transparent interactive overlay */ | ||||||
|   overflow-x: hidden; |   overflow: hidden; | ||||||
|   scrollbar-width: none; |  | ||||||
|   z-index: 20; |   z-index: 20; | ||||||
|   cursor: ns-resize; |   cursor: ns-resize; | ||||||
|  |   overscroll-behavior: contain; | ||||||
| } | } | ||||||
|  |  | ||||||
| .jogwheel-viewport::-webkit-scrollbar { | .jogwheel-viewport::-webkit-scrollbar { display: none; } | ||||||
|   display: none; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| .jogwheel-content { |  | ||||||
|   position: relative; |  | ||||||
|   width: 100%; |  | ||||||
| } |  | ||||||
| </style> | </style> | ||||||
|   | |||||||
| @@ -4,10 +4,13 @@ | |||||||
|       ref="searchInputRef" |       ref="searchInputRef" | ||||||
|       v-model="searchQuery" |       v-model="searchQuery" | ||||||
|       type="search" |       type="search" | ||||||
|       placeholder="Date or event..." |       placeholder="Date or Event..." | ||||||
|       aria-label="Search date and events" |       aria-label="Search dates, holidays and events" | ||||||
|       @keydown="handleSearchKeydown" |       @keydown="handleSearchKeydown" | ||||||
|     /> |     /> | ||||||
|  |     <div v-if="shortcut" class="shortcut-hint"> | ||||||
|  |       {{ shortcut }} | ||||||
|  |     </div> | ||||||
|     <ul |     <ul | ||||||
|       v-if="searchQuery.trim() && searchResults.length" |       v-if="searchQuery.trim() && searchResults.length" | ||||||
|       class="search-dropdown" |       class="search-dropdown" | ||||||
| @@ -22,8 +25,8 @@ | |||||||
|         role="option" |         role="option" | ||||||
|         @mousedown.prevent="selectResult(i)" |         @mousedown.prevent="selectResult(i)" | ||||||
|       > |       > | ||||||
|         <span class="title">{{ r.title }}</span> |         <span class="title">{{ r.title }}</span | ||||||
|         <span class="date">{{ r.startDate }}</span> |         ><span class="date">{{ r.startDate }}</span> | ||||||
|       </li> |       </li> | ||||||
|     </ul> |     </ul> | ||||||
|     <div v-else-if="searchQuery.trim() && !searchResults.length" class="search-empty"> |     <div v-else-if="searchQuery.trim() && !searchResults.length" class="search-empty"> | ||||||
| @@ -33,7 +36,7 @@ | |||||||
| </template> | </template> | ||||||
|  |  | ||||||
| <script setup> | <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 { useCalendarStore } from '@/stores/CalendarStore' | ||||||
| import { | import { | ||||||
|   fromLocalString, |   fromLocalString, | ||||||
| @@ -44,10 +47,11 @@ import { | |||||||
|   getMondayOfISOWeek, |   getMondayOfISOWeek, | ||||||
|   formatTodayString, |   formatTodayString, | ||||||
|   makeTZDate, |   makeTZDate, | ||||||
|  |   getISOWeek, | ||||||
| } from '@/utils/date' | } from '@/utils/date' | ||||||
| import { addDays } from 'date-fns' | import { addDays } from 'date-fns' | ||||||
| import { getDate as getRecurDate, getNearestOccurrence } from '@/utils/events' | import { getDate as getNearestOccurrence } from '@/utils/events' | ||||||
| import * as dateFns from 'date-fns' | import { getHolidaysForYear } from '@/utils/holidays' | ||||||
|  |  | ||||||
| const emit = defineEmits(['activate', 'preview']) | const emit = defineEmits(['activate', 'preview']) | ||||||
| const props = defineProps({ referenceDate: { type: String, default: null } }) | const props = defineProps({ referenceDate: { type: String, default: null } }) | ||||||
| @@ -57,57 +61,126 @@ const searchQuery = ref('') | |||||||
| const searchResults = ref([]) | const searchResults = ref([]) | ||||||
| const searchIndex = ref(0) | const searchIndex = ref(0) | ||||||
| const searchInputRef = ref(null) | const searchInputRef = ref(null) | ||||||
|  | let previewTimer = null | ||||||
|  |  | ||||||
| function buildSearchResults() { | const shortcut = /Mac/.test(navigator.userAgent) ? '⌘F' | ||||||
|  |   : /Windows|Linux/.test(navigator.userAgent) ? 'Ctrl+F' | ||||||
|  |   : '' | ||||||
|  |  | ||||||
|  | // 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 raw = searchQuery.value.trim() | ||||||
|   const q = raw.toLowerCase() |   if (!raw) { | ||||||
|   if (!q) { |  | ||||||
|     searchResults.value = [] |     searchResults.value = [] | ||||||
|     searchIndex.value = 0 |     searchIndex.value = 0 | ||||||
|  |     lastQuery = raw | ||||||
|     return |     return | ||||||
|   } |   } | ||||||
|   const listAll = raw === '*' |   const listAll = raw === '*' | ||||||
|  |   const search = norm(raw) | ||||||
|   const out = [] |   const out = [] | ||||||
|   // Reference date: prefer viewport anchor (date-only) else 'now'. Normalize to midnight local. |   let refStrLive = props.referenceDate || calendarStore.today || calendarStore.now | ||||||
|   let refStr = props.referenceDate || calendarStore.today || calendarStore.now |   if (refStrLive.includes('T')) refStrLive = refStrLive.slice(0, 10) | ||||||
|   // If it's full ISO (with time), slice date portion. |   if (queryChanged || !frozenRefStr) frozenRefStr = refStrLive | ||||||
|   if (refStr.includes('T')) refStr = refStr.slice(0, 10) |   const refStr = frozenRefStr | ||||||
|   const nowDate = fromLocalString(refStr, DEFAULT_TZ) |   const nowDate = fromLocalString(refStr, DEFAULT_TZ) | ||||||
|   for (const ev of calendarStore.events.values()) { |   for (const ev of calendarStore.events.values()) { | ||||||
|     const title = (ev.title || '').trim() |     const title = '⚜️ ' + (ev.title || '').trim() | ||||||
|     if (!title) continue |     if (!(listAll || norm(title).includes(search))) continue | ||||||
|     if (!(listAll || title.toLowerCase().includes(q))) continue |  | ||||||
|     let displayStart = ev.startDate |     let displayStart = ev.startDate | ||||||
|     if (ev.recur) { |     if (ev.recur) { | ||||||
|       const nearest = getNearestOccurrence(ev, refStr, DEFAULT_TZ) |       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 }) |     out.push({ id: ev.id, title, startDate: displayStart }) | ||||||
|   } |   } | ||||||
|   out.sort((a, b) => (a.startDate < b.startDate ? -1 : a.startDate > b.startDate ? 1 : 0)) |   if (calendarStore.config?.holidays?.enabled) { | ||||||
|   // Inject Go To Date option if query matches a date pattern (first item) |     try { | ||||||
|   const gotoDateStr = parseGoToDateCandidate(raw) |       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)) | ||||||
|  |   } 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) { |   if (gotoDateStr) { | ||||||
|     const dateObj = fromLocalString(gotoDateStr, DEFAULT_TZ) |     const dateObj = fromLocalString(gotoDateStr, DEFAULT_TZ) | ||||||
|     const label = formatTodayString(dateObj).replace(/\n+/g, ' ') |     out.unshift({ | ||||||
|     out.unshift({ id: '__goto__' + gotoDateStr, title: label, startDate: gotoDateStr, _goto: true }) |       id: '__goto__' + gotoDateStr, | ||||||
|  |       title: '📅 ' + formatTodayString(dateObj), | ||||||
|  |       startDate: gotoDateStr, | ||||||
|  |       _goto: true, | ||||||
|  |     }) | ||||||
|   } |   } | ||||||
|   searchResults.value = out |   searchResults.value = out | ||||||
|   if (searchIndex.value >= out.length) searchIndex.value = 0 |   if (searchIndex.value >= out.length) searchIndex.value = 0 | ||||||
|  |   lastQuery = raw | ||||||
| } | } | ||||||
|  |  | ||||||
| watch(searchQuery, buildSearchResults) | watch(searchQuery, (nv, ov) => { | ||||||
|  |   buildSearchResults(nv.trim() !== lastQuery) | ||||||
|  | }) | ||||||
| watch( | watch( | ||||||
|   () => calendarStore.events, |   () => calendarStore.events, | ||||||
|   () => { |   () => { | ||||||
|     if (searchQuery.value.trim()) buildSearchResults() |     if (searchQuery.value.trim()) buildSearchResults(false) | ||||||
|   }, |   }, | ||||||
|   { deep: true }, |   { deep: true }, | ||||||
| ) | ) | ||||||
| watch( | watch( | ||||||
|   () => props.referenceDate, |   () => props.referenceDate, | ||||||
|   () => { |   () => { | ||||||
|     if (searchQuery.value.trim()) buildSearchResults() |     if (searchQuery.value.trim()) buildSearchResults(false) | ||||||
|   }, |   }, | ||||||
| ) | ) | ||||||
|  |  | ||||||
| @@ -124,19 +197,38 @@ function navigate(delta) { | |||||||
|   const n = searchResults.value.length |   const n = searchResults.value.length | ||||||
|   if (!n) return |   if (!n) return | ||||||
|   searchIndex.value = (searchIndex.value + delta + n) % n |   searchIndex.value = (searchIndex.value + delta + n) % n | ||||||
|  |   // Ensure active item is visible | ||||||
|   const r = searchResults.value[searchIndex.value] |   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) { | function selectResult(idx) { | ||||||
|   searchIndex.value = idx |   searchIndex.value = idx | ||||||
|   const r = searchResults.value[searchIndex.value] |   const r = searchResults.value[searchIndex.value] | ||||||
|   if (r) { |   if (r) { | ||||||
|  |     if (previewTimer) { | ||||||
|  |       clearTimeout(previewTimer) | ||||||
|  |       previewTimer = null | ||||||
|  |     } | ||||||
|     emit('activate', r) |     emit('activate', r) | ||||||
|     // Clear query after activation (auto-close handled by parent visibility) |     // Clear query after activation (auto-close handled by parent visibility) | ||||||
|     searchQuery.value = '' |     searchQuery.value = '' | ||||||
|   } |   } | ||||||
| } | } | ||||||
| function handleSearchKeydown(e) { | 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') { |   if (e.key === 'ArrowDown') { | ||||||
|     e.preventDefault() |     e.preventDefault() | ||||||
|     navigate(1) |     navigate(1) | ||||||
| @@ -163,122 +255,220 @@ const activeResultId = computed(() => { | |||||||
| }) | }) | ||||||
|  |  | ||||||
| defineExpose({ focusSearch }) | 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() |   const s = input.trim() | ||||||
|   if (!s) return null |   if (!s) return null | ||||||
|   const today = new Date() |   const base = refStr ? fromLocalString(refStr, DEFAULT_TZ) : new Date(), | ||||||
|   const currentYear = today.getFullYear() |     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)) |   const localized = Array.from({ length: 12 }, (_, i) => getLocalizedMonthName(i)) | ||||||
|   function monthFromToken(tok) { |   const monthFromToken = (tok) => { | ||||||
|     if (!tok) return null |     if (!tok) return null | ||||||
|     const t = tok.toLowerCase() |     const tNorm = norm(tok.trim()) | ||||||
|     if (/^\d{1,2}$/.test(t)) { |     if (/^\d{1,2}$/.test(tok)) { | ||||||
|       const n = +t |       const n = +tok | ||||||
|       return n >= 1 && n <= 12 ? n : null |       return n >= 1 && n <= 12 ? n : null | ||||||
|     } |     } | ||||||
|     for (let i = 0; i < 12; i++) { |     for (let i = 0; i < 12; i++) { | ||||||
|       const ab = monthAbbr[i] |       if (norm(monthAbbr[i]) === tNorm || norm(monthAbbr[i].slice(0, 3)) === tNorm) return i + 1 | ||||||
|       if (t === ab || t === ab.slice(0, 3)) return i + 1 |  | ||||||
|     } |     } | ||||||
|     for (let i = 0; i < 12; i++) { |     for (let i = 0; i < 12; i++) { | ||||||
|       const full = localized[i].toLowerCase() |       const full = norm(localized[i]) | ||||||
|       if (t === full || full.startsWith(t)) return i + 1 |       if (full === tNorm || full.startsWith(tNorm)) return i + 1 | ||||||
|     } |     } | ||||||
|     return null |     return null | ||||||
|   } |   } | ||||||
|   // ISO full date or year-month (defaults day=1) |   // month token -> 15th of nearest year | ||||||
|   let mIsoFull = s.match(/^(\d{4})-(\d{2})-(\d{2})$/) |   const soleMonth = s.match(/^(\p{L}+)[.,;:]?$/u) | ||||||
|   if (mIsoFull) { |   if (soleMonth) { | ||||||
|     const y = +mIsoFull[1], |     const rawMonthTok = soleMonth[1] | ||||||
|       m = +mIsoFull[2], |     const m = monthFromToken(rawMonthTok) | ||||||
|       d = +mIsoFull[3] |     if (m) { | ||||||
|     const dt = makeTZDate(y, m - 1, d, DEFAULT_TZ) |       let bestYear = baseYear | ||||||
|     return toLocalString(dt, DEFAULT_TZ) |       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 | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |       return toLocalString(makeTZDate(bestYear, m - 1, 15, DEFAULT_TZ), DEFAULT_TZ) | ||||||
|  |     } | ||||||
|   } |   } | ||||||
|   let mIsoMonth = s.match(/^(\d{4})[-/](\d{2})$/) |   const isoFull = s.match(/^(\d{4})-(\d{2})-(\d{2})$/) | ||||||
|   if (mIsoMonth) { |   if (isoFull) { | ||||||
|     const y = +mIsoMonth[1], |     const y = +isoFull[1], | ||||||
|       m = +mIsoMonth[2] |       mm = +isoFull[2], | ||||||
|     const dt = makeTZDate(y, m - 1, 1, DEFAULT_TZ) |       d = +isoFull[3] | ||||||
|     return toLocalString(dt, DEFAULT_TZ) |     return toLocalString(makeTZDate(y, mm - 1, d, DEFAULT_TZ), DEFAULT_TZ) | ||||||
|   } |   } | ||||||
|   // ISO week |   // wNN -> Monday of nearest ISO week | ||||||
|   const mWeek = s.match(/^(\d{4})-W(\d{1,2})$/i) |   const weekOnly = s.match(/^w(\d{1,2})[.,;:]?$/i) | ||||||
|   if (mWeek) { |   if (weekOnly) { | ||||||
|     const wy = +mWeek[1], |     const wk = +weekOnly[1] | ||||||
|       w = +mWeek[2] |     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) | ||||||
|  |         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 >= 1 && w <= 53) { | ||||||
|       const jan4 = new Date(Date.UTC(wy, 0, 4)) |       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) |       const target = addDays(jan4, (w - 1) * 7) | ||||||
|       const monday = getMondayOfISOWeek(target) |       return toLocalString(getMondayOfISOWeek(target), DEFAULT_TZ) | ||||||
|       return toLocalString(monday, DEFAULT_TZ) |  | ||||||
|     } |     } | ||||||
|     return null |     return null | ||||||
|   } |   } | ||||||
|   // Dotted: day.month[.year] or day.month. (trailing dot) or day.month.year. |  | ||||||
|   let d = null, |   let d = null, | ||||||
|     m = null, |     m = null, | ||||||
|     y = null |     y = null, | ||||||
|   let mDot = s.match(/^(\d{1,2})\.([A-Za-z]+|\d{1,2})(?:\.(\d{4}))?\.?$/) |     yearExplicit = false | ||||||
|   if (mDot) { |   const dot = s.match(/^(\d{1,2})\.([\p{L}]+|\d{1,2})(?:\.(\d{4}))?\.?$/u) | ||||||
|     d = +mDot[1] |   if (dot) { | ||||||
|     m = monthFromToken(mDot[2]) |     d = +dot[1] | ||||||
|     y = mDot[3] ? +mDot[3] : currentYear |     m = monthFromToken(dot[2]) | ||||||
|   } else { |     if (dot[3]) { | ||||||
|     // Slash month/day(/year) (month accepts names); year optional -> current year |       y = +dot[3] | ||||||
|     let mUSFull = s.match(/^([A-Za-z]+|\d{1,2})\/(\d{1,2})\/(\d{4})$/) |       yearExplicit = true | ||||||
|     if (mUSFull) { |     } | ||||||
|       m = monthFromToken(mUSFull[1]) |   } | ||||||
|       d = +mUSFull[2] |   if (m == null) { | ||||||
|       y = +mUSFull[3] |     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 { |     } else { | ||||||
|       let mUSShort = s.match(/^([A-Za-z]+|\d{1,2})\/(\d{1,2})$/) |       const usShort = s.match(/^([\p{L}]+|\d{1,2})\/(\d{1,2})$/u) | ||||||
|       if (mUSShort) { |       if (usShort) { | ||||||
|         m = monthFromToken(mUSShort[1]) |         m = monthFromToken(usShort[1]) | ||||||
|         d = +mUSShort[2] |         d = +usShort[2] | ||||||
|         y = currentYear |  | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|   // Free-form with spaces: tokens containing month names and numbers |   if (m == null) { | ||||||
|   if (!y && !m && !d) { |  | ||||||
|     const tokens = s.split(/[ ,]+/).filter(Boolean) |     const tokens = s.split(/[ ,]+/).filter(Boolean) | ||||||
|     if (tokens.length >= 2 && tokens.length <= 3) { |     if (tokens.length >= 2 && tokens.length <= 3) { | ||||||
|       // Prefer a token with letters as month over numeric month |       let monthIdx = tokens.findIndex((t) => /\p{L}/u.test(t) && monthFromToken(t) != null) | ||||||
|       let monthIdx = tokens.findIndex((t) => /[a-zA-Z]/.test(t) && monthFromToken(t) != null) |  | ||||||
|       if (monthIdx === -1) monthIdx = tokens.findIndex((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) { |       if (monthIdx !== -1) { | ||||||
|         m = monthFromToken(tokens[monthIdx]) |         m = monthFromToken(tokens[monthIdx]) | ||||||
|         const others = tokens.filter((_t, i) => i !== monthIdx) |         const others = tokens.filter((_, i) => i !== monthIdx) | ||||||
|         let dayExplicit = false |         let dayExplicit = false | ||||||
|         for (const rawTok of others) { |         for (const rawTok of others) { | ||||||
|           const tok = rawTok.replace(/^[.,;:]+|[.,;:]+$/g, '') // trim punctuation |           const tok = rawTok.replace(/^[.,;:]+|[.,;:]+$/g, '') | ||||||
|           if (!tok) continue |           if (!tok) continue | ||||||
|           if (/^\d+$/.test(tok)) { |           if (/^\d+$/.test(tok)) { | ||||||
|             const num = +tok |             const num = +tok | ||||||
|             if (num > 100) { |             if (num > 100) { | ||||||
|               y = num |               y = num | ||||||
|  |               yearExplicit = true | ||||||
|             } else if (!d) { |             } else if (!d) { | ||||||
|               d = num |               d = num | ||||||
|               dayExplicit = true |               dayExplicit = true | ||||||
|             } |             } | ||||||
|           } else if (!y && /^\d{4}[.,;:]?$/.test(tok)) { |           } else if (!y && /^\d{4}[.,;:]?$/.test(tok)) { | ||||||
|             // salvage year with trailing punctuation |  | ||||||
|             const num = parseInt(tok, 10) |             const num = parseInt(tok, 10) | ||||||
|             if (num > 1000) y = num |             if (num > 1000) { | ||||||
|  |               y = num | ||||||
|  |               yearExplicit = true | ||||||
|  |             } | ||||||
|           } |           } | ||||||
|         } |         } | ||||||
|         if (!y) y = currentYear |         if (!d && !dayExplicit) d = 15 | ||||||
|         // Only default day=1 if user didn't provide any day-ish numeric token |  | ||||||
|         if (!d && !dayExplicit) d = 1 |  | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |   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 != null && m != null && d != null) { | ||||||
|     if (y < 1000 || y > 9999 || m < 1 || m > 12 || d < 1 || d > 31) return 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(makeTZDate(y, m - 1, d, DEFAULT_TZ), DEFAULT_TZ) | ||||||
|     return toLocalString(dt, DEFAULT_TZ) |  | ||||||
|   } |   } | ||||||
|   return null |   return null | ||||||
| } | } | ||||||
| @@ -286,20 +476,18 @@ function parseGoToDateCandidate(input) { | |||||||
|  |  | ||||||
| <style scoped> | <style scoped> | ||||||
| .search-bar { | .search-bar { | ||||||
|  |   flex: 1; | ||||||
|  |   min-width: 0; | ||||||
|   position: relative; |   position: relative; | ||||||
|   min-width: 14rem; |  | ||||||
|   flex: 1 1 clamp(14rem, 40vw, 30rem); |  | ||||||
|   max-width: clamp(18rem, 40vw, 30rem); |  | ||||||
|   min-width: 12rem; |  | ||||||
| } | } | ||||||
| .search-bar input { | .search-bar input { | ||||||
|   width: 100%; |   width: 100%; | ||||||
|   padding: 0.32rem 0.5rem; |   padding: 0.32rem 0.5rem; | ||||||
|  |   padding-inline-start: 2.05rem; /* increased space for icon */ | ||||||
|   border-radius: 0.45rem; |   border-radius: 0.45rem; | ||||||
|   border: 1px solid color-mix(in srgb, var(--muted) 35%, transparent); |   border: 1px solid color-mix(in srgb, var(--muted) 35%, transparent); | ||||||
|   background: color-mix(in srgb, var(--panel) 88%, transparent); |   background: color-mix(in srgb, var(--panel) 88%, transparent); | ||||||
|   font: inherit; |   font: inherit; | ||||||
|   font-size: 0.8rem; |  | ||||||
|   line-height: 1.1; |   line-height: 1.1; | ||||||
|   color: var(--ink); |   color: var(--ink); | ||||||
|   outline: none; |   outline: none; | ||||||
| @@ -308,6 +496,18 @@ function parseGoToDateCandidate(input) { | |||||||
|     box-shadow 0.15s ease, |     box-shadow 0.15s ease, | ||||||
|     background 0.2s; |     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 { | .search-bar input:focus-visible { | ||||||
|   border-color: color-mix(in srgb, var(--accent, #4b7) 70%, transparent); |   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); |   box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent, #4b7) 45%, transparent); | ||||||
| @@ -316,6 +516,27 @@ function parseGoToDateCandidate(input) { | |||||||
| .search-bar input::-webkit-search-cancel-button { | .search-bar input::-webkit-search-cancel-button { | ||||||
|   cursor: pointer; |   cursor: pointer; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | .shortcut-hint { | ||||||
|  |   position: absolute; | ||||||
|  |   top: 50%; | ||||||
|  |   inset-inline-end: 0.5rem; | ||||||
|  |   transform: translateY(-50%); | ||||||
|  |   pointer-events: none; | ||||||
|  |   font-size: 0.75rem; | ||||||
|  |   opacity: 0.6; | ||||||
|  |   color: var(--muted); | ||||||
|  |   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; | ||||||
|  |   background: color-mix(in srgb, var(--panel) 85%, transparent); | ||||||
|  |   padding: 0.15rem 0.3rem; | ||||||
|  |   border-radius: 0.25rem; | ||||||
|  |   border: 1px solid color-mix(in srgb, var(--muted) 25%, transparent); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .search-bar input:focus + .shortcut-hint, | ||||||
|  | .search-bar input:not(:placeholder-shown) + .shortcut-hint { | ||||||
|  |   display: none; | ||||||
|  | } | ||||||
| .search-dropdown { | .search-dropdown { | ||||||
|   position: absolute; |   position: absolute; | ||||||
|   top: calc(100% + 0.25rem); |   top: calc(100% + 0.25rem); | ||||||
|   | |||||||
| @@ -23,7 +23,6 @@ export const useCalendarStore = defineStore('calendar', { | |||||||
|     _holidayConfigSignature: null, |     _holidayConfigSignature: null, | ||||||
|     _holidaysInitialized: false, |     _holidaysInitialized: false, | ||||||
|     config: { |     config: { | ||||||
|       select_days: 14, |  | ||||||
|       first_day: 1, |       first_day: 1, | ||||||
|       holidays: { |       holidays: { | ||||||
|         enabled: true, |         enabled: true, | ||||||
|   | |||||||
| @@ -185,13 +185,24 @@ function formatDateLong(date, includeYear = false) { | |||||||
| /** | /** | ||||||
|  * Format date as today string (e.g., "Monday\nJanuary 15") |  * Format date as today string (e.g., "Monday\nJanuary 15") | ||||||
|  */ |  */ | ||||||
| function formatTodayString(date) { | function formatTodayString(date, weekday = "long", month = "long") { | ||||||
|   const formatted = date |   const formatted = date | ||||||
|     .toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' }) |     .toLocaleDateString(undefined, { weekday, month, day: 'numeric' }) | ||||||
|     .replace(/,? /, '\n') |     .replace(/,? /, '\n') | ||||||
|   return formatted.charAt(0).toUpperCase() + formatted.slice(1) |   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 { | export { | ||||||
|   // constants |   // constants | ||||||
|   monthAbbr, |   monthAbbr, | ||||||
| @@ -218,6 +229,7 @@ export { | |||||||
|   formatDateRange, |   formatDateRange, | ||||||
|   formatDateShort, |   formatDateShort, | ||||||
|   formatDateLong, |   formatDateLong, | ||||||
|  |   formatDateCompact, | ||||||
|   formatTodayString, |   formatTodayString, | ||||||
|   lunarPhaseSymbol, |   lunarPhaseSymbol, | ||||||
|   // iso helpers re-export |   // iso helpers re-export | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user