18 Commits
v0.3.3 ... main

Author SHA1 Message Date
Leo Vasanko
bc16473715 Soften the scroll blur effect a tiny bit to avoid glitches when missing a frame. 2025-09-25 10:28:35 -06:00
Leo Vasanko
b30618031a Larger month label column so that it is easier to use on mobile devices. 2025-09-25 10:26:10 -06:00
Leo Vasanko
cb60c589e3 Move calendarweeks etc. in DOM after the month name bar, to make them render on top of it and not under. 2025-09-25 10:17:22 -06:00
Leo Vasanko
3c5cad0afe Dark autumn colors tuning. 2025-09-25 10:05:59 -06:00
Leo Vasanko
6d91833f0f Fix touch moving of dialogs. 2025-09-25 10:02:27 -06:00
Leo Vasanko
a3e9e13b29 Split the bloated date libs to a separate module that rarely changes, speeding up page loads when the app itself has changed. 2025-09-25 09:34:03 -06:00
Leo Vasanko
73ce1b1be2 Dark mode month color tuneup to avoid too dark shades and to match spring colors with light mode. 2025-09-25 09:29:07 -06:00
Leo Vasanko
93fc600a7a Do not import defineExpose because it is now a compiler macro. 2025-09-25 08:41:40 -06:00
Leo Vasanko
09df4bed5e Remove export block in favor of directly exporting symbols. 2025-09-25 08:39:25 -06:00
Leo Vasanko
86a1a4d772 Scroll the week to view the second row visible (showing also the previous week) rather than centered, for better UX. 2025-09-25 08:35:46 -06:00
Leo Vasanko
159bbf816d Prefer rem, assure that dialogs scale correctly. 2025-09-25 08:29:14 -06:00
Leo Vasanko
c41a3b84f4 Do not show results box and "no results" when there are no results. Just show nothing. 2025-09-25 08:03:43 -06:00
Leo Vasanko
6c396bab61 Hopefully more robust header autoclose logic to avoid OSD keyboard causing a just-focused search bar getting hidden. 2025-09-25 08:00:12 -06:00
Leo Vasanko
8a508f273d Optimise blur. 2025-09-25 07:36:42 -06:00
Leo Vasanko
704773dc8a Show holiday name tooltip on the entire day cell. 2025-09-25 07:23:22 -06:00
Leo Vasanko
0859e77b6a Styling changes, avoid glitches even on very small screens. 2025-09-25 07:19:40 -06:00
Leo Vasanko
d461a42ae5 Hide shortcut key on Android. 2025-09-24 17:03:19 -06:00
Leo Vasanko
ade17b80b1 Shrink header by removing gaps on small screens. 2025-09-24 16:57:08 -06:00
16 changed files with 236 additions and 244 deletions

View File

@@ -95,18 +95,18 @@
.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%) }
.dec { background: hsl(220 50% 12%) }
.jan { background: hsl(220 50% 8%) }
.feb { background: hsl(220 50% 12%) }
.mar { background: hsl(130 40% 20%) }
.apr { background: hsl(130 60% 15%) }
.may { background: hsl(130 80% 10%) }
.jun { background: hsl(50 85% 16%) }
.jul { background: hsl(50 85% 20%) }
.aug { background: hsl(50 85% 16%) }
.sep { background: hsl(22 100% 14%) }
.oct { background: hsl(22 90% 10%) }
.nov { background: hsl(22 80% 7%) }
}
/* Light mode — gray shades and colors */

View File

@@ -1,13 +1,15 @@
:root {
--week-w: 3rem;
--day-w: 1fr;
--month-w: 2rem;
--month-w: 3rem;
--row-h: 15vh;
}
* {
box-sizing: border-box;
}
html {
font-size: min(3vmin, 16px);
}
html,
body {
height: 100%;
@@ -16,7 +18,7 @@ body {
body {
margin: 0;
font:
500 14px/1.2 ui-sans-serif,
500 1rem/1.2 ui-sans-serif,
system-ui,
-apple-system,
Segoe UI,
@@ -90,7 +92,7 @@ header {
width: 100%;
color: var(--muted);
cursor: ns-resize;
font-size: 1.2em;
font-size: 1.2rem;
}
.week-label {
@@ -109,7 +111,7 @@ header {
.month-name-label {
grid-column: -2 / -1;
font-size: 2em;
font-size: 2rem;
font-weight: 700;
color: var(--muted);
display: flex;

View File

@@ -24,7 +24,6 @@ const modalPosition = ref({ x: 0, y: 0 })
const dialogWidth = ref(null)
const dialogHeight = ref(null)
const hasMoved = ref(false)
const margin = 8 // viewport margin in px to keep dialog from touching edges
// Collect incoming non-prop attributes (e.g., class / style from usage site)
const attrs = useAttrs()
@@ -62,8 +61,8 @@ function handleDrag(event) {
const h = dialogHeight.value || modalRef.value?.offsetHeight || 0
const vw = window.innerWidth
const vh = window.innerHeight
x = clamp(x, margin, Math.max(margin, vw - w - margin))
y = clamp(y, margin, Math.max(margin, vh - h - margin))
x = clamp(x, 0, Math.max(0, vw - w - 0))
y = clamp(y, 0, Math.max(0, vh - h - 0))
modalPosition.value = { x, y }
event.preventDefault()
}
@@ -97,10 +96,14 @@ const modalStyle = computed(() => {
// <BaseDialog class="settings-modal" :style="{ top: '...' }" /> works even with fragment root.
const modalAttrs = computed(() => {
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 {
...rest,
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
if (!anchor) return
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 h = modalRef.value?.offsetHeight || dialogHeight.value || 200
const vw = window.innerWidth
@@ -128,8 +132,8 @@ function positionNearAnchor() {
let x = rect.left
let y = rect.bottom + offsetY
// If anchor is wider than dialog and would overflow right edge, clamp; otherwise keep left align
x = clamp(x, margin, Math.max(margin, vw - w - margin))
y = clamp(y, margin, Math.max(margin, vh - h - margin))
x = clamp(x, 0, Math.max(0, vw - w - 0))
y = clamp(y, 0, Math.max(0, vh - h - 0))
modalPosition.value = { x, y }
}
@@ -172,8 +176,8 @@ function handleResize() {
const vw = window.innerWidth
const vh = window.innerHeight
modalPosition.value = {
x: clamp(modalPosition.value.x, margin, Math.max(margin, vw - w - margin)),
y: clamp(modalPosition.value.y, margin, Math.max(margin, vh - h - margin)),
x: clamp(modalPosition.value.x, 0, Math.max(0, vw - w - 0)),
y: clamp(modalPosition.value.y, 0, Math.max(0, vh - h - 0)),
}
}
}
@@ -206,19 +210,18 @@ onUnmounted(() => {
</div>
</template>
<style scoped>
<style>
.ec-modal {
position: fixed; /* still fixed for overlay & dragging, but now top/left are set dynamically */
background: color-mix(in srgb, var(--panel) 85%, transparent);
backdrop-filter: blur(0.625em);
-webkit-backdrop-filter: blur(0.625em);
color: var(--ink);
border-radius: 0.6em;
min-height: 23em;
min-width: 26em;
max-width: min(34em, 90vw);
box-shadow: 0 0.6em 1.8em rgba(0, 0, 0, 0.35);
border: 0.0625em solid color-mix(in srgb, var(--muted) 40%, transparent);
border-radius: 0.6rem;
min-height: 23rem;
min-width: 26rem;
max-width: min(34rem, 90vw);
box-shadow: 0 0.6rem 1.8rem rgba(0, 0, 0, 0.35);
border: 0.0625rem solid color-mix(in srgb, var(--muted) 40%, transparent);
z-index: 1000;
overflow: hidden;
}
@@ -230,35 +233,36 @@ onUnmounted(() => {
.ec-form {
display: grid;
grid-template-rows: auto 1fr auto;
min-height: 23em;
min-height: 23rem;
height: 100%;
width: 100%;
}
.ec-header {
cursor: move;
user-select: none;
padding: 0.75em 1em 0.5em 1em;
touch-action: none;
padding: 0.75rem 1rem 0.5rem 1rem;
display: flex;
justify-content: space-between;
align-items: center;
gap: 1em;
gap: 1rem;
}
.ec-title {
margin: 0;
font-size: 1.1em;
font-size: 1.1rem;
}
.ec-body {
display: flex;
flex-direction: column;
gap: 1em;
padding: 0 1em 0.5em 1em;
gap: 1rem;
padding: 0 1rem 0.5rem 1rem;
overflow: auto;
}
.ec-footer {
padding: 0.5em 1em 1em 1em;
padding: 0.5rem 1rem 1rem 1rem;
display: flex;
justify-content: space-between;
gap: 1em;
gap: 1rem;
flex-wrap: wrap;
}
</style>

View File

@@ -9,14 +9,12 @@ const props = defineProps({
// Reactive viewport width detection
const isNarrowView = ref(false)
const isVeryNarrowView = ref(false)
const isSmallView = ref(false)
function checkViewportWidth() {
const width = window.innerWidth
isSmallView.value = width < 800
isNarrowView.value = width < 600
isVeryNarrowView.value = width < 400
}
onMounted(() => {
@@ -30,27 +28,12 @@ onBeforeUnmount(() => {
const formattedDate = computed(() => {
const date = fromLocalString(props.day.date)
let 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 options = { weekday: 'short', day: 'numeric', month: 'short' }
// Remove weekday on very small viewports
if (isNarrowView.value) options = { day: 'numeric', month: 'short' }
let formatted = date.toLocaleDateString(undefined, options)
// Below 700px, replace first space with newline to force weekday on separate line
if (isSmallView.value && !isNarrowView.value && !isVeryNarrowView.value) {
formatted = formatted.replace(/\s/, '\n')
}
// Split between weekday and day/month on small viewports
if (isSmallView.value) formatted = formatted.replace(/\s/, '\n')
// 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
formatted = formatted.replace(/\s+(?=\S+$)/, '\u00A0')
@@ -65,11 +48,12 @@ const formattedDate = computed(() => {
: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 }]"
:data-date="props.day.date"
:title="props.day.holiday?.name"
>
<span class="compact-date">{{ formattedDate }}</span>
<h1 class="day-number">{{ props.day.displayText }}</h1>
<span v-if="props.day.lunarPhase" class="lunar-phase">{{ props.day.lunarPhase }}</span>
<div v-if="props.day.holiday" class="holiday-info" dir="auto" :title="props.day.holiday.name">
<div v-if="props.day.holiday" class="holiday-info" dir="auto">
{{ props.day.holiday.name }}
</div>
</div>
@@ -167,10 +151,9 @@ const formattedDate = computed(() => {
.lunar-phase {
grid-area: lunar-phase;
position: absolute;
inset-block-start: 0.5em;
inset-inline-end: 0.2em;
font-size: 0.8em;
opacity: 0.7;
inset-block-start: 0.1em;
inset-inline-end: 0.1em;
font-size: 0.8rem;
}
.compact-date {
@@ -178,7 +161,8 @@ const formattedDate = computed(() => {
top: 0.25em;
left: 0.25em;
inset-inline-end: 1rem; /* Space for lunar phase */
font-weight: 400;
font-weight: 300;
font-size: 0.8rem;
color: var(--ink);
line-height: 1;
pointer-events: none;
@@ -204,7 +188,7 @@ const formattedDate = computed(() => {
overflow: hidden;
max-width: 100%;
color: var(--holiday);
font-size: 1em;
font-size: 0.8rem;
font-weight: 400;
line-height: 1.0;
padding-inline: 0.15em;

View File

@@ -119,7 +119,7 @@ const weekdayNames = computed(() => {
.calendar-header {
display: grid;
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;
flex-shrink: 0;
width: 100%;
@@ -135,7 +135,7 @@ const weekdayNames = computed(() => {
text-transform: uppercase;
text-align: center;
font-weight: 600;
font-size: 1.2em;
font-size: 1.2rem;
}
.dow.weekend {
color: var(--weekend);

View File

@@ -54,9 +54,8 @@ let _blurFrame = null
function _updateMotionBlur() {
const pos = scrollTop.value || 0
if (_lastBlurPos) {
blurAmount.value = 0.5 * Math.abs(pos - _lastBlurPos)
}
if (_lastBlurPos) blurAmount.value = 0.1 * blurAmount.value + 0.9 * Math.min(20, 0.5 * Math.abs(pos - _lastBlurPos))
if (!_lastBlurPos || blurAmount.value < 5) blurAmount.value = 0
_lastBlurPos = pos
_blurFrame = requestAnimationFrame(_updateMotionBlur)
}
@@ -198,13 +197,13 @@ const {
getWeekIndex,
getFirstDayForVirtualWeek,
handleHeaderYearChange,
scrollToWeekCentered,
scrollToWeek,
} = 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)
scrollToWeek(weekIndex, 'nav', true)
const diff = Math.abs(weekIndex - centerVisibleWeek.value)
const delay = Math.min(800, diff * 40)
setTimeout(() => {
@@ -229,10 +228,6 @@ const centerVisibleDateStr = computed(() => {
}
})
// createWeek logic moved to virtualWeeks plugin
// goToToday now provided by manager
function clearSelection() {
selection.value = { startDate: null, dayCount: 0 }
}
@@ -546,26 +541,6 @@ window.addEventListener('resize', () => {
/>
<div class="calendar-container">
<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-labels-container" :style="{ height: '100%' }">
<div
@@ -593,6 +568,26 @@ window.addEventListener('resize', () => {
</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>
<!-- Jogwheel overlay captures drag + wheel over month name column -->
<Jogwheel
@@ -655,6 +650,8 @@ header h1 {
.calendar-content {
position: relative;
width: 100%;
grid-column: 1;
grid-row: 1;
}
.weeks-wrapper {
@@ -667,6 +664,8 @@ header h1 {
.month-column-area {
position: relative;
cursor: ns-resize;
grid-column: 2;
grid-row: 1;
}
.month-labels-container {
@@ -687,12 +686,11 @@ header h1 {
.month-label {
width: 100%;
opacity: 0.8;
font-size: 2em;
font-size: 2.2rem;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
z-index: 5;
justify-content: start;
overflow: hidden;
cursor: ns-resize;
user-select: none;

View File

@@ -67,7 +67,7 @@ const handleEventClick = (payload) => {
place-items: center;
width: 100%;
color: var(--muted);
font-size: 1.2em;
font-size: 1.2rem;
font-weight: 500;
user-select: none;
height: var(--row-h);

View File

@@ -6,7 +6,6 @@ import WeekdaySelector from './WeekdaySelector.vue'
import Numeric from './Numeric.vue'
import {
addDaysStr,
getMondayOfISOWeek,
fromLocalString,
formatDateShort,
formatDateLong,
@@ -675,7 +674,7 @@ const recurrenceSummary = computed(() => {
}
.ec-field > span {
font-size: 0.85em;
font-size: 0.85rem;
color: var(--muted);
}
@@ -683,12 +682,13 @@ const recurrenceSummary = computed(() => {
.ec-field input[type='time'],
.ec-field input[type='number'],
.ec-field select {
border: 1px solid var(--muted);
border: .1rem solid var(--muted);
border-radius: 0.4rem;
padding: 0.5rem 0.6rem;
width: 100%;
background: transparent;
color: var(--ink);
font-size: 1rem;
}
.ec-color-swatches {
@@ -726,6 +726,7 @@ const recurrenceSummary = computed(() => {
padding: 0.5em 0.8em;
border-radius: 0.4em;
cursor: pointer;
font-size: 1rem;
transition: all 0.2s ease;
}
@@ -771,7 +772,7 @@ const recurrenceSummary = computed(() => {
}
.ec-field-label {
font-size: 0.85em;
font-size: 0.85rem;
color: var(--muted);
}
@@ -801,7 +802,7 @@ const recurrenceSummary = computed(() => {
}
.ec-weekday-text {
font-size: 0.8em;
font-size: 0.8rem;
font-weight: 500;
text-align: center;
}
@@ -848,12 +849,12 @@ const recurrenceSummary = computed(() => {
align-items: center;
gap: 0.5em;
flex-wrap: wrap;
font-size: 0.75em;
font-size: 0.75rem;
}
.freq-select {
padding: 0.4rem 0.55rem;
font-size: 0.75rem;
border: 1px solid var(--input-border);
border: .1rem solid var(--input-border);
background: var(--panel-alt);
color: var(--ink);
border-radius: 0.45rem;
@@ -867,18 +868,19 @@ const recurrenceSummary = computed(() => {
background: var(--panel-accent);
color: var(--ink);
box-shadow:
0 0 0 1px var(--input-focus),
0 0 0 4px rgba(37, 99, 235, 0.15);
0 0 0 .1rem var(--input-focus),
0 0 0 .4rem rgba(37, 99, 235, 0.15);
}
.interval-input,
.occ-input {
display: none;
}
.ec-field input[type='text'] {
border: 1px solid var(--input-border);
border: .1rem solid var(--input-border);
background: var(--panel-alt);
border-radius: 0.45rem;
padding: 0.4rem 0.5rem;
font-size: 1rem;
transition:
border-color 0.18s ease,
background-color 0.18s ease,
@@ -889,8 +891,8 @@ const recurrenceSummary = computed(() => {
border-color: var(--input-focus);
background: var(--panel-accent);
box-shadow:
0 0 0 1px var(--input-focus),
0 0 0 4px rgba(37, 99, 235, 0.15);
0 0 0 .1rem var(--input-focus),
0 0 0 .4rem rgba(37, 99, 235, 0.15);
}
.hint {
font-size: 0.65rem;
@@ -908,7 +910,7 @@ const recurrenceSummary = computed(() => {
align-items: center;
width: 100%;
padding: 0.6rem 0.8rem;
border: 1px solid var(--muted);
border: .1rem solid var(--muted);
background: var(--panel);
border-radius: 0.4rem;
cursor: pointer;
@@ -931,7 +933,7 @@ const recurrenceSummary = computed(() => {
display: grid;
gap: 0.6rem;
padding: 0.6rem;
border: 1px solid var(--muted);
border: .1rem solid var(--muted);
border-radius: 0.4rem;
background: color-mix(in srgb, var(--muted) 20%, transparent);
}
@@ -945,7 +947,7 @@ const recurrenceSummary = computed(() => {
.ec-repeat-modes .mode-btn {
flex: 1 1 auto;
padding: 0.4rem 0.6rem;
border: 1px solid var(--muted);
border: .1rem solid var(--muted);
background: var(--panel);
border-radius: 0.4rem;
cursor: pointer;

View File

@@ -185,7 +185,7 @@ function segmentKey(seg) {
function getSegmentRowHeight(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) {
@@ -321,18 +321,16 @@ function startLocalDrag(init, evt) {
let originalWeekday = null
let originalPattern = null
if (init.mode === 'move') {
try {
originalWeekday = new Date(init.startDate + 'T00:00:00').getDay()
const baseEv = store.getEventById(init.id)
if (
baseEv &&
baseEv.recur &&
baseEv.recur.freq === 'weeks' &&
Array.isArray(baseEv.recur.weekdays)
) {
originalPattern = [...baseEv.recur.weekdays]
}
} catch {}
originalWeekday = new Date(init.startDate + 'T00:00:00').getDay()
const baseEv = store.getEventById(init.id)
if (
baseEv &&
baseEv.recur &&
baseEv.recur.freq === 'weeks' &&
Array.isArray(baseEv.recur.weekdays)
) {
originalPattern = [...baseEv.recur.weekdays]
}
}
dragState.value = {
@@ -565,7 +563,7 @@ function applyRangeDuringDrag(st, startDate, endDate) {
inset: 0;
display: grid;
grid-template-columns: repeat(7, 1fr);
margin-top: 1.8em;
margin-top: 1.0rem;
pointer-events: none;
}
.segment-grid {
@@ -582,7 +580,7 @@ function applyRangeDuringDrag(st, startDate, endDate) {
border-radius: 1rem;
/* Font-size so that ascender+descender exactly fills the row height:
given total = asc+desc at 1em (hardcoded 1.15), font-size = rowHeight / total */
font-size: calc(var(--segment-row-height, 1.5em) / 1.15);
font-size: calc(var(--segment-row-height, 1.5rem) / 1.15);
font-weight: 500;
cursor: grab;
pointer-events: auto;

View File

@@ -1,7 +1,13 @@
<template>
<div class="header-controls-wrapper">
<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">
<!-- Shrinkable spacer to align search with week label column; smoothly shrinks as needed -->
<div class="pre-search-spacer" aria-hidden="true"></div>
@@ -69,7 +75,7 @@
</template>
<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 { formatTodayString } from '@/utils/date'
import EventSearch from '@/components/Search.vue'
@@ -122,18 +128,19 @@ function goToToday() {
// Screen size detection and visibility toggle
const isVisible = ref(false)
// Track if we auto-opened due to a find (Ctrl/Cmd+F)
const autoOpenedForSearch = ref(false)
const headerControlsRef = ref(null)
const hasFocusWithin = ref(false)
function checkScreenSize() {
const isSmallScreen = window.innerHeight < 600
// Default to open on large screens, closed on small screens
isVisible.value = !isSmallScreen
isVisible.value = !isSmallScreen || hasFocusWithin.value
}
function toggleVisibility() {
isVisible.value = !isVisible.value
if (!isVisible.value) autoOpenedForSearch.value = false
if (!isVisible.value) {
hasFocusWithin.value = false
}
}
// Settings dialog integration
@@ -150,6 +157,25 @@ function openSettings() {
// Search component ref exposure
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) {
eventSearchRef.value?.focusSearch(selectAll)
}
@@ -167,24 +193,21 @@ function handleGlobalFind(e) {
e.preventDefault()
if (!isVisible.value) {
isVisible.value = true
autoOpenedForSearch.value = true
} else {
autoOpenedForSearch.value = false
}
// Defer focus until after transition renders input
nextTick(() => requestAnimationFrame(() => focusSearch(true)))
nextTick(() => focusSearch(true))
}
}
function handleSearchActivate(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(() => {
checkScreenSize()
window.addEventListener('resize', checkScreenSize)
@@ -211,6 +234,9 @@ onBeforeUnmount(() => {
gap: 1rem;
}
@media (max-width: 600px) {
.header-controls { gap: 0.1rem; }
}
/* Group search + spacer so outer gap doesn't create unwanted space */
.search-with-spacer {
display: flex;
@@ -241,7 +267,7 @@ onBeforeUnmount(() => {
padding: 0;
margin: 0.5em;
cursor: pointer;
font-size: 1em;
font-size: 1rem;
font-weight: 700;
line-height: 1;
display: inline-flex;
@@ -269,13 +295,13 @@ onBeforeUnmount(() => {
.header-controls-leave-to {
opacity: 0;
max-height: 0;
transform: translateY(-20px);
transform: translateY(-1rem);
}
.header-controls-enter-to,
.header-controls-leave-from {
opacity: 1;
max-height: 100px;
max-height: 4rem;
transform: translateY(0);
}
@@ -330,14 +356,14 @@ onBeforeUnmount(() => {
}
.today-date {
font-size: 1.5em;
font-size: 1.5rem;
white-space: pre-line;
text-align: center;
}
.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;
font-size: 3.6rem;
white-space: nowrap;
text-align: center;
cursor: pointer;

View File

@@ -245,6 +245,7 @@ function onWheel(e) {
justify-content: center;
gap: 0.25rem;
background: none;
font-size: 1rem;
font-variant-numeric: tabular-nums;
touch-action: none;
}

View File

@@ -29,14 +29,11 @@
><span class="date">{{ r.startDate }}</span>
</li>
</ul>
<div v-else-if="searchQuery.trim() && !searchResults.length" class="search-empty">
No matches
</div>
</div>
</template>
<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 {
fromLocalString,
@@ -63,7 +60,9 @@ const searchIndex = ref(0)
const searchInputRef = ref(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'
: ''
@@ -485,9 +484,9 @@ function parseGoToDateCandidate(input, refStr) {
padding: 0.32rem 0.5rem;
padding-inline-start: 2.05rem; /* increased space for icon */
border-radius: 0.45rem;
border: 1px solid color-mix(in srgb, var(--muted) 35%, transparent);
border: .1rem solid color-mix(in srgb, var(--muted) 35%, transparent);
background: color-mix(in srgb, var(--panel) 88%, transparent);
font: inherit;
font-size: 1rem;
line-height: 1.1;
color: var(--ink);
outline: none;
@@ -530,7 +529,7 @@ function parseGoToDateCandidate(input, refStr) {
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);
border: .1rem solid color-mix(in srgb, var(--muted) 25%, transparent);
}
.search-bar input:focus + .shortcut-hint,
@@ -548,8 +547,7 @@ function parseGoToDateCandidate(input, refStr) {
padding: 0.2rem;
background: color-mix(in srgb, var(--panel) 92%, transparent);
backdrop-filter: blur(0.6em);
-webkit-backdrop-filter: blur(0.6em);
border: 1px solid color-mix(in srgb, var(--muted) 35%, transparent);
border: .1rem solid color-mix(in srgb, var(--muted) 35%, transparent);
border-radius: 0.55rem;
max-height: 16rem;
overflow: auto;
@@ -590,8 +588,7 @@ function parseGoToDateCandidate(input, refStr) {
padding: 0.45rem 0.6rem;
background: color-mix(in srgb, var(--panel) 92%, transparent);
backdrop-filter: blur(0.6em);
-webkit-backdrop-filter: blur(0.6em);
border: 1px solid color-mix(in srgb, var(--muted) 35%, transparent);
border: .1rem solid color-mix(in srgb, var(--muted) 35%, transparent);
border-radius: 0.55rem;
box-shadow: 0 0.5em 1.25em rgba(0, 0, 0, 0.3);
font-size: 0.7rem;

View File

@@ -253,11 +253,12 @@ defineExpose({ open })
border-inline-start: 2px solid var(--border-color);
}
select {
border: 1px solid var(--muted);
border: .1rem solid var(--muted);
background: var(--panel-alt, transparent);
color: var(--ink);
padding: 0.4rem 0.5rem;
border-radius: 0.4rem;
font-size: 1rem;
}
.holiday-row {
@@ -273,7 +274,7 @@ select {
.state-select {
flex: 0 0 auto;
min-width: 120px;
min-width: 4rem;
}
.footer-row {
@@ -291,12 +292,13 @@ select {
gap: 0.5rem;
}
.ec-btn {
border: 1px solid var(--muted);
border: .1rem solid var(--muted);
background: transparent;
color: var(--ink);
padding: 0.5rem 0.8rem;
border-radius: 0.4rem;
cursor: pointer;
font-size: 1rem;
}
.ec-btn.close-btn {
background: var(--panel-alt);

View File

@@ -287,20 +287,20 @@ export function createVirtualWeekManager({
function goToToday() {
const todayDate = new Date(calendarStore.now)
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
const maxScroll = Math.max(0, contentHeight.value - viewportHeight.value)
const baseTop = (weekIndex - minVirtualWeek.value) * rowHeight.value
// Center: subtract half viewport minus half row height
let newScrollTop = baseTop - (viewportHeight.value / 2 - rowHeight.value / 2)
newScrollTop = Math.max(0, Math.min(newScrollTop, maxScroll))
// Scroll so that the top of the viewport aligns with the top of the previous week,
// making the target week the second visible week row
const newScrollTop = (weekIndex - 1 - minVirtualWeek.value) * rowHeight.value
const clampedScrollTop = Math.max(0, Math.min(newScrollTop, maxScroll))
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) {
setScrollTopFn(newScrollTop, reason)
setScrollTopFn(clampedScrollTop, reason)
scheduleWindowUpdate(reason)
}
}
@@ -322,7 +322,7 @@ export function createVirtualWeekManager({
getWeekIndex,
getFirstDayForVirtualWeek,
goToToday,
scrollToWeekCentered,
scrollToWeek,
handleHeaderYearChange,
attachScroll,
}

View File

@@ -2,14 +2,14 @@
import * as dateFns from 'date-fns'
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)
const getISOWeek = dateFns.getISOWeek
const getISOWeekYear = dateFns.getISOWeekYear
export const getISOWeek = dateFns.getISOWeek
export const getISOWeekYear = dateFns.getISOWeekYear
// Constants
const monthAbbr = [
export const monthAbbr = [
'jan',
'feb',
'mar',
@@ -24,15 +24,15 @@ const monthAbbr = [
'dec',
]
// We get scrolling issues if the virtual view is bigger than that
const MIN_YEAR = 1582
const MAX_YEAR = 3000
export const MIN_YEAR = 1582
export const MAX_YEAR = 3000
// Core helpers ------------------------------------------------------------
/**
* 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).
*/
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(
day,
).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).
*/
const TZDate = (year, monthIndex, day, timeZone = DEFAULT_TZ) =>
export const TZDate = (year, monthIndex, day, timeZone = DEFAULT_TZ) =>
makeTZDate(year, monthIndex, day, timeZone)
/**
* 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))
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')
}
function fromLocalString(dateString, timeZone = DEFAULT_TZ) {
export function fromLocalString(dateString, timeZone = DEFAULT_TZ) {
if (!dateString) return makeTZDate(1970, 0, 1, timeZone)
const parsed = dateFns.parseISO(dateString)
const utcDate = fromZonedTime(`${dateString}T00:00:00`, timeZone)
return toZonedTime(utcDate, timeZone) || parsed
}
function getMondayOfISOWeek(date, timeZone = DEFAULT_TZ) {
export function getMondayOfISOWeek(date, timeZone = DEFAULT_TZ) {
const d = toZonedTime(date, timeZone)
const dow = (dateFns.getDay(d) + 6) % 7 // Monday=0
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)
// 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 b = fromLocalString(bStr, timeZone)
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)
}
// 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
return Array.from({ length: 7 }, (_, i) =>
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
function getLocalizedWeekdayNamesLong(timeZone = DEFAULT_TZ) {
export function getLocalizedWeekdayNamesLong(timeZone = DEFAULT_TZ) {
const sunday = makeTZDate(2025, 0, 5, timeZone)
return Array.from({ length: 7 }, (_, i) =>
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
return day % 7
}
function getLocaleWeekendDays() {
export function getLocaleWeekendDays() {
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)))
}
function reorderByFirstDay(days, firstDay) {
export function reorderByFirstDay(days, firstDay) {
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)
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 b = toLocalString(endDate, timeZone)
if (a === b) return a
@@ -138,7 +138,7 @@ function formatDateRange(startDate, endDate, timeZone = DEFAULT_TZ) {
return `${a}/${b}`
}
function lunarPhaseSymbol(date) {
export function lunarPhaseSymbol(date) {
// Reference new moon (J2000 era) used for approximate phase calculations
const ref = UTCDate(2000, 0, 6, 18, 14, 0)
const obs = new Date(date)
@@ -165,14 +165,14 @@ function lunarPhaseSymbol(date) {
/**
* 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(/, /, ' ')
}
/**
* 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 = {
weekday: 'short',
month: 'short',
@@ -185,45 +185,9 @@ function formatDateLong(date, includeYear = false) {
/**
* 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
.toLocaleDateString(undefined, { weekday, month, day: 'numeric' })
.replace(/,? /, '\n')
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,
}

View File

@@ -15,4 +15,18 @@ export default defineConfig({
'@': 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';
}
}
}
}
}
})