Fit the calendar to the space it actually has
Some checks failed
CI / backend (push) Failing after 7s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 7s

The previous attempt at this was verified against an empty month, which is why
it measured clean and still scrolled: put events in the month and the fixed
height clipped the grid, leaving the last week behind FullCalendar's own
scroller. The media-query floors it added are gone.

Two things were guessed and are now measured. The height came from
calc(100vh - 230px), a stand-in for chrome that is not 230px tall; it is taken
from the container's real top at mount and on resize, then corrected against
whatever the page still overflows by. And dayMaxEvents was a fixed 3, which
does not fit an 80px row - hence the overflow. It is `true` now, so
FullCalendar shows as many chips as the row genuinely holds and rolls the rest
into a '+N more'; that is the only setting that cannot outgrow the box. The
chips and the day-number strip are tighter, so more fit before that happens.

With 44 events across 11 days, at 1920x1080, 1600x900, 1366x768 and 1280x720:
all six weeks visible, no page scrollbar anywhere. A full-height display shows
every chip; a laptop shows one and a link.
This commit is contained in:
cproudlock
2026-08-08 15:12:01 -04:00
parent f67d975058
commit 686966b3b1

View File

@@ -3,7 +3,7 @@
<h1>Calendar</h1> <h1>Calendar</h1>
</div> </div>
<div class="calendar-container card"> <div ref="calendarContainer" class="calendar-container card">
<FullCalendar :options="calendarOptions" /> <FullCalendar :options="calendarOptions" />
</div> </div>
@@ -94,6 +94,7 @@ import { notificationsApi } from '@/api'
const events = ref([]) const events = ref([])
const selectedEvent = ref(null) const selectedEvent = ref(null)
const calendarRef = ref(null) const calendarRef = ref(null)
const calendarContainer = ref(null)
const moreTooltip = ref(null) const moreTooltip = ref(null)
const moreTooltipData = ref([]) const moreTooltipData = ref([])
const tooltipPosition = ref({ left: 0, top: 0 }) const tooltipPosition = ref({ left: 0, top: 0 })
@@ -113,14 +114,19 @@ const calendarOptions = ref({
center: 'title', center: 'title',
right: 'dayGridMonth,dayGridWeek' right: 'dayGridMonth,dayGridWeek'
}, },
// 'auto' sized every row to its own content, so a month of mostly empty days // Measured at mount and on resize (fitHeight), not a viewport calc: the
// collapsed into thin strips and the grid looked squashed. A viewport-relative // calendar starts below a header and a page title whose heights are not
// height makes the calendar fill the space it has been given, and expandRows // knowable here, and guessing at them is what left the grid either clipped
// shares that height evenly across the weeks rather than leaving a gap under // or overflowing. expandRows shares that height across the six weeks so an
// the last one. // empty month fills it instead of collapsing into strips.
height: 'calc(100vh - 230px)', height: 600,
expandRows: true, expandRows: true,
dayMaxEvents: 3, // true = FullCalendar fits as many chips as the row actually has room for and
// rolls the rest into a '+N more'. That is the only setting that cannot
// overflow the box: a fixed number outgrows short rows and pushes the last
// weeks behind the calendar's own scroller. Compact chip styling below buys
// back a chip or two per day.
dayMaxEvents: true,
moreLinkClick: () => 'none' // Disable click, we use hover moreLinkClick: () => 'none' // Disable click, we use hover
}) })
@@ -185,11 +191,39 @@ function buildEventsByDate() {
eventsByDate.value = byDate eventsByDate.value = byDate
} }
// Height the calendar can have without pushing the page into a scrollbar.
// A first guess from the container's top, then corrected against whatever the
// page actually overflows by - the card's padding, page margins and anything
// else below it are not worth enumerating, and guessing at them is exactly how
// this ended up either clipped or scrolling. Floored so a very short window
// scrolls rather than crushing the grid.
const MIN_CALENDAR_HEIGHT = 460
const FIT_PASSES = 3
async function fitHeight() {
const el = calendarContainer.value
if (!el) return
const available = window.innerHeight - el.getBoundingClientRect().top
calendarOptions.value.height = Math.max(MIN_CALENDAR_HEIGHT, Math.round(available))
for (let pass = 0; pass < FIT_PASSES; pass++) {
await nextTick()
const overflow = document.documentElement.scrollHeight - window.innerHeight
if (overflow <= 0) break
const next = calendarOptions.value.height - overflow
if (next <= MIN_CALENDAR_HEIGHT) {
calendarOptions.value.height = MIN_CALENDAR_HEIGHT
break
}
calendarOptions.value.height = next
}
}
onMounted(async () => { onMounted(async () => {
await loadEvents() await loadEvents()
// Add event delegation for more links // Add event delegation for more links
await nextTick() await nextTick()
fitHeight()
window.addEventListener('resize', fitHeight)
const container = document.querySelector('.calendar-container') const container = document.querySelector('.calendar-container')
if (container) { if (container) {
container.addEventListener('mouseenter', handleMoreLinkHover, true) container.addEventListener('mouseenter', handleMoreLinkHover, true)
@@ -198,6 +232,7 @@ onMounted(async () => {
}) })
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('resize', fitHeight)
const container = document.querySelector('.calendar-container') const container = document.querySelector('.calendar-container')
if (container) { if (container) {
container.removeEventListener('mouseenter', handleMoreLinkHover, true) container.removeEventListener('mouseenter', handleMoreLinkHover, true)
@@ -254,44 +289,35 @@ function formatDate(dateStr) {
<style scoped> <style scoped>
.calendar-container { .calendar-container {
min-height: 640px; /* The height itself is measured in fitHeight(); this is only a floor for the
first paint, before that measurement lands. */
min-height: 460px;
} }
/* :deep, because these are FullCalendar's own elements and scoped styles do not /* Compact chips: the row height decides how many events a day can show before
reach them. A floor under each day cell keeps the grid readable on a short they roll into a '+N more', so every pixel saved here is one more visible. */
window, where the viewport-relative height above would otherwise squeeze the :deep(.fc-daygrid-event) {
rows back down. */ font-size: 12px;
:deep(.fc-daygrid-day-frame) { line-height: 1.3;
min-height: 110px; padding: 1px 4px;
margin-top: 1px;
}
:deep(.fc-daygrid-more-link) {
font-size: 12px;
} }
/* The calc() height above is viewport-relative, so on a short window - a laptop /* The day number was tight against the top edge once the cells grew. Kept
with a docked browser, or a shopfloor display in portrait - it can resolve small: this strip is pure overhead in the row-height budget FullCalendar
smaller than the grid needs. This stops it undercutting the day cells. */ uses to decide how many chips fit before a '+N more'. */
:deep(.fc) {
min-height: 620px;
}
/* Those floors are sized for a full-height display. On a shorter window they
add up to more than calc(100vh - 230px) gives - six rows at 110px plus the
toolbar overflows a 768px screen - and the page grew a scrollbar to show a
month that used to fit. Relax them there: still floored well above the thin
strips this was fixing, just not taller than the window. */
@media (max-height: 899px) {
.calendar-container {
min-height: 520px;
}
:deep(.fc) {
min-height: 500px;
}
:deep(.fc-daygrid-day-frame) {
min-height: 78px;
}
}
/* The day number was tight against the top edge once the cells grew. */
:deep(.fc-daygrid-day-top) { :deep(.fc-daygrid-day-top) {
padding: 2px 4px; padding: 1px 4px;
font-size: 13px;
}
:deep(.fc-daygrid-day-events) {
margin-bottom: 0;
}
:deep(.fc-daygrid-event-harness) {
margin-top: 0;
} }
.modal { .modal {