Files
shopdb-flask/plugins/notifications/frontend/views/CalendarView.vue
cproudlock 686966b3b1
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
Fit the calendar to the space it actually has
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.
2026-08-08 15:12:01 -04:00

454 lines
12 KiB
Vue

<template>
<div class="page-header">
<h1>Calendar</h1>
</div>
<div ref="calendarContainer" class="calendar-container card">
<FullCalendar :options="calendarOptions" />
</div>
<!-- More events tooltip -->
<div
v-if="moreTooltipData.length"
ref="moreTooltip"
class="fc-more-tooltip"
:style="{ left: tooltipPosition.left + 'px', top: tooltipPosition.top + 'px' }"
@mouseleave="hideMoreTooltip"
>
<div
v-for="(evt, idx) in moreTooltipData"
:key="idx"
class="fc-more-tooltip-event"
:style="{ borderLeftColor: evt.color }"
@click="openEventFromTooltip(evt)"
>
{{ evt.title }}
</div>
</div>
<!-- Event details modal -->
<div v-if="selectedEvent" class="modal-overlay" @click.self="closeEventModal">
<div class="modal">
<!-- Employee-photo event (recognition/recertification) with highlight -->
<div v-if="selectedEvent.extendedProps?.showemployeephoto" class="recognition-header">
<div class="recognition-badge">
<span class="recognition-icon"><Trophy :size="24" /></span>
</div>
<div class="recognition-info">
<div class="recognition-label">{{ selectedEvent.extendedProps?.typename || 'Recognition' }}</div>
<h2 class="recognition-title">{{ selectedEvent.extendedProps?.message || selectedEvent.title }}</h2>
<div v-if="selectedEvent.extendedProps?.employeename || selectedEvent.extendedProps?.employeesso" class="recognition-employee">
<span class="employee-icon"><User :size="16" /></span>
<span class="employee-name">{{ selectedEvent.extendedProps.employeename || selectedEvent.extendedProps.employeesso }}</span>
</div>
</div>
</div>
<!-- Regular event header -->
<h2 v-else>{{ selectedEvent.title }}</h2>
<div class="event-details">
<p v-if="selectedEvent.extendedProps?.typename && !selectedEvent.extendedProps?.showemployeephoto">
<strong>Type:</strong> {{ selectedEvent.extendedProps.typename }}
</p>
<p>
<strong>Start:</strong> {{ formatDate(selectedEvent.start) }}
</p>
<p v-if="selectedEvent.end">
<strong>End:</strong> {{ formatDate(selectedEvent.end) }}
</p>
<p v-if="selectedEvent.extendedProps?.message && !selectedEvent.extendedProps?.showemployeephoto" class="message-block">
<strong>Details:</strong>
<span class="message-text">{{ selectedEvent.extendedProps.message }}</span>
</p>
<p v-if="selectedEvent.extendedProps?.ticketnumber">
<strong>Ticket:</strong> {{ selectedEvent.extendedProps.ticketnumber }}
</p>
<p v-if="selectedEvent.extendedProps?.linkurl">
<a :href="selectedEvent.extendedProps.linkurl" target="_blank" class="btn btn-link">
More Info
</a>
</p>
</div>
<div class="modal-actions">
<router-link
v-if="selectedEvent.extendedProps?.notificationid"
:to="`/notifications/${selectedEvent.extendedProps.notificationid}`"
class="btn btn-secondary"
>
View Notification
</router-link>
<button class="btn btn-primary" @click="closeEventModal">Close</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import FullCalendar from '@fullcalendar/vue3'
import dayGridPlugin from '@fullcalendar/daygrid'
import { Trophy, User } from 'lucide-vue-next'
import { notificationsApi } from '@/api'
const events = ref([])
const selectedEvent = ref(null)
const calendarRef = ref(null)
const calendarContainer = ref(null)
const moreTooltip = ref(null)
const moreTooltipData = ref([])
const tooltipPosition = ref({ left: 0, top: 0 })
// Store events by date for hover lookup
const eventsByDate = ref({})
const calendarOptions = ref({
plugins: [dayGridPlugin],
initialView: 'dayGridMonth',
events: [],
eventClick: (info) => {
selectedEvent.value = info.event
},
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,dayGridWeek'
},
// Measured at mount and on resize (fitHeight), not a viewport calc: the
// calendar starts below a header and a page title whose heights are not
// knowable here, and guessing at them is what left the grid either clipped
// or overflowing. expandRows shares that height across the six weeks so an
// empty month fills it instead of collapsing into strips.
height: 600,
expandRows: true,
// 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
})
function hideMoreTooltip() {
moreTooltipData.value = []
}
function handleMoreLinkHover(e) {
const moreLink = e.target.closest('.fc-daygrid-more-link')
if (!moreLink) {
return
}
// Find the day cell and get its date
const dayCell = moreLink.closest('.fc-daygrid-day')
if (!dayCell) return
const dateStr = dayCell.getAttribute('data-date')
if (!dateStr || !eventsByDate.value[dateStr]) return
// Get events for this date that would be hidden (after first 3)
const dayEvents = eventsByDate.value[dateStr]
const hiddenEvents = dayEvents.slice(3).map(evt => ({
title: evt.title,
color: evt.backgroundColor || '#14abef',
// Include full event data for modal
start: evt.start,
end: evt.end,
extendedProps: evt.extendedProps || {}
}))
if (hiddenEvents.length === 0) return
moreTooltipData.value = hiddenEvents
// Position tooltip
const rect = moreLink.getBoundingClientRect()
tooltipPosition.value = {
left: rect.left,
top: rect.bottom + 5
}
}
function handleMoreLinkLeave(e) {
const related = e.relatedTarget
// Don't hide if moving to the tooltip itself
if (related && (related.closest('.fc-more-tooltip') || related.closest('.fc-daygrid-more-link'))) {
return
}
hideMoreTooltip()
}
function buildEventsByDate() {
const byDate = {}
for (const evt of events.value) {
const dateStr = evt.start ? evt.start.split('T')[0] : null
if (dateStr) {
if (!byDate[dateStr]) byDate[dateStr] = []
byDate[dateStr].push(evt)
}
}
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 () => {
await loadEvents()
// Add event delegation for more links
await nextTick()
fitHeight()
window.addEventListener('resize', fitHeight)
const container = document.querySelector('.calendar-container')
if (container) {
container.addEventListener('mouseenter', handleMoreLinkHover, true)
container.addEventListener('mouseleave', handleMoreLinkLeave, true)
}
})
onUnmounted(() => {
window.removeEventListener('resize', fitHeight)
const container = document.querySelector('.calendar-container')
if (container) {
container.removeEventListener('mouseenter', handleMoreLinkHover, true)
container.removeEventListener('mouseleave', handleMoreLinkLeave, true)
}
})
onMounted(async () => {
await loadEvents()
})
async function loadEvents() {
try {
const response = await notificationsApi.getCalendar()
events.value = response.data.data
calendarOptions.value.events = events.value
buildEventsByDate()
} catch (error) {
console.error('Error loading calendar events:', error)
}
}
function closeEventModal() {
selectedEvent.value = null
}
function openEventFromTooltip(evt) {
// Create an event-like object that matches FullCalendar's event structure
selectedEvent.value = {
title: evt.title,
start: evt.start,
end: evt.end,
extendedProps: evt.extendedProps
}
hideMoreTooltip()
}
function formatDate(dateStr) {
if (!dateStr) return ''
// allDay events carry a site-local date-only value (YYYY-MM-DD). Build the
// Date from local parts so it is not shifted a day by UTC-midnight parsing.
const parts = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateStr)
const date = parts
? new Date(Number(parts[1]), Number(parts[2]) - 1, Number(parts[3]))
: new Date(dateStr)
return date.toLocaleDateString('en-US', {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric'
})
}
</script>
<style scoped>
.calendar-container {
/* The height itself is measured in fitHeight(); this is only a floor for the
first paint, before that measurement lands. */
min-height: 460px;
}
/* Compact chips: the row height decides how many events a day can show before
they roll into a '+N more', so every pixel saved here is one more visible. */
:deep(.fc-daygrid-event) {
font-size: 12px;
line-height: 1.3;
padding: 1px 4px;
margin-top: 1px;
}
:deep(.fc-daygrid-more-link) {
font-size: 12px;
}
/* The day number was tight against the top edge once the cells grew. Kept
small: this strip is pure overhead in the row-height budget FullCalendar
uses to decide how many chips fit before a '+N more'. */
:deep(.fc-daygrid-day-top) {
padding: 1px 4px;
font-size: 13px;
}
:deep(.fc-daygrid-day-events) {
margin-bottom: 0;
}
:deep(.fc-daygrid-event-harness) {
margin-top: 0;
}
.modal {
padding: 1.5rem;
}
.modal h2 {
margin: 0 0 1rem 0;
font-size: 18px;
color: var(--text);
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--border);
}
.event-details {
padding: 0.5rem 0;
}
.event-details p {
margin-bottom: 0.75rem;
font-size: 14px;
line-height: 1.5;
}
.event-details p:last-child {
margin-bottom: 0;
}
.event-details strong {
color: var(--text-light);
display: inline-block;
min-width: 70px;
}
.message-block {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.message-block strong {
min-width: auto;
}
.message-text {
display: block;
padding: 0.5rem 0.75rem;
background: var(--bg);
border-radius: 0.25rem;
white-space: pre-wrap;
line-height: 1.5;
}
.modal-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
margin-top: 1.25rem;
padding-top: 1rem;
border-top: 1px solid var(--border);
}
/* Recognition event styling */
.recognition-header {
display: flex;
gap: 1rem;
padding-bottom: 1rem;
margin-bottom: 1rem;
border-bottom: 1px solid var(--border);
}
.recognition-badge {
flex-shrink: 0;
width: 60px;
height: 60px;
background: linear-gradient(135deg, #ffd700 0%, #ffaa00 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(255, 215, 0, 0.3);
}
.recognition-icon {
font-size: 28px;
}
.recognition-info {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
}
.recognition-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--primary);
font-weight: 600;
margin-bottom: 0.25rem;
}
.recognition-title {
margin: 0 !important;
padding: 0 !important;
border: none !important;
font-size: 16px !important;
line-height: 1.4;
color: var(--text) !important;
}
.recognition-employee {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.5rem;
padding: 0.5rem 0.75rem;
background: rgba(65, 129, 255, 0.1);
border-radius: 0.25rem;
border-left: 3px solid var(--primary);
}
.employee-icon {
font-size: 18px;
}
.employee-name {
font-weight: 600;
color: var(--text);
font-size: 15px;
}
</style>