Compare commits
	
		
			18 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | bc16473715 | ||
|   | b30618031a | ||
|   | cb60c589e3 | ||
|   | 3c5cad0afe | ||
|   | 6d91833f0f | ||
|   | a3e9e13b29 | ||
|   | 73ce1b1be2 | ||
|   | 93fc600a7a | ||
|   | 09df4bed5e | ||
|   | 86a1a4d772 | ||
|   | 159bbf816d | ||
|   | c41a3b84f4 | ||
|   | 6c396bab61 | ||
|   | 8a508f273d | ||
|   | 704773dc8a | ||
|   | 0859e77b6a | ||
|   | d461a42ae5 | ||
|   | ade17b80b1 | 
| @@ -95,18 +95,18 @@ | |||||||
| .nov { background: hsl(22 15% 55%) } | .nov { background: hsl(22 15% 55%) } | ||||||
|  |  | ||||||
| @media (prefers-color-scheme: dark) { | @media (prefers-color-scheme: dark) { | ||||||
|   .dec { background: hsl(220 50% 10%) } |   .dec { background: hsl(220 50% 12%) } | ||||||
|   .jan { background: hsl(220 50% 4%) } |   .jan { background: hsl(220 50% 8%) } | ||||||
|   .feb { background: hsl(220 50% 10%) } |   .feb { background: hsl(220 50% 12%) } | ||||||
|   .mar { background: hsl(130 60% 3%) } |   .mar { background: hsl(130 40% 20%) } | ||||||
|   .apr { background: hsl(130 60% 6%) } |   .apr { background: hsl(130 60% 15%) } | ||||||
|   .may { background: hsl(130 60% 10%) } |   .may { background: hsl(130 80% 10%) } | ||||||
|   .jun { background: hsl(50 85% 8%) } |   .jun { background: hsl(50 85% 16%) } | ||||||
|   .jul { background: hsl(50 85% 12%) } |   .jul { background: hsl(50 85% 20%) } | ||||||
|   .aug { background: hsl(50 85% 8%) } |   .aug { background: hsl(50 85% 16%) } | ||||||
|   .sep { background: hsl(22 100% 10%) } |   .sep { background: hsl(22 100% 14%) } | ||||||
|   .oct { background: hsl(22 90% 6%) } |   .oct { background: hsl(22 90% 10%) } | ||||||
|   .nov { background: hsl(22 80% 3%) } |   .nov { background: hsl(22 80% 7%) } | ||||||
| } | } | ||||||
|  |  | ||||||
| /* Light mode — gray shades and colors */ | /* Light mode — gray shades and colors */ | ||||||
|   | |||||||
| @@ -1,13 +1,15 @@ | |||||||
| :root { | :root { | ||||||
|   --week-w: 3rem; |   --week-w: 3rem; | ||||||
|   --day-w: 1fr; |   --day-w: 1fr; | ||||||
|   --month-w: 2rem; |   --month-w: 3rem; | ||||||
|   --row-h: 15vh; |   --row-h: 15vh; | ||||||
| } | } | ||||||
| * { | * { | ||||||
|   box-sizing: border-box; |   box-sizing: border-box; | ||||||
| } | } | ||||||
|  | html { | ||||||
|  |   font-size: min(3vmin, 16px); | ||||||
|  | } | ||||||
| html, | html, | ||||||
| body { | body { | ||||||
|   height: 100%; |   height: 100%; | ||||||
| @@ -16,7 +18,7 @@ body { | |||||||
| body { | body { | ||||||
|   margin: 0; |   margin: 0; | ||||||
|   font: |   font: | ||||||
|     500 14px/1.2 ui-sans-serif, |     500 1rem/1.2 ui-sans-serif, | ||||||
|     system-ui, |     system-ui, | ||||||
|     -apple-system, |     -apple-system, | ||||||
|     Segoe UI, |     Segoe UI, | ||||||
| @@ -90,7 +92,7 @@ header { | |||||||
|   width: 100%; |   width: 100%; | ||||||
|   color: var(--muted); |   color: var(--muted); | ||||||
|   cursor: ns-resize; |   cursor: ns-resize; | ||||||
|   font-size: 1.2em; |   font-size: 1.2rem; | ||||||
| } | } | ||||||
|  |  | ||||||
| .week-label { | .week-label { | ||||||
| @@ -109,7 +111,7 @@ header { | |||||||
|  |  | ||||||
| .month-name-label { | .month-name-label { | ||||||
|   grid-column: -2 / -1; |   grid-column: -2 / -1; | ||||||
|   font-size: 2em; |   font-size: 2rem; | ||||||
|   font-weight: 700; |   font-weight: 700; | ||||||
|   color: var(--muted); |   color: var(--muted); | ||||||
|   display: flex; |   display: flex; | ||||||
|   | |||||||
| @@ -24,7 +24,6 @@ const modalPosition = ref({ x: 0, y: 0 }) | |||||||
| const dialogWidth = ref(null) | const dialogWidth = ref(null) | ||||||
| const dialogHeight = ref(null) | const dialogHeight = ref(null) | ||||||
| const hasMoved = ref(false) | const hasMoved = ref(false) | ||||||
| const margin = 8 // viewport margin in px to keep dialog from touching edges |  | ||||||
|  |  | ||||||
| // Collect incoming non-prop attributes (e.g., class / style from usage site) | // Collect incoming non-prop attributes (e.g., class / style from usage site) | ||||||
| const attrs = useAttrs() | const attrs = useAttrs() | ||||||
| @@ -62,8 +61,8 @@ function handleDrag(event) { | |||||||
|   const h = dialogHeight.value || modalRef.value?.offsetHeight || 0 |   const h = dialogHeight.value || modalRef.value?.offsetHeight || 0 | ||||||
|   const vw = window.innerWidth |   const vw = window.innerWidth | ||||||
|   const vh = window.innerHeight |   const vh = window.innerHeight | ||||||
|   x = clamp(x, margin, Math.max(margin, vw - w - margin)) |   x = clamp(x, 0, Math.max(0, vw - w - 0)) | ||||||
|   y = clamp(y, margin, Math.max(margin, vh - h - margin)) |   y = clamp(y, 0, Math.max(0, vh - h - 0)) | ||||||
|   modalPosition.value = { x, y } |   modalPosition.value = { x, y } | ||||||
|   event.preventDefault() |   event.preventDefault() | ||||||
| } | } | ||||||
| @@ -97,10 +96,14 @@ const modalStyle = computed(() => { | |||||||
| // <BaseDialog class="settings-modal" :style="{ top: '...' }" /> works even with fragment root. | // <BaseDialog class="settings-modal" :style="{ top: '...' }" /> works even with fragment root. | ||||||
| const modalAttrs = computed(() => { | const modalAttrs = computed(() => { | ||||||
|   const { class: extClass, style: extStyle, ...rest } = attrs |   const { class: extClass, style: extStyle, ...rest } = attrs | ||||||
|  |   // When dialog has been moved (dragged), internal positioning styles must override external ones | ||||||
|  |   const mergedStyle = hasMoved.value | ||||||
|  |     ? [extStyle, modalStyle.value].filter(Boolean) | ||||||
|  |     : [modalStyle.value, extStyle].filter(Boolean) | ||||||
|   return { |   return { | ||||||
|     ...rest, |     ...rest, | ||||||
|     class: ['ec-modal', extClass].filter(Boolean), |     class: ['ec-modal', extClass].filter(Boolean), | ||||||
|     style: [modalStyle.value, extStyle].filter(Boolean), // external style overrides internal |     style: mergedStyle, | ||||||
|   } |   } | ||||||
| }) | }) | ||||||
|  |  | ||||||
| @@ -120,7 +123,8 @@ function positionNearAnchor() { | |||||||
|   const anchor = props.anchorEl || anchorRef.value |   const anchor = props.anchorEl || anchorRef.value | ||||||
|   if (!anchor) return |   if (!anchor) return | ||||||
|   const rect = anchor.getBoundingClientRect() |   const rect = anchor.getBoundingClientRect() | ||||||
|   const offsetY = 8 // vertical gap below the anchor |   const rootFontSize = parseFloat(getComputedStyle(document.documentElement).fontSize) | ||||||
|  |   const offsetY = 0.5 * rootFontSize // vertical gap below the anchor in rem converted to pixels | ||||||
|   const w = modalRef.value?.offsetWidth || dialogWidth.value || 320 |   const w = modalRef.value?.offsetWidth || dialogWidth.value || 320 | ||||||
|   const h = modalRef.value?.offsetHeight || dialogHeight.value || 200 |   const h = modalRef.value?.offsetHeight || dialogHeight.value || 200 | ||||||
|   const vw = window.innerWidth |   const vw = window.innerWidth | ||||||
| @@ -128,8 +132,8 @@ function positionNearAnchor() { | |||||||
|   let x = rect.left |   let x = rect.left | ||||||
|   let y = rect.bottom + offsetY |   let y = rect.bottom + offsetY | ||||||
|   // If anchor is wider than dialog and would overflow right edge, clamp; otherwise keep left align |   // If anchor is wider than dialog and would overflow right edge, clamp; otherwise keep left align | ||||||
|   x = clamp(x, margin, Math.max(margin, vw - w - margin)) |   x = clamp(x, 0, Math.max(0, vw - w - 0)) | ||||||
|   y = clamp(y, margin, Math.max(margin, vh - h - margin)) |   y = clamp(y, 0, Math.max(0, vh - h - 0)) | ||||||
|   modalPosition.value = { x, y } |   modalPosition.value = { x, y } | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -172,8 +176,8 @@ function handleResize() { | |||||||
|     const vw = window.innerWidth |     const vw = window.innerWidth | ||||||
|     const vh = window.innerHeight |     const vh = window.innerHeight | ||||||
|     modalPosition.value = { |     modalPosition.value = { | ||||||
|       x: clamp(modalPosition.value.x, margin, Math.max(margin, vw - w - margin)), |       x: clamp(modalPosition.value.x, 0, Math.max(0, vw - w - 0)), | ||||||
|       y: clamp(modalPosition.value.y, margin, Math.max(margin, vh - h - margin)), |       y: clamp(modalPosition.value.y, 0, Math.max(0, vh - h - 0)), | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| } | } | ||||||
| @@ -206,19 +210,18 @@ onUnmounted(() => { | |||||||
|   </div> |   </div> | ||||||
| </template> | </template> | ||||||
|  |  | ||||||
| <style scoped> | <style> | ||||||
| .ec-modal { | .ec-modal { | ||||||
|   position: fixed; /* still fixed for overlay & dragging, but now top/left are set dynamically */ |   position: fixed; /* still fixed for overlay & dragging, but now top/left are set dynamically */ | ||||||
|   background: color-mix(in srgb, var(--panel) 85%, transparent); |   background: color-mix(in srgb, var(--panel) 85%, transparent); | ||||||
|   backdrop-filter: blur(0.625em); |   backdrop-filter: blur(0.625em); | ||||||
|   -webkit-backdrop-filter: blur(0.625em); |  | ||||||
|   color: var(--ink); |   color: var(--ink); | ||||||
|   border-radius: 0.6em; |   border-radius: 0.6rem; | ||||||
|   min-height: 23em; |   min-height: 23rem; | ||||||
|   min-width: 26em; |   min-width: 26rem; | ||||||
|   max-width: min(34em, 90vw); |   max-width: min(34rem, 90vw); | ||||||
|   box-shadow: 0 0.6em 1.8em rgba(0, 0, 0, 0.35); |   box-shadow: 0 0.6rem 1.8rem rgba(0, 0, 0, 0.35); | ||||||
|   border: 0.0625em solid color-mix(in srgb, var(--muted) 40%, transparent); |   border: 0.0625rem solid color-mix(in srgb, var(--muted) 40%, transparent); | ||||||
|   z-index: 1000; |   z-index: 1000; | ||||||
|   overflow: hidden; |   overflow: hidden; | ||||||
| } | } | ||||||
| @@ -230,35 +233,36 @@ onUnmounted(() => { | |||||||
| .ec-form { | .ec-form { | ||||||
|   display: grid; |   display: grid; | ||||||
|   grid-template-rows: auto 1fr auto; |   grid-template-rows: auto 1fr auto; | ||||||
|   min-height: 23em; |   min-height: 23rem; | ||||||
|   height: 100%; |   height: 100%; | ||||||
|   width: 100%; |   width: 100%; | ||||||
| } | } | ||||||
| .ec-header { | .ec-header { | ||||||
|   cursor: move; |   cursor: move; | ||||||
|   user-select: none; |   user-select: none; | ||||||
|   padding: 0.75em 1em 0.5em 1em; |   touch-action: none; | ||||||
|  |   padding: 0.75rem 1rem 0.5rem 1rem; | ||||||
|   display: flex; |   display: flex; | ||||||
|   justify-content: space-between; |   justify-content: space-between; | ||||||
|   align-items: center; |   align-items: center; | ||||||
|   gap: 1em; |   gap: 1rem; | ||||||
| } | } | ||||||
| .ec-title { | .ec-title { | ||||||
|   margin: 0; |   margin: 0; | ||||||
|   font-size: 1.1em; |   font-size: 1.1rem; | ||||||
| } | } | ||||||
| .ec-body { | .ec-body { | ||||||
|   display: flex; |   display: flex; | ||||||
|   flex-direction: column; |   flex-direction: column; | ||||||
|   gap: 1em; |   gap: 1rem; | ||||||
|   padding: 0 1em 0.5em 1em; |   padding: 0 1rem 0.5rem 1rem; | ||||||
|   overflow: auto; |   overflow: auto; | ||||||
| } | } | ||||||
| .ec-footer { | .ec-footer { | ||||||
|   padding: 0.5em 1em 1em 1em; |   padding: 0.5rem 1rem 1rem 1rem; | ||||||
|   display: flex; |   display: flex; | ||||||
|   justify-content: space-between; |   justify-content: space-between; | ||||||
|   gap: 1em; |   gap: 1rem; | ||||||
|   flex-wrap: wrap; |   flex-wrap: wrap; | ||||||
| } | } | ||||||
| </style> | </style> | ||||||
|   | |||||||
| @@ -9,14 +9,12 @@ const props = defineProps({ | |||||||
|  |  | ||||||
| // Reactive viewport width detection | // Reactive viewport width detection | ||||||
| const isNarrowView = ref(false) | const isNarrowView = ref(false) | ||||||
| const isVeryNarrowView = ref(false) |  | ||||||
| const isSmallView = ref(false) | const isSmallView = ref(false) | ||||||
|  |  | ||||||
| function checkViewportWidth() { | function checkViewportWidth() { | ||||||
|   const width = window.innerWidth |   const width = window.innerWidth | ||||||
|   isSmallView.value = width < 800 |   isSmallView.value = width < 800 | ||||||
|   isNarrowView.value = width < 600 |   isNarrowView.value = width < 600 | ||||||
|   isVeryNarrowView.value = width < 400 |  | ||||||
| } | } | ||||||
|  |  | ||||||
| onMounted(() => { | onMounted(() => { | ||||||
| @@ -30,27 +28,12 @@ onBeforeUnmount(() => { | |||||||
|  |  | ||||||
| const formattedDate = computed(() => { | const formattedDate = computed(() => { | ||||||
|   const date = fromLocalString(props.day.date) |   const date = fromLocalString(props.day.date) | ||||||
|    |   let options = { weekday: 'short', day: 'numeric', month: 'short' } | ||||||
|   let options = { day: 'numeric', month: 'short' } |   // Remove weekday on very small viewports | ||||||
|    |   if (isNarrowView.value) options = { day: 'numeric', month: 'short' } | ||||||
|   if (isVeryNarrowView.value) { |  | ||||||
|     // Very narrow: show only day number |  | ||||||
|     options = { day: 'numeric' } |  | ||||||
|   } else if (isNarrowView.value) { |  | ||||||
|     // Narrow: show day and month, no weekday |  | ||||||
|     options = { day: 'numeric', month: 'short' } |  | ||||||
|   } else { |  | ||||||
|     // Wide: show weekday, day, and month |  | ||||||
|     options = { weekday: 'short', day: 'numeric', month: 'short' } |  | ||||||
|   } |  | ||||||
|    |  | ||||||
|   let formatted = date.toLocaleDateString(undefined, options) |   let formatted = date.toLocaleDateString(undefined, options) | ||||||
|    |   // Split between weekday and day/month on small viewports | ||||||
|   // Below 700px, replace first space with newline to force weekday on separate line |   if (isSmallView.value) formatted = formatted.replace(/\s/, '\n') | ||||||
|   if (isSmallView.value && !isNarrowView.value && !isVeryNarrowView.value) { |  | ||||||
|     formatted = formatted.replace(/\s/, '\n') |  | ||||||
|   } |  | ||||||
|    |  | ||||||
|   // Replace the last space (between month and day) with nbsp to prevent breaking there |   // Replace the last space (between month and day) with nbsp to prevent breaking there | ||||||
|   // but keep the space after weekday (if present) as regular space to allow wrapping |   // but keep the space after weekday (if present) as regular space to allow wrapping | ||||||
|   formatted = formatted.replace(/\s+(?=\S+$)/, '\u00A0') |   formatted = formatted.replace(/\s+(?=\S+$)/, '\u00A0') | ||||||
| @@ -65,11 +48,12 @@ const formattedDate = computed(() => { | |||||||
|     :style="props.dragging ? 'touch-action:none;' : 'touch-action:pan-y;'" |     :style="props.dragging ? 'touch-action:none;' : 'touch-action:pan-y;'" | ||||||
|     :class="[props.day.monthClass, { today: props.day.isToday, weekend: props.day.isWeekend, firstday: props.day.isFirstDay, selected: props.day.isSelected, holiday: props.day.isHoliday }]" |     :class="[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" | ||||||
|  |     :title="props.day.holiday?.name" | ||||||
|   > |   > | ||||||
|     <span class="compact-date">{{ formattedDate }}</span> |     <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"> | ||||||
|       {{ props.day.holiday.name }} |       {{ props.day.holiday.name }} | ||||||
|     </div> |     </div> | ||||||
|   </div> |   </div> | ||||||
| @@ -167,10 +151,9 @@ const formattedDate = computed(() => { | |||||||
| .lunar-phase { | .lunar-phase { | ||||||
|   grid-area: lunar-phase; |   grid-area: lunar-phase; | ||||||
|   position: absolute; |   position: absolute; | ||||||
|   inset-block-start: 0.5em; |   inset-block-start: 0.1em; | ||||||
|   inset-inline-end: 0.2em; |   inset-inline-end: 0.1em; | ||||||
|   font-size: 0.8em; |   font-size: 0.8rem; | ||||||
|   opacity: 0.7; |  | ||||||
| } | } | ||||||
|  |  | ||||||
| .compact-date { | .compact-date { | ||||||
| @@ -178,7 +161,8 @@ const formattedDate = computed(() => { | |||||||
|   top: 0.25em; |   top: 0.25em; | ||||||
|   left: 0.25em; |   left: 0.25em; | ||||||
|   inset-inline-end: 1rem; /* Space for lunar phase */ |   inset-inline-end: 1rem; /* Space for lunar phase */ | ||||||
|   font-weight: 400; |   font-weight: 300; | ||||||
|  |   font-size: 0.8rem; | ||||||
|   color: var(--ink); |   color: var(--ink); | ||||||
|   line-height: 1; |   line-height: 1; | ||||||
|   pointer-events: none; |   pointer-events: none; | ||||||
| @@ -204,7 +188,7 @@ const formattedDate = computed(() => { | |||||||
|   overflow: hidden; |   overflow: hidden; | ||||||
|   max-width: 100%; |   max-width: 100%; | ||||||
|   color: var(--holiday); |   color: var(--holiday); | ||||||
|   font-size: 1em; |   font-size: 0.8rem; | ||||||
|   font-weight: 400; |   font-weight: 400; | ||||||
|   line-height: 1.0; |   line-height: 1.0; | ||||||
|   padding-inline: 0.15em; |   padding-inline: 0.15em; | ||||||
|   | |||||||
| @@ -119,7 +119,7 @@ const weekdayNames = computed(() => { | |||||||
| .calendar-header { | .calendar-header { | ||||||
|   display: grid; |   display: grid; | ||||||
|   grid-template-columns: var(--week-w) repeat(7, 1fr) var(--month-w); |   grid-template-columns: var(--week-w) repeat(7, 1fr) var(--month-w); | ||||||
|   border-bottom: 2px solid var(--muted); |   border-bottom: .1rem solid var(--muted); | ||||||
|   align-items: last baseline; |   align-items: last baseline; | ||||||
|   flex-shrink: 0; |   flex-shrink: 0; | ||||||
|   width: 100%; |   width: 100%; | ||||||
| @@ -135,7 +135,7 @@ const weekdayNames = computed(() => { | |||||||
|   text-transform: uppercase; |   text-transform: uppercase; | ||||||
|   text-align: center; |   text-align: center; | ||||||
|   font-weight: 600; |   font-weight: 600; | ||||||
|   font-size: 1.2em; |   font-size: 1.2rem; | ||||||
| } | } | ||||||
| .dow.weekend { | .dow.weekend { | ||||||
|   color: var(--weekend); |   color: var(--weekend); | ||||||
|   | |||||||
| @@ -54,9 +54,8 @@ let _blurFrame = null | |||||||
|  |  | ||||||
| function _updateMotionBlur() { | function _updateMotionBlur() { | ||||||
|   const pos = scrollTop.value || 0 |   const pos = scrollTop.value || 0 | ||||||
|   if (_lastBlurPos) { |   if (_lastBlurPos) blurAmount.value = 0.1 * blurAmount.value + 0.9 * Math.min(20, 0.5 * Math.abs(pos - _lastBlurPos)) | ||||||
|     blurAmount.value = 0.5 * Math.abs(pos - _lastBlurPos) |   if (!_lastBlurPos || blurAmount.value < 5) blurAmount.value = 0 | ||||||
|   } |  | ||||||
|   _lastBlurPos = pos |   _lastBlurPos = pos | ||||||
|   _blurFrame = requestAnimationFrame(_updateMotionBlur) |   _blurFrame = requestAnimationFrame(_updateMotionBlur) | ||||||
| } | } | ||||||
| @@ -198,13 +197,13 @@ const { | |||||||
|   getWeekIndex, |   getWeekIndex, | ||||||
|   getFirstDayForVirtualWeek, |   getFirstDayForVirtualWeek, | ||||||
|   handleHeaderYearChange, |   handleHeaderYearChange, | ||||||
|   scrollToWeekCentered, |   scrollToWeek, | ||||||
| } = vwm | } = vwm | ||||||
|  |  | ||||||
| function showDay(input) { | function showDay(input) { | ||||||
|   const dateStr = input instanceof Date ? toLocalString(input, DEFAULT_TZ) : String(input) |   const dateStr = input instanceof Date ? toLocalString(input, DEFAULT_TZ) : String(input) | ||||||
|   const weekIndex = getWeekIndex(fromLocalString(dateStr, DEFAULT_TZ)) |   const weekIndex = getWeekIndex(fromLocalString(dateStr, DEFAULT_TZ)) | ||||||
|   scrollToWeekCentered(weekIndex, 'nav', true) |   scrollToWeek(weekIndex, 'nav', true) | ||||||
|   const diff = Math.abs(weekIndex - centerVisibleWeek.value) |   const diff = Math.abs(weekIndex - centerVisibleWeek.value) | ||||||
|   const delay = Math.min(800, diff * 40) |   const delay = Math.min(800, diff * 40) | ||||||
|   setTimeout(() => { |   setTimeout(() => { | ||||||
| @@ -229,10 +228,6 @@ const centerVisibleDateStr = computed(() => { | |||||||
|   } |   } | ||||||
| }) | }) | ||||||
|  |  | ||||||
| // createWeek logic moved to virtualWeeks plugin |  | ||||||
|  |  | ||||||
| // goToToday now provided by manager |  | ||||||
|  |  | ||||||
| function clearSelection() { | function clearSelection() { | ||||||
|   selection.value = { startDate: null, dayCount: 0 } |   selection.value = { startDate: null, dayCount: 0 } | ||||||
| } | } | ||||||
| @@ -546,26 +541,6 @@ window.addEventListener('resize', () => { | |||||||
|       /> |       /> | ||||||
|       <div class="calendar-container"> |       <div class="calendar-container"> | ||||||
|         <div class="calendar-viewport" ref="viewport" :style="viewportBlurStyle"> |         <div class="calendar-viewport" ref="viewport" :style="viewportBlurStyle"> | ||||||
|           <div class="calendar-content" :style="{ height: contentHeight + 'px' }"> |  | ||||||
|             <div |  | ||||||
|               class="weeks-wrapper" |  | ||||||
|               :style="{ |  | ||||||
|                 transform: `translateY(${visibleWeeks.length ? visibleWeeks[0].top : 0}px)`, |  | ||||||
|               }" |  | ||||||
|             > |  | ||||||
|               <CalendarWeek |  | ||||||
|                 v-for="week in visibleWeeks" |  | ||||||
|                 :key="week.virtualWeek" |  | ||||||
|                 :week="week" |  | ||||||
|                 :dragging="isDragging" |  | ||||||
|                 @day-mousedown="handleDayMouseDown" |  | ||||||
|                 @day-mouseenter="handleDayMouseEnter" |  | ||||||
|                 @day-mouseup="handleDayMouseUp" |  | ||||||
|                 @day-touchstart="handleDayTouchStart" |  | ||||||
|                 @event-click="handleEventClick" |  | ||||||
|               /> |  | ||||||
|             </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%' }"> | ||||||
|               <div |               <div | ||||||
| @@ -593,6 +568,26 @@ window.addEventListener('resize', () => { | |||||||
|               </div> |               </div> | ||||||
|             </div> |             </div> | ||||||
|           </div> |           </div> | ||||||
|  |           <div class="calendar-content" :style="{ height: contentHeight + 'px' }"> | ||||||
|  |             <div | ||||||
|  |               class="weeks-wrapper" | ||||||
|  |               :style="{ | ||||||
|  |                 transform: `translateY(${visibleWeeks.length ? visibleWeeks[0].top : 0}px)`, | ||||||
|  |               }" | ||||||
|  |             > | ||||||
|  |               <CalendarWeek | ||||||
|  |                 v-for="week in visibleWeeks" | ||||||
|  |                 :key="week.virtualWeek" | ||||||
|  |                 :week="week" | ||||||
|  |                 :dragging="isDragging" | ||||||
|  |                 @day-mousedown="handleDayMouseDown" | ||||||
|  |                 @day-mouseenter="handleDayMouseEnter" | ||||||
|  |                 @day-mouseup="handleDayMouseUp" | ||||||
|  |                 @day-touchstart="handleDayTouchStart" | ||||||
|  |                 @event-click="handleEventClick" | ||||||
|  |               /> | ||||||
|  |             </div> | ||||||
|  |           </div> | ||||||
|         </div> |         </div> | ||||||
|         <!-- Jogwheel overlay captures drag + wheel over month name column --> |         <!-- Jogwheel overlay captures drag + wheel over month name column --> | ||||||
|         <Jogwheel |         <Jogwheel | ||||||
| @@ -655,6 +650,8 @@ header h1 { | |||||||
| .calendar-content { | .calendar-content { | ||||||
|   position: relative; |   position: relative; | ||||||
|   width: 100%; |   width: 100%; | ||||||
|  |   grid-column: 1; | ||||||
|  |   grid-row: 1; | ||||||
| } | } | ||||||
|  |  | ||||||
| .weeks-wrapper { | .weeks-wrapper { | ||||||
| @@ -667,6 +664,8 @@ header h1 { | |||||||
| .month-column-area { | .month-column-area { | ||||||
|   position: relative; |   position: relative; | ||||||
|   cursor: ns-resize; |   cursor: ns-resize; | ||||||
|  |   grid-column: 2; | ||||||
|  |   grid-row: 1; | ||||||
| } | } | ||||||
|  |  | ||||||
| .month-labels-container { | .month-labels-container { | ||||||
| @@ -687,12 +686,11 @@ header h1 { | |||||||
| .month-label { | .month-label { | ||||||
|   width: 100%; |   width: 100%; | ||||||
|   opacity: 0.8; |   opacity: 0.8; | ||||||
|   font-size: 2em; |   font-size: 2.2rem; | ||||||
|   font-weight: 700; |   font-weight: 700; | ||||||
|   display: flex; |   display: flex; | ||||||
|   align-items: center; |   align-items: center; | ||||||
|   justify-content: center; |   justify-content: start; | ||||||
|   z-index: 5; |  | ||||||
|   overflow: hidden; |   overflow: hidden; | ||||||
|   cursor: ns-resize; |   cursor: ns-resize; | ||||||
|   user-select: none; |   user-select: none; | ||||||
|   | |||||||
| @@ -67,7 +67,7 @@ const handleEventClick = (payload) => { | |||||||
|   place-items: center; |   place-items: center; | ||||||
|   width: 100%; |   width: 100%; | ||||||
|   color: var(--muted); |   color: var(--muted); | ||||||
|   font-size: 1.2em; |   font-size: 1.2rem; | ||||||
|   font-weight: 500; |   font-weight: 500; | ||||||
|   user-select: none; |   user-select: none; | ||||||
|   height: var(--row-h); |   height: var(--row-h); | ||||||
|   | |||||||
| @@ -6,7 +6,6 @@ import WeekdaySelector from './WeekdaySelector.vue' | |||||||
| import Numeric from './Numeric.vue' | import Numeric from './Numeric.vue' | ||||||
| import { | import { | ||||||
|   addDaysStr, |   addDaysStr, | ||||||
|   getMondayOfISOWeek, |  | ||||||
|   fromLocalString, |   fromLocalString, | ||||||
|   formatDateShort, |   formatDateShort, | ||||||
|   formatDateLong, |   formatDateLong, | ||||||
| @@ -675,7 +674,7 @@ const recurrenceSummary = computed(() => { | |||||||
| } | } | ||||||
|  |  | ||||||
| .ec-field > span { | .ec-field > span { | ||||||
|   font-size: 0.85em; |   font-size: 0.85rem; | ||||||
|   color: var(--muted); |   color: var(--muted); | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -683,12 +682,13 @@ const recurrenceSummary = computed(() => { | |||||||
| .ec-field input[type='time'], | .ec-field input[type='time'], | ||||||
| .ec-field input[type='number'], | .ec-field input[type='number'], | ||||||
| .ec-field select { | .ec-field select { | ||||||
|   border: 1px solid var(--muted); |   border: .1rem solid var(--muted); | ||||||
|   border-radius: 0.4rem; |   border-radius: 0.4rem; | ||||||
|   padding: 0.5rem 0.6rem; |   padding: 0.5rem 0.6rem; | ||||||
|   width: 100%; |   width: 100%; | ||||||
|   background: transparent; |   background: transparent; | ||||||
|   color: var(--ink); |   color: var(--ink); | ||||||
|  |   font-size: 1rem; | ||||||
| } | } | ||||||
|  |  | ||||||
| .ec-color-swatches { | .ec-color-swatches { | ||||||
| @@ -726,6 +726,7 @@ const recurrenceSummary = computed(() => { | |||||||
|   padding: 0.5em 0.8em; |   padding: 0.5em 0.8em; | ||||||
|   border-radius: 0.4em; |   border-radius: 0.4em; | ||||||
|   cursor: pointer; |   cursor: pointer; | ||||||
|  |   font-size: 1rem; | ||||||
|   transition: all 0.2s ease; |   transition: all 0.2s ease; | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -771,7 +772,7 @@ const recurrenceSummary = computed(() => { | |||||||
| } | } | ||||||
|  |  | ||||||
| .ec-field-label { | .ec-field-label { | ||||||
|   font-size: 0.85em; |   font-size: 0.85rem; | ||||||
|   color: var(--muted); |   color: var(--muted); | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -801,7 +802,7 @@ const recurrenceSummary = computed(() => { | |||||||
| } | } | ||||||
|  |  | ||||||
| .ec-weekday-text { | .ec-weekday-text { | ||||||
|   font-size: 0.8em; |   font-size: 0.8rem; | ||||||
|   font-weight: 500; |   font-weight: 500; | ||||||
|   text-align: center; |   text-align: center; | ||||||
| } | } | ||||||
| @@ -848,12 +849,12 @@ const recurrenceSummary = computed(() => { | |||||||
|   align-items: center; |   align-items: center; | ||||||
|   gap: 0.5em; |   gap: 0.5em; | ||||||
|   flex-wrap: wrap; |   flex-wrap: wrap; | ||||||
|   font-size: 0.75em; |   font-size: 0.75rem; | ||||||
| } | } | ||||||
| .freq-select { | .freq-select { | ||||||
|   padding: 0.4rem 0.55rem; |   padding: 0.4rem 0.55rem; | ||||||
|   font-size: 0.75rem; |   font-size: 0.75rem; | ||||||
|   border: 1px solid var(--input-border); |   border: .1rem solid var(--input-border); | ||||||
|   background: var(--panel-alt); |   background: var(--panel-alt); | ||||||
|   color: var(--ink); |   color: var(--ink); | ||||||
|   border-radius: 0.45rem; |   border-radius: 0.45rem; | ||||||
| @@ -867,18 +868,19 @@ const recurrenceSummary = computed(() => { | |||||||
|   background: var(--panel-accent); |   background: var(--panel-accent); | ||||||
|   color: var(--ink); |   color: var(--ink); | ||||||
|   box-shadow: |   box-shadow: | ||||||
|     0 0 0 1px var(--input-focus), |     0 0 0 .1rem var(--input-focus), | ||||||
|     0 0 0 4px rgba(37, 99, 235, 0.15); |     0 0 0 .4rem rgba(37, 99, 235, 0.15); | ||||||
| } | } | ||||||
| .interval-input, | .interval-input, | ||||||
| .occ-input { | .occ-input { | ||||||
|   display: none; |   display: none; | ||||||
| } | } | ||||||
| .ec-field input[type='text'] { | .ec-field input[type='text'] { | ||||||
|   border: 1px solid var(--input-border); |   border: .1rem solid var(--input-border); | ||||||
|   background: var(--panel-alt); |   background: var(--panel-alt); | ||||||
|   border-radius: 0.45rem; |   border-radius: 0.45rem; | ||||||
|   padding: 0.4rem 0.5rem; |   padding: 0.4rem 0.5rem; | ||||||
|  |   font-size: 1rem; | ||||||
|   transition: |   transition: | ||||||
|     border-color 0.18s ease, |     border-color 0.18s ease, | ||||||
|     background-color 0.18s ease, |     background-color 0.18s ease, | ||||||
| @@ -889,8 +891,8 @@ const recurrenceSummary = computed(() => { | |||||||
|   border-color: var(--input-focus); |   border-color: var(--input-focus); | ||||||
|   background: var(--panel-accent); |   background: var(--panel-accent); | ||||||
|   box-shadow: |   box-shadow: | ||||||
|     0 0 0 1px var(--input-focus), |     0 0 0 .1rem var(--input-focus), | ||||||
|     0 0 0 4px rgba(37, 99, 235, 0.15); |     0 0 0 .4rem rgba(37, 99, 235, 0.15); | ||||||
| } | } | ||||||
| .hint { | .hint { | ||||||
|   font-size: 0.65rem; |   font-size: 0.65rem; | ||||||
| @@ -908,7 +910,7 @@ const recurrenceSummary = computed(() => { | |||||||
|   align-items: center; |   align-items: center; | ||||||
|   width: 100%; |   width: 100%; | ||||||
|   padding: 0.6rem 0.8rem; |   padding: 0.6rem 0.8rem; | ||||||
|   border: 1px solid var(--muted); |   border: .1rem solid var(--muted); | ||||||
|   background: var(--panel); |   background: var(--panel); | ||||||
|   border-radius: 0.4rem; |   border-radius: 0.4rem; | ||||||
|   cursor: pointer; |   cursor: pointer; | ||||||
| @@ -931,7 +933,7 @@ const recurrenceSummary = computed(() => { | |||||||
|   display: grid; |   display: grid; | ||||||
|   gap: 0.6rem; |   gap: 0.6rem; | ||||||
|   padding: 0.6rem; |   padding: 0.6rem; | ||||||
|   border: 1px solid var(--muted); |   border: .1rem solid var(--muted); | ||||||
|   border-radius: 0.4rem; |   border-radius: 0.4rem; | ||||||
|   background: color-mix(in srgb, var(--muted) 20%, transparent); |   background: color-mix(in srgb, var(--muted) 20%, transparent); | ||||||
| } | } | ||||||
| @@ -945,7 +947,7 @@ const recurrenceSummary = computed(() => { | |||||||
| .ec-repeat-modes .mode-btn { | .ec-repeat-modes .mode-btn { | ||||||
|   flex: 1 1 auto; |   flex: 1 1 auto; | ||||||
|   padding: 0.4rem 0.6rem; |   padding: 0.4rem 0.6rem; | ||||||
|   border: 1px solid var(--muted); |   border: .1rem solid var(--muted); | ||||||
|   background: var(--panel); |   background: var(--panel); | ||||||
|   border-radius: 0.4rem; |   border-radius: 0.4rem; | ||||||
|   cursor: pointer; |   cursor: pointer; | ||||||
|   | |||||||
| @@ -185,7 +185,7 @@ function segmentKey(seg) { | |||||||
|  |  | ||||||
| function getSegmentRowHeight(seg) { | function getSegmentRowHeight(seg) { | ||||||
|   const data = segmentCompression.value[segmentKey(seg)] |   const data = segmentCompression.value[segmentKey(seg)] | ||||||
|   return data && typeof data.rowHeight === 'number' ? `${data.rowHeight}px` : '1.5em' |   return data && typeof data.rowHeight === 'number' ? `${data.rowHeight}px` : '1.5rem' | ||||||
| } | } | ||||||
|  |  | ||||||
| function getSegmentTotalHeight(seg) { | function getSegmentTotalHeight(seg) { | ||||||
| @@ -321,18 +321,16 @@ function startLocalDrag(init, evt) { | |||||||
|   let originalWeekday = null |   let originalWeekday = null | ||||||
|   let originalPattern = null |   let originalPattern = null | ||||||
|   if (init.mode === 'move') { |   if (init.mode === 'move') { | ||||||
|     try { |     originalWeekday = new Date(init.startDate + 'T00:00:00').getDay() | ||||||
|       originalWeekday = new Date(init.startDate + 'T00:00:00').getDay() |     const baseEv = store.getEventById(init.id) | ||||||
|       const baseEv = store.getEventById(init.id) |     if ( | ||||||
|       if ( |       baseEv && | ||||||
|         baseEv && |       baseEv.recur && | ||||||
|         baseEv.recur && |       baseEv.recur.freq === 'weeks' && | ||||||
|         baseEv.recur.freq === 'weeks' && |       Array.isArray(baseEv.recur.weekdays) | ||||||
|         Array.isArray(baseEv.recur.weekdays) |     ) { | ||||||
|       ) { |       originalPattern = [...baseEv.recur.weekdays] | ||||||
|         originalPattern = [...baseEv.recur.weekdays] |     } | ||||||
|       } |  | ||||||
|     } catch {} |  | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   dragState.value = { |   dragState.value = { | ||||||
| @@ -565,7 +563,7 @@ function applyRangeDuringDrag(st, startDate, endDate) { | |||||||
|   inset: 0; |   inset: 0; | ||||||
|   display: grid; |   display: grid; | ||||||
|   grid-template-columns: repeat(7, 1fr); |   grid-template-columns: repeat(7, 1fr); | ||||||
|   margin-top: 1.8em; |   margin-top: 1.0rem; | ||||||
|   pointer-events: none; |   pointer-events: none; | ||||||
| } | } | ||||||
| .segment-grid { | .segment-grid { | ||||||
| @@ -582,7 +580,7 @@ function applyRangeDuringDrag(st, startDate, endDate) { | |||||||
|   border-radius: 1rem; |   border-radius: 1rem; | ||||||
|   /* Font-size so that ascender+descender exactly fills the row height: |   /* Font-size so that ascender+descender exactly fills the row height: | ||||||
|     given total = asc+desc at 1em (hardcoded 1.15), font-size = rowHeight / total */ |     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-size: calc(var(--segment-row-height, 1.5rem) / 1.15); | ||||||
|   font-weight: 500; |   font-weight: 500; | ||||||
|   cursor: grab; |   cursor: grab; | ||||||
|   pointer-events: auto; |   pointer-events: auto; | ||||||
|   | |||||||
| @@ -1,7 +1,13 @@ | |||||||
| <template> | <template> | ||||||
|   <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" | ||||||
|  |         ref="headerControlsRef" | ||||||
|  |         class="header-controls" | ||||||
|  |         @focusin="handleFocusIn" | ||||||
|  |         @focusout="handleFocusOut" | ||||||
|  |       > | ||||||
|         <div class="search-with-spacer"> |         <div class="search-with-spacer"> | ||||||
|           <!-- Shrinkable spacer to align search with week label column; smoothly shrinks as needed --> |           <!-- Shrinkable spacer to align search with week label column; smoothly shrinks as needed --> | ||||||
|           <div class="pre-search-spacer" aria-hidden="true"></div> |           <div class="pre-search-spacer" aria-hidden="true"></div> | ||||||
| @@ -69,7 +75,7 @@ | |||||||
| </template> | </template> | ||||||
|  |  | ||||||
| <script setup> | <script setup> | ||||||
| import { computed, ref, onMounted, onBeforeUnmount, defineExpose, nextTick } from 'vue' | import { computed, ref, onMounted, onBeforeUnmount, nextTick, watch } from 'vue' | ||||||
| import { useCalendarStore } from '@/stores/CalendarStore' | import { useCalendarStore } from '@/stores/CalendarStore' | ||||||
| import { formatTodayString } from '@/utils/date' | import { formatTodayString } from '@/utils/date' | ||||||
| import EventSearch from '@/components/Search.vue' | import EventSearch from '@/components/Search.vue' | ||||||
| @@ -122,18 +128,19 @@ function goToToday() { | |||||||
|  |  | ||||||
| // Screen size detection and visibility toggle | // Screen size detection and visibility toggle | ||||||
| const isVisible = ref(false) | const isVisible = ref(false) | ||||||
| // Track if we auto-opened due to a find (Ctrl/Cmd+F) | const headerControlsRef = ref(null) | ||||||
| const autoOpenedForSearch = ref(false) | const hasFocusWithin = ref(false) | ||||||
|  |  | ||||||
| function checkScreenSize() { | function checkScreenSize() { | ||||||
|   const isSmallScreen = window.innerHeight < 600 |   const isSmallScreen = window.innerHeight < 600 | ||||||
|   // Default to open on large screens, closed on small screens |   isVisible.value = !isSmallScreen || hasFocusWithin.value | ||||||
|   isVisible.value = !isSmallScreen |  | ||||||
| } | } | ||||||
|  |  | ||||||
| function toggleVisibility() { | function toggleVisibility() { | ||||||
|   isVisible.value = !isVisible.value |   isVisible.value = !isVisible.value | ||||||
|   if (!isVisible.value) autoOpenedForSearch.value = false |   if (!isVisible.value) { | ||||||
|  |     hasFocusWithin.value = false | ||||||
|  |   } | ||||||
| } | } | ||||||
|  |  | ||||||
| // Settings dialog integration | // Settings dialog integration | ||||||
| @@ -150,6 +157,25 @@ function openSettings() { | |||||||
|  |  | ||||||
| // Search component ref exposure | // Search component ref exposure | ||||||
| const eventSearchRef = ref(null) | const eventSearchRef = ref(null) | ||||||
|  |  | ||||||
|  | function handleFocusIn() { | ||||||
|  |   hasFocusWithin.value = true | ||||||
|  |   if (!isVisible.value) isVisible.value = true | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function handleFocusOut(event) { | ||||||
|  |   const container = headerControlsRef.value | ||||||
|  |   if (!container) { | ||||||
|  |     hasFocusWithin.value = false | ||||||
|  |     return | ||||||
|  |   } | ||||||
|  |   const nextTarget = event.relatedTarget ?? document.activeElement | ||||||
|  |   if (nextTarget && container.contains(nextTarget)) return | ||||||
|  |   hasFocusWithin.value = false | ||||||
|  |   if (window.innerHeight < 600) { | ||||||
|  |     checkScreenSize() | ||||||
|  |   } | ||||||
|  | } | ||||||
| function focusSearch(selectAll = true) { | function focusSearch(selectAll = true) { | ||||||
|   eventSearchRef.value?.focusSearch(selectAll) |   eventSearchRef.value?.focusSearch(selectAll) | ||||||
| } | } | ||||||
| @@ -167,24 +193,21 @@ function handleGlobalFind(e) { | |||||||
|     e.preventDefault() |     e.preventDefault() | ||||||
|     if (!isVisible.value) { |     if (!isVisible.value) { | ||||||
|       isVisible.value = true |       isVisible.value = true | ||||||
|       autoOpenedForSearch.value = true |  | ||||||
|     } else { |  | ||||||
|       autoOpenedForSearch.value = false |  | ||||||
|     } |     } | ||||||
|     // Defer focus until after transition renders input |     // Defer focus until after transition renders input | ||||||
|     nextTick(() => requestAnimationFrame(() => focusSearch(true))) |     nextTick(() => focusSearch(true)) | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
| function handleSearchActivate(r) { | function handleSearchActivate(r) { | ||||||
|   emit('search-activate', r) |   emit('search-activate', r) | ||||||
|   // Auto close only if we auto-opened for search shortcut |  | ||||||
|   if (autoOpenedForSearch.value) { |  | ||||||
|     isVisible.value = false |  | ||||||
|   } |  | ||||||
|   autoOpenedForSearch.value = false |  | ||||||
| } | } | ||||||
|  |  | ||||||
|  | watch(isVisible, (visible) => { | ||||||
|  |   if (visible) nextTick(() => focusSearch(true)) | ||||||
|  |   else hasFocusWithin.value = false | ||||||
|  | }) | ||||||
|  |  | ||||||
| onMounted(() => { | onMounted(() => { | ||||||
|   checkScreenSize() |   checkScreenSize() | ||||||
|   window.addEventListener('resize', checkScreenSize) |   window.addEventListener('resize', checkScreenSize) | ||||||
| @@ -211,6 +234,9 @@ onBeforeUnmount(() => { | |||||||
|   gap: 1rem; |   gap: 1rem; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @media (max-width: 600px) { | ||||||
|  |   .header-controls { gap: 0.1rem; } | ||||||
|  | } | ||||||
| /* Group search + spacer so outer gap doesn't create unwanted space */ | /* Group search + spacer so outer gap doesn't create unwanted space */ | ||||||
| .search-with-spacer { | .search-with-spacer { | ||||||
|   display: flex; |   display: flex; | ||||||
| @@ -241,7 +267,7 @@ onBeforeUnmount(() => { | |||||||
|   padding: 0; |   padding: 0; | ||||||
|   margin: 0.5em; |   margin: 0.5em; | ||||||
|   cursor: pointer; |   cursor: pointer; | ||||||
|   font-size: 1em; |   font-size: 1rem; | ||||||
|   font-weight: 700; |   font-weight: 700; | ||||||
|   line-height: 1; |   line-height: 1; | ||||||
|   display: inline-flex; |   display: inline-flex; | ||||||
| @@ -269,13 +295,13 @@ onBeforeUnmount(() => { | |||||||
| .header-controls-leave-to { | .header-controls-leave-to { | ||||||
|   opacity: 0; |   opacity: 0; | ||||||
|   max-height: 0; |   max-height: 0; | ||||||
|   transform: translateY(-20px); |   transform: translateY(-1rem); | ||||||
| } | } | ||||||
|  |  | ||||||
| .header-controls-enter-to, | .header-controls-enter-to, | ||||||
| .header-controls-leave-from { | .header-controls-leave-from { | ||||||
|   opacity: 1; |   opacity: 1; | ||||||
|   max-height: 100px; |   max-height: 4rem; | ||||||
|   transform: translateY(0); |   transform: translateY(0); | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -330,14 +356,14 @@ onBeforeUnmount(() => { | |||||||
| } | } | ||||||
|  |  | ||||||
| .today-date { | .today-date { | ||||||
|   font-size: 1.5em; |   font-size: 1.5rem; | ||||||
|   white-space: pre-line; |   white-space: pre-line; | ||||||
|   text-align: center; |   text-align: center; | ||||||
| } | } | ||||||
|  |  | ||||||
| .current-time { | .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-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; |   font-size: 3.6rem; | ||||||
|   white-space: nowrap; |   white-space: nowrap; | ||||||
|   text-align: center; |   text-align: center; | ||||||
|   cursor: pointer; |   cursor: pointer; | ||||||
|   | |||||||
| @@ -245,6 +245,7 @@ function onWheel(e) { | |||||||
|   justify-content: center; |   justify-content: center; | ||||||
|   gap: 0.25rem; |   gap: 0.25rem; | ||||||
|   background: none; |   background: none; | ||||||
|  |   font-size: 1rem; | ||||||
|   font-variant-numeric: tabular-nums; |   font-variant-numeric: tabular-nums; | ||||||
|   touch-action: none; |   touch-action: none; | ||||||
| } | } | ||||||
|   | |||||||
| @@ -29,14 +29,11 @@ | |||||||
|         ><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"> |  | ||||||
|       No matches |  | ||||||
|     </div> |  | ||||||
|   </div> |   </div> | ||||||
| </template> | </template> | ||||||
|  |  | ||||||
| <script setup> | <script setup> | ||||||
| import { ref, watch, nextTick, computed, defineExpose, onUnmounted, onMounted } from 'vue' | import { ref, watch, nextTick, computed, onUnmounted, onMounted } from 'vue' | ||||||
| import { useCalendarStore } from '@/stores/CalendarStore' | import { useCalendarStore } from '@/stores/CalendarStore' | ||||||
| import { | import { | ||||||
|   fromLocalString, |   fromLocalString, | ||||||
| @@ -63,7 +60,9 @@ const searchIndex = ref(0) | |||||||
| const searchInputRef = ref(null) | const searchInputRef = ref(null) | ||||||
| let previewTimer = null | let previewTimer = null | ||||||
|  |  | ||||||
| const shortcut = /Mac/.test(navigator.userAgent) ? '⌘F' | // Note: Android is also Linux. HarmonyOS 5 doesn't include "Linux". | ||||||
|  | const shortcut = /Android/.test(navigator.userAgent) ? '' | ||||||
|  |   : /Mac/.test(navigator.userAgent) ? '⌘F' | ||||||
|   : /Windows|Linux/.test(navigator.userAgent) ? 'Ctrl+F' |   : /Windows|Linux/.test(navigator.userAgent) ? 'Ctrl+F' | ||||||
|   : '' |   : '' | ||||||
|  |  | ||||||
| @@ -485,9 +484,9 @@ function parseGoToDateCandidate(input, refStr) { | |||||||
|   padding: 0.32rem 0.5rem; |   padding: 0.32rem 0.5rem; | ||||||
|   padding-inline-start: 2.05rem; /* increased space for icon */ |   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: .1rem 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-size: 1rem; | ||||||
|   line-height: 1.1; |   line-height: 1.1; | ||||||
|   color: var(--ink); |   color: var(--ink); | ||||||
|   outline: none; |   outline: none; | ||||||
| @@ -530,7 +529,7 @@ function parseGoToDateCandidate(input, refStr) { | |||||||
|   background: color-mix(in srgb, var(--panel) 85%, transparent); |   background: color-mix(in srgb, var(--panel) 85%, transparent); | ||||||
|   padding: 0.15rem 0.3rem; |   padding: 0.15rem 0.3rem; | ||||||
|   border-radius: 0.25rem; |   border-radius: 0.25rem; | ||||||
|   border: 1px solid color-mix(in srgb, var(--muted) 25%, transparent); |   border: .1rem solid color-mix(in srgb, var(--muted) 25%, transparent); | ||||||
| } | } | ||||||
|  |  | ||||||
| .search-bar input:focus + .shortcut-hint, | .search-bar input:focus + .shortcut-hint, | ||||||
| @@ -548,8 +547,7 @@ function parseGoToDateCandidate(input, refStr) { | |||||||
|   padding: 0.2rem; |   padding: 0.2rem; | ||||||
|   background: color-mix(in srgb, var(--panel) 92%, transparent); |   background: color-mix(in srgb, var(--panel) 92%, transparent); | ||||||
|   backdrop-filter: blur(0.6em); |   backdrop-filter: blur(0.6em); | ||||||
|   -webkit-backdrop-filter: blur(0.6em); |   border: .1rem solid color-mix(in srgb, var(--muted) 35%, transparent); | ||||||
|   border: 1px solid color-mix(in srgb, var(--muted) 35%, transparent); |  | ||||||
|   border-radius: 0.55rem; |   border-radius: 0.55rem; | ||||||
|   max-height: 16rem; |   max-height: 16rem; | ||||||
|   overflow: auto; |   overflow: auto; | ||||||
| @@ -590,8 +588,7 @@ function parseGoToDateCandidate(input, refStr) { | |||||||
|   padding: 0.45rem 0.6rem; |   padding: 0.45rem 0.6rem; | ||||||
|   background: color-mix(in srgb, var(--panel) 92%, transparent); |   background: color-mix(in srgb, var(--panel) 92%, transparent); | ||||||
|   backdrop-filter: blur(0.6em); |   backdrop-filter: blur(0.6em); | ||||||
|   -webkit-backdrop-filter: blur(0.6em); |   border: .1rem solid color-mix(in srgb, var(--muted) 35%, transparent); | ||||||
|   border: 1px solid color-mix(in srgb, var(--muted) 35%, transparent); |  | ||||||
|   border-radius: 0.55rem; |   border-radius: 0.55rem; | ||||||
|   box-shadow: 0 0.5em 1.25em rgba(0, 0, 0, 0.3); |   box-shadow: 0 0.5em 1.25em rgba(0, 0, 0, 0.3); | ||||||
|   font-size: 0.7rem; |   font-size: 0.7rem; | ||||||
|   | |||||||
| @@ -253,11 +253,12 @@ defineExpose({ open }) | |||||||
|   border-inline-start: 2px solid var(--border-color); |   border-inline-start: 2px solid var(--border-color); | ||||||
| } | } | ||||||
| select { | select { | ||||||
|   border: 1px solid var(--muted); |   border: .1rem solid var(--muted); | ||||||
|   background: var(--panel-alt, transparent); |   background: var(--panel-alt, transparent); | ||||||
|   color: var(--ink); |   color: var(--ink); | ||||||
|   padding: 0.4rem 0.5rem; |   padding: 0.4rem 0.5rem; | ||||||
|   border-radius: 0.4rem; |   border-radius: 0.4rem; | ||||||
|  |   font-size: 1rem; | ||||||
| } | } | ||||||
|  |  | ||||||
| .holiday-row { | .holiday-row { | ||||||
| @@ -273,7 +274,7 @@ select { | |||||||
|  |  | ||||||
| .state-select { | .state-select { | ||||||
|   flex: 0 0 auto; |   flex: 0 0 auto; | ||||||
|   min-width: 120px; |   min-width: 4rem; | ||||||
| } | } | ||||||
|  |  | ||||||
| .footer-row { | .footer-row { | ||||||
| @@ -291,12 +292,13 @@ select { | |||||||
|   gap: 0.5rem; |   gap: 0.5rem; | ||||||
| } | } | ||||||
| .ec-btn { | .ec-btn { | ||||||
|   border: 1px solid var(--muted); |   border: .1rem solid var(--muted); | ||||||
|   background: transparent; |   background: transparent; | ||||||
|   color: var(--ink); |   color: var(--ink); | ||||||
|   padding: 0.5rem 0.8rem; |   padding: 0.5rem 0.8rem; | ||||||
|   border-radius: 0.4rem; |   border-radius: 0.4rem; | ||||||
|   cursor: pointer; |   cursor: pointer; | ||||||
|  |   font-size: 1rem; | ||||||
| } | } | ||||||
| .ec-btn.close-btn { | .ec-btn.close-btn { | ||||||
|   background: var(--panel-alt); |   background: var(--panel-alt); | ||||||
|   | |||||||
| @@ -287,20 +287,20 @@ export function createVirtualWeekManager({ | |||||||
|   function goToToday() { |   function goToToday() { | ||||||
|     const todayDate = new Date(calendarStore.now) |     const todayDate = new Date(calendarStore.now) | ||||||
|     const targetWeekIndex = getWeekIndex(todayDate) |     const targetWeekIndex = getWeekIndex(todayDate) | ||||||
|     scrollToWeekCentered(targetWeekIndex, 'go-to-today', true) |     scrollToWeek(targetWeekIndex, 'go-to-today', true) | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   function scrollToWeekCentered(weekIndex, reason = 'center-scroll', smooth = true) { |   function scrollToWeek(weekIndex, reason = 'scroll', smooth = true) { | ||||||
|     if (weekIndex == null || !isFinite(weekIndex)) return |     if (weekIndex == null || !isFinite(weekIndex)) return | ||||||
|     const maxScroll = Math.max(0, contentHeight.value - viewportHeight.value) |     const maxScroll = Math.max(0, contentHeight.value - viewportHeight.value) | ||||||
|     const baseTop = (weekIndex - minVirtualWeek.value) * rowHeight.value |     // Scroll so that the top of the viewport aligns with the top of the previous week, | ||||||
|     // Center: subtract half viewport minus half row height |     // making the target week the second visible week row | ||||||
|     let newScrollTop = baseTop - (viewportHeight.value / 2 - rowHeight.value / 2) |     const newScrollTop = (weekIndex - 1 - minVirtualWeek.value) * rowHeight.value | ||||||
|     newScrollTop = Math.max(0, Math.min(newScrollTop, maxScroll)) |     const clampedScrollTop = Math.max(0, Math.min(newScrollTop, maxScroll)) | ||||||
|     if (smooth && viewport.value && typeof viewport.value.scrollTo === 'function') { |     if (smooth && viewport.value && typeof viewport.value.scrollTo === 'function') { | ||||||
|       viewport.value.scrollTo({ top: newScrollTop, behavior: 'smooth' }) |       viewport.value.scrollTo({ top: clampedScrollTop, behavior: 'smooth' }) | ||||||
|     } else if (setScrollTopFn) { |     } else if (setScrollTopFn) { | ||||||
|       setScrollTopFn(newScrollTop, reason) |       setScrollTopFn(clampedScrollTop, reason) | ||||||
|       scheduleWindowUpdate(reason) |       scheduleWindowUpdate(reason) | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| @@ -322,7 +322,7 @@ export function createVirtualWeekManager({ | |||||||
|     getWeekIndex, |     getWeekIndex, | ||||||
|     getFirstDayForVirtualWeek, |     getFirstDayForVirtualWeek, | ||||||
|     goToToday, |     goToToday, | ||||||
|     scrollToWeekCentered, |     scrollToWeek, | ||||||
|     handleHeaderYearChange, |     handleHeaderYearChange, | ||||||
|     attachScroll, |     attachScroll, | ||||||
|   } |   } | ||||||
|   | |||||||
| @@ -2,14 +2,14 @@ | |||||||
| import * as dateFns from 'date-fns' | import * as dateFns from 'date-fns' | ||||||
| import { fromZonedTime, toZonedTime } from 'date-fns-tz' | import { fromZonedTime, toZonedTime } from 'date-fns-tz' | ||||||
|  |  | ||||||
| const DEFAULT_TZ = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC' | export const DEFAULT_TZ = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC' | ||||||
|  |  | ||||||
| // Re-exported iso helpers (keep the same exported names used elsewhere) | // Re-exported iso helpers (keep the same exported names used elsewhere) | ||||||
| const getISOWeek = dateFns.getISOWeek | export const getISOWeek = dateFns.getISOWeek | ||||||
| const getISOWeekYear = dateFns.getISOWeekYear | export const getISOWeekYear = dateFns.getISOWeekYear | ||||||
|  |  | ||||||
| // Constants | // Constants | ||||||
| const monthAbbr = [ | export const monthAbbr = [ | ||||||
|   'jan', |   'jan', | ||||||
|   'feb', |   'feb', | ||||||
|   'mar', |   'mar', | ||||||
| @@ -24,15 +24,15 @@ const monthAbbr = [ | |||||||
|   'dec', |   'dec', | ||||||
| ] | ] | ||||||
| // We get scrolling issues if the virtual view is bigger than that | // We get scrolling issues if the virtual view is bigger than that | ||||||
| const MIN_YEAR = 1582 | export const MIN_YEAR = 1582 | ||||||
| const MAX_YEAR = 3000 | export const MAX_YEAR = 3000 | ||||||
|  |  | ||||||
| // Core helpers ------------------------------------------------------------ | // Core helpers ------------------------------------------------------------ | ||||||
| /** | /** | ||||||
|  * Construct a date at local midnight in the specified IANA timezone. |  * Construct a date at local midnight in the specified IANA timezone. | ||||||
|  * Returns a native Date whose wall-clock components in that zone are (Y, M, D 00:00:00). |  * Returns a native Date whose wall-clock components in that zone are (Y, M, D 00:00:00). | ||||||
|  */ |  */ | ||||||
| function makeTZDate(year, monthIndex, day, timeZone = DEFAULT_TZ) { | export function makeTZDate(year, monthIndex, day, timeZone = DEFAULT_TZ) { | ||||||
|   const iso = `${String(year).padStart(4, '0')}-${String(monthIndex + 1).padStart(2, '0')}-${String( |   const iso = `${String(year).padStart(4, '0')}-${String(monthIndex + 1).padStart(2, '0')}-${String( | ||||||
|     day, |     day, | ||||||
|   ).padStart(2, '0')}` |   ).padStart(2, '0')}` | ||||||
| @@ -43,40 +43,40 @@ function makeTZDate(year, monthIndex, day, timeZone = DEFAULT_TZ) { | |||||||
| /** | /** | ||||||
|  * Alias constructor for timezone-specific calendar date (semantic sugar over makeTZDate). |  * Alias constructor for timezone-specific calendar date (semantic sugar over makeTZDate). | ||||||
|  */ |  */ | ||||||
| const TZDate = (year, monthIndex, day, timeZone = DEFAULT_TZ) => | export const TZDate = (year, monthIndex, day, timeZone = DEFAULT_TZ) => | ||||||
|   makeTZDate(year, monthIndex, day, timeZone) |   makeTZDate(year, monthIndex, day, timeZone) | ||||||
|  |  | ||||||
| /** | /** | ||||||
|  * Construct a UTC-based date/time (wrapper for Date.UTC for consistency). |  * Construct a UTC-based date/time (wrapper for Date.UTC for consistency). | ||||||
|  */ |  */ | ||||||
| const UTCDate = (year, monthIndex, day, hour = 0, minute = 0, second = 0, ms = 0) => | export const UTCDate = (year, monthIndex, day, hour = 0, minute = 0, second = 0, ms = 0) => | ||||||
|   new Date(Date.UTC(year, monthIndex, day, hour, minute, second, ms)) |   new Date(Date.UTC(year, monthIndex, day, hour, minute, second, ms)) | ||||||
|  |  | ||||||
| function toLocalString(date = new Date(), timeZone = DEFAULT_TZ) { | export function toLocalString(date = new Date(), timeZone = DEFAULT_TZ) { | ||||||
|   return dateFns.format(toZonedTime(date, timeZone), 'yyyy-MM-dd') |   return dateFns.format(toZonedTime(date, timeZone), 'yyyy-MM-dd') | ||||||
| } | } | ||||||
|  |  | ||||||
| function fromLocalString(dateString, timeZone = DEFAULT_TZ) { | export function fromLocalString(dateString, timeZone = DEFAULT_TZ) { | ||||||
|   if (!dateString) return makeTZDate(1970, 0, 1, timeZone) |   if (!dateString) return makeTZDate(1970, 0, 1, timeZone) | ||||||
|   const parsed = dateFns.parseISO(dateString) |   const parsed = dateFns.parseISO(dateString) | ||||||
|   const utcDate = fromZonedTime(`${dateString}T00:00:00`, timeZone) |   const utcDate = fromZonedTime(`${dateString}T00:00:00`, timeZone) | ||||||
|   return toZonedTime(utcDate, timeZone) || parsed |   return toZonedTime(utcDate, timeZone) || parsed | ||||||
| } | } | ||||||
|  |  | ||||||
| function getMondayOfISOWeek(date, timeZone = DEFAULT_TZ) { | export function getMondayOfISOWeek(date, timeZone = DEFAULT_TZ) { | ||||||
|   const d = toZonedTime(date, timeZone) |   const d = toZonedTime(date, timeZone) | ||||||
|   const dow = (dateFns.getDay(d) + 6) % 7 // Monday=0 |   const dow = (dateFns.getDay(d) + 6) % 7 // Monday=0 | ||||||
|   return dateFns.addDays(dateFns.startOfDay(d), -dow) |   return dateFns.addDays(dateFns.startOfDay(d), -dow) | ||||||
| } | } | ||||||
|  |  | ||||||
| const mondayIndex = (d) => (dateFns.getDay(d) + 6) % 7 | export const mondayIndex = (d) => (dateFns.getDay(d) + 6) % 7 | ||||||
|  |  | ||||||
| // (Recurrence utilities moved to events.js) | // (Recurrence utilities moved to events.js) | ||||||
|  |  | ||||||
| // Utility formatting & localization --------------------------------------- | // Utility formatting & localization --------------------------------------- | ||||||
| const pad = (n) => String(n).padStart(2, '0') | export const pad = (n) => String(n).padStart(2, '0') | ||||||
|  |  | ||||||
| function daysInclusive(aStr, bStr, timeZone = DEFAULT_TZ) { | export function daysInclusive(aStr, bStr, timeZone = DEFAULT_TZ) { | ||||||
|   const a = fromLocalString(aStr, timeZone) |   const a = fromLocalString(aStr, timeZone) | ||||||
|   const b = fromLocalString(bStr, timeZone) |   const b = fromLocalString(bStr, timeZone) | ||||||
|   return ( |   return ( | ||||||
| @@ -84,12 +84,12 @@ function daysInclusive(aStr, bStr, timeZone = DEFAULT_TZ) { | |||||||
|   ) |   ) | ||||||
| } | } | ||||||
|  |  | ||||||
| function addDaysStr(str, n, timeZone = DEFAULT_TZ) { | export function addDaysStr(str, n, timeZone = DEFAULT_TZ) { | ||||||
|   return toLocalString(dateFns.addDays(fromLocalString(str, timeZone), n), timeZone) |   return toLocalString(dateFns.addDays(fromLocalString(str, timeZone), n), timeZone) | ||||||
| } | } | ||||||
|  |  | ||||||
| // Weekday name helpers now return Sunday-first ordering (index 0 = Sunday ... 6 = Saturday) | // Weekday name helpers now return Sunday-first ordering (index 0 = Sunday ... 6 = Saturday) | ||||||
| function getLocalizedWeekdayNames(timeZone = DEFAULT_TZ) { | export function getLocalizedWeekdayNames(timeZone = DEFAULT_TZ) { | ||||||
|   const sunday = makeTZDate(2025, 0, 5, timeZone) // a Sunday |   const sunday = makeTZDate(2025, 0, 5, timeZone) // a Sunday | ||||||
|   return Array.from({ length: 7 }, (_, i) => |   return Array.from({ length: 7 }, (_, i) => | ||||||
|     new Intl.DateTimeFormat(undefined, { weekday: 'short', timeZone }).format( |     new Intl.DateTimeFormat(undefined, { weekday: 'short', timeZone }).format( | ||||||
| @@ -99,7 +99,7 @@ function getLocalizedWeekdayNames(timeZone = DEFAULT_TZ) { | |||||||
| } | } | ||||||
|  |  | ||||||
| // Long (wide) localized weekday names, Sunday-first ordering | // Long (wide) localized weekday names, Sunday-first ordering | ||||||
| function getLocalizedWeekdayNamesLong(timeZone = DEFAULT_TZ) { | export function getLocalizedWeekdayNamesLong(timeZone = DEFAULT_TZ) { | ||||||
|   const sunday = makeTZDate(2025, 0, 5, timeZone) |   const sunday = makeTZDate(2025, 0, 5, timeZone) | ||||||
|   return Array.from({ length: 7 }, (_, i) => |   return Array.from({ length: 7 }, (_, i) => | ||||||
|     new Intl.DateTimeFormat(undefined, { weekday: 'long', timeZone }).format( |     new Intl.DateTimeFormat(undefined, { weekday: 'long', timeZone }).format( | ||||||
| @@ -108,26 +108,26 @@ function getLocalizedWeekdayNamesLong(timeZone = DEFAULT_TZ) { | |||||||
|   ) |   ) | ||||||
| } | } | ||||||
|  |  | ||||||
| function getLocaleFirstDay() { | export function getLocaleFirstDay() { | ||||||
|   const day = new Intl.Locale(navigator.language).weekInfo?.firstDay ?? 1 |   const day = new Intl.Locale(navigator.language).weekInfo?.firstDay ?? 1 | ||||||
|   return day % 7 |   return day % 7 | ||||||
| } | } | ||||||
|  |  | ||||||
| function getLocaleWeekendDays() { | export function getLocaleWeekendDays() { | ||||||
|   const wk = new Set(new Intl.Locale(navigator.language).weekInfo?.weekend ?? [6, 7]) |   const wk = new Set(new Intl.Locale(navigator.language).weekInfo?.weekend ?? [6, 7]) | ||||||
|   return Array.from({ length: 7 }, (_, i) => wk.has(1 + ((i + 6) % 7))) |   return Array.from({ length: 7 }, (_, i) => wk.has(1 + ((i + 6) % 7))) | ||||||
| } | } | ||||||
|  |  | ||||||
| function reorderByFirstDay(days, firstDay) { | export function reorderByFirstDay(days, firstDay) { | ||||||
|   return Array.from({ length: 7 }, (_, i) => days[(i + firstDay) % 7]) |   return Array.from({ length: 7 }, (_, i) => days[(i + firstDay) % 7]) | ||||||
| } | } | ||||||
|  |  | ||||||
| function getLocalizedMonthName(idx, short = false, timeZone = DEFAULT_TZ) { | export function getLocalizedMonthName(idx, short = false, timeZone = DEFAULT_TZ) { | ||||||
|   const d = makeTZDate(2025, idx, 1, timeZone) |   const d = makeTZDate(2025, idx, 1, timeZone) | ||||||
|   return new Intl.DateTimeFormat(undefined, { month: short ? 'short' : 'long', timeZone }).format(d) |   return new Intl.DateTimeFormat(undefined, { month: short ? 'short' : 'long', timeZone }).format(d) | ||||||
| } | } | ||||||
|  |  | ||||||
| function formatDateRange(startDate, endDate, timeZone = DEFAULT_TZ) { | export function formatDateRange(startDate, endDate, timeZone = DEFAULT_TZ) { | ||||||
|   const a = toLocalString(startDate, timeZone) |   const a = toLocalString(startDate, timeZone) | ||||||
|   const b = toLocalString(endDate, timeZone) |   const b = toLocalString(endDate, timeZone) | ||||||
|   if (a === b) return a |   if (a === b) return a | ||||||
| @@ -138,7 +138,7 @@ function formatDateRange(startDate, endDate, timeZone = DEFAULT_TZ) { | |||||||
|   return `${a}/${b}` |   return `${a}/${b}` | ||||||
| } | } | ||||||
|  |  | ||||||
| function lunarPhaseSymbol(date) { | export function lunarPhaseSymbol(date) { | ||||||
|   // Reference new moon (J2000 era) used for approximate phase calculations |   // Reference new moon (J2000 era) used for approximate phase calculations | ||||||
|   const ref = UTCDate(2000, 0, 6, 18, 14, 0) |   const ref = UTCDate(2000, 0, 6, 18, 14, 0) | ||||||
|   const obs = new Date(date) |   const obs = new Date(date) | ||||||
| @@ -165,14 +165,14 @@ function lunarPhaseSymbol(date) { | |||||||
| /** | /** | ||||||
|  * Format date as short localized string (e.g., "Jan 15") |  * Format date as short localized string (e.g., "Jan 15") | ||||||
|  */ |  */ | ||||||
| function formatDateShort(date) { | export function formatDateShort(date) { | ||||||
|   return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }).replace(/, /, ' ') |   return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }).replace(/, /, ' ') | ||||||
| } | } | ||||||
|  |  | ||||||
| /** | /** | ||||||
|  * Format date as long localized string with optional year (e.g., "Mon Jan 15" or "Mon Jan 15, 2025") |  * Format date as long localized string with optional year (e.g., "Mon Jan 15" or "Mon Jan 15, 2025") | ||||||
|  */ |  */ | ||||||
| function formatDateLong(date, includeYear = false) { | export function formatDateLong(date, includeYear = false) { | ||||||
|   const opts = { |   const opts = { | ||||||
|     weekday: 'short', |     weekday: 'short', | ||||||
|     month: 'short', |     month: 'short', | ||||||
| @@ -185,45 +185,9 @@ 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, weekday = "long", month = "long") { | export function formatTodayString(date, weekday = "long", month = "long") { | ||||||
|   const formatted = date |   const formatted = date | ||||||
|     .toLocaleDateString(undefined, { weekday, month, 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) | ||||||
| } | } | ||||||
|  |  | ||||||
| export { |  | ||||||
|   // constants |  | ||||||
|   monthAbbr, |  | ||||||
|   MIN_YEAR, |  | ||||||
|   MAX_YEAR, |  | ||||||
|   DEFAULT_TZ, |  | ||||||
|   // core tz helpers |  | ||||||
|   makeTZDate, |  | ||||||
|   toLocalString, |  | ||||||
|   fromLocalString, |  | ||||||
|   // recurrence |  | ||||||
|   getMondayOfISOWeek, |  | ||||||
|   mondayIndex, |  | ||||||
|   // formatting & localization |  | ||||||
|   pad, |  | ||||||
|   daysInclusive, |  | ||||||
|   addDaysStr, |  | ||||||
|   getLocalizedWeekdayNames, |  | ||||||
|   getLocalizedWeekdayNamesLong, |  | ||||||
|   getLocaleFirstDay, |  | ||||||
|   getLocaleWeekendDays, |  | ||||||
|   reorderByFirstDay, |  | ||||||
|   getLocalizedMonthName, |  | ||||||
|   formatDateRange, |  | ||||||
|   formatDateShort, |  | ||||||
|   formatDateLong, |  | ||||||
|   formatTodayString, |  | ||||||
|   lunarPhaseSymbol, |  | ||||||
|   // iso helpers re-export |  | ||||||
|   getISOWeek, |  | ||||||
|   getISOWeekYear, |  | ||||||
|   // constructors |  | ||||||
|   TZDate, |  | ||||||
|   UTCDate, |  | ||||||
| } |  | ||||||
|   | |||||||
| @@ -15,4 +15,18 @@ export default defineConfig({ | |||||||
|       '@': fileURLToPath(new URL('./src', import.meta.url)) |       '@': fileURLToPath(new URL('./src', import.meta.url)) | ||||||
|     }, |     }, | ||||||
|   }, |   }, | ||||||
|  |   build: { | ||||||
|  |     chunkSizeWarningLimit: 1500, | ||||||
|  |     rollupOptions: { | ||||||
|  |       output: { | ||||||
|  |         manualChunks: (id) => { | ||||||
|  |           if (id.includes('node_modules/date-fns') ||  | ||||||
|  |               id.includes('node_modules/date-fns-tz') ||  | ||||||
|  |               id.includes('node_modules/date-holidays')) { | ||||||
|  |             return 'vendor-date-libs'; | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   } | ||||||
| }) | }) | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user