From 7b14b382e9be0f92d3f7ecfaed622521bfab6cab Mon Sep 17 00:00:00 2001 From: Qasim Date: Fri, 30 Jan 2026 20:39:27 -0500 Subject: [PATCH 1/4] fix(calendar): improve dynamic header and event loading - Increase event fetch limit to 200 to cover full month view - Update events panel header dynamically based on selected date: - Shows "Today's Schedule" when clicking on today's date - Shows formatted date (e.g., "Wednesday, January 28") for other days - Change default sidebar view from "Today" to "Week" - Replace hardcoded title/date with "Loading..." placeholders that get populated dynamically by JavaScript --- internal/air/static/js/calendar-data.js | 3 ++- internal/air/static/js/calendar-ui.js | 18 +++++++++++++++++- internal/air/templates/pages/calendar.gohtml | 8 ++++---- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/internal/air/static/js/calendar-data.js b/internal/air/static/js/calendar-data.js index 38a6b74..9995e89 100644 --- a/internal/air/static/js/calendar-data.js +++ b/internal/air/static/js/calendar-data.js @@ -21,7 +21,8 @@ async loadEvents() { const data = await AirAPI.getEvents({ start: Math.floor(start.getTime() / 1000), - end: Math.floor(end.getTime() / 1000) + end: Math.floor(end.getTime() / 1000), + limit: 200 // Fetch more events to cover the full month }); this.events = data.events || []; diff --git a/internal/air/static/js/calendar-ui.js b/internal/air/static/js/calendar-ui.js index 644f08a..d07bf16 100644 --- a/internal/air/static/js/calendar-ui.js +++ b/internal/air/static/js/calendar-ui.js @@ -95,8 +95,24 @@ onDayClick(dateStr, dayEl) { document.querySelectorAll('.calendar-day.selected').forEach(el => el.classList.remove('selected')); dayEl.classList.add('selected'); - // Update events panel header + // Check if clicked date is today + const today = new Date(); + const isToday = clickedDate.getFullYear() === today.getFullYear() && + clickedDate.getMonth() === today.getMonth() && + clickedDate.getDate() === today.getDate(); + + // Update events panel header title and date + const headerEl = document.querySelector('.events-header h3'); const dateEl = document.querySelector('.events-date'); + + if (headerEl) { + headerEl.textContent = isToday ? "Today's Schedule" : clickedDate.toLocaleDateString('en-US', { + weekday: 'long', + month: 'long', + day: 'numeric' + }); + } + if (dateEl) { dateEl.textContent = clickedDate.toLocaleDateString('en-US', { weekday: 'short', diff --git a/internal/air/templates/pages/calendar.gohtml b/internal/air/templates/pages/calendar.gohtml index a41e2c6..34514ac 100644 --- a/internal/air/templates/pages/calendar.gohtml +++ b/internal/air/templates/pages/calendar.gohtml @@ -11,11 +11,11 @@
-
+
📆 Today
-
+
📅 Week
@@ -79,7 +79,7 @@
-

December 2024

+

Loading...

@@ -139,7 +139,7 @@

Today's Schedule

- Wed, Dec 25 + Loading...
From 0912562ba3ee79a1a42d5a9535b7c99ab3a7862a Mon Sep 17 00:00:00 2001 From: Qasim Date: Fri, 30 Jan 2026 20:44:05 -0500 Subject: [PATCH 2/4] feat(calendar): add UI quick wins for better UX 1. Event count badges - Show number of events per day instead of simple dots, making it easy to see busy days at a glance 2. Join Meeting button - Replace plain video call links with prominent gradient buttons for one-click meeting joins 3. Relative time indicators - Show contextual time labels: - "Starting now" (red, pulsing) for events within 5 min - "in X min" (yellow) for events within 30 min - "in X hrs" (purple) for events within 3 hours - "Today" / "Tomorrow" for same/next day events 4. Pulsing today indicator - Add animated pulse effect to today's date for better visual recognition --- internal/air/static/css/calendar-grid.css | 123 +++++++++++++++++++--- internal/air/static/js/calendar-ui.js | 71 +++++++++++-- 2 files changed, 173 insertions(+), 21 deletions(-) diff --git a/internal/air/static/css/calendar-grid.css b/internal/air/static/css/calendar-grid.css index 036970b..617a21d 100644 --- a/internal/air/static/css/calendar-grid.css +++ b/internal/air/static/css/calendar-grid.css @@ -178,23 +178,61 @@ background: white; } - .today-dot { + /* Pulsing today indicator */ + .today-indicator { position: absolute; - bottom: 8px; - left: 50%; - transform: translateX(-50%); - width: 6px; - height: 6px; + top: 8px; + right: 8px; + width: 8px; + height: 8px; background: var(--accent); border-radius: 50%; + animation: todayPulse 2s ease-in-out infinite; } - .event-dot { - width: 8px; - height: 8px; + @keyframes todayPulse { + 0%, 100% { + transform: scale(1); + opacity: 1; + box-shadow: 0 0 0 0 rgba(139, 92, 246, 0.4); + } + 50% { + transform: scale(1.2); + opacity: 0.8; + box-shadow: 0 0 0 6px rgba(139, 92, 246, 0); + } + } + + /* Event count badge */ + .event-count-badge { + position: absolute; + bottom: 8px; + right: 8px; + min-width: 20px; + height: 20px; + padding: 0 6px; background: var(--accent); - border-radius: 50%; - margin-top: 4px; + border-radius: 10px; + font-size: 11px; + font-weight: 600; + color: white; + display: flex; + align-items: center; + justify-content: center; + } + + .calendar-day.selected .event-count-badge { + background: white; + color: var(--accent); + } + + /* Legacy support */ + .today-dot { + display: none; + } + + .event-dot { + display: none; } .calendar-events-panel { @@ -262,10 +300,46 @@ margin-bottom: 10px; } + .event-time-row { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 6px; + } + .event-time { font-size: 12px; color: var(--text-muted); - margin-bottom: 6px; + } + + .event-relative-time { + font-size: 11px; + font-weight: 600; + padding: 2px 8px; + border-radius: 10px; + background: var(--bg-surface); + color: var(--text-muted); + } + + .event-relative-time.starting-now { + background: linear-gradient(135deg, #ef4444 0%, #f97316 100%); + color: white; + animation: urgentPulse 1s ease-in-out infinite; + } + + .event-relative-time.starting-soon { + background: linear-gradient(135deg, #f59e0b 0%, #eab308 100%); + color: white; + } + + .event-relative-time.upcoming { + background: rgba(139, 92, 246, 0.15); + color: var(--accent); + } + + @keyframes urgentPulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.7; } } .event-title { @@ -316,6 +390,31 @@ color: var(--text-muted); } + /* Join Meeting button */ + .join-meeting-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + background: linear-gradient(135deg, #4285f4 0%, #34a853 100%); + border-radius: 8px; + font-size: 13px; + font-weight: 600; + color: white; + text-decoration: none; + transition: all 0.2s; + box-shadow: 0 2px 8px rgba(66, 133, 244, 0.3); + } + + .join-meeting-btn:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(66, 133, 244, 0.4); + } + + .join-meeting-btn:active { + transform: translateY(0); + } + .event-actions { display: flex; gap: 8px; diff --git a/internal/air/static/js/calendar-ui.js b/internal/air/static/js/calendar-ui.js index d07bf16..7a13f5e 100644 --- a/internal/air/static/js/calendar-ui.js +++ b/internal/air/static/js/calendar-ui.js @@ -45,21 +45,23 @@ renderCalendarGrid() { for (let day = 1; day <= daysInMonth; day++) { const isToday = isCurrentMonth && day === todayDate; const dateStr = `${year}-${month + 1}-${day}`; - const hasEvents = this.events.some(e => { + // Count events for this day + const dayEvents = this.events.filter(e => { const eventDate = new Date(e.start_time * 1000); return eventDate.getFullYear() === year && eventDate.getMonth() === month && eventDate.getDate() === day; }); + const eventCount = dayEvents.length; let classes = 'calendar-day'; if (isToday) classes += ' today'; - if (hasEvents) classes += ' has-event'; + if (eventCount > 0) classes += ' has-event'; html += `
${day} - ${isToday ? '' : ''} - ${hasEvents ? '
' : ''} + ${isToday ? '' : ''} + ${eventCount > 0 ? `
${eventCount}
` : ''}
`; } @@ -274,6 +276,7 @@ renderEventCard(event) { const endTime = this.formatEventTime(event.end_time); const isFocusTime = event.title?.toLowerCase().includes('focus'); const hasConferencing = event.conferencing && event.conferencing.url; + const relativeTime = this.getRelativeTime(event.start_time); const participantsHtml = event.participants && event.participants.length > 0 ? `
@@ -287,16 +290,19 @@ renderEventCard(event) { : ''; return ` -
-
${event.is_all_day ? 'All Day' : `${startTime} - ${endTime}`}
+
+
+
${event.is_all_day ? 'All Day' : `${startTime} - ${endTime}`}
+ ${relativeTime.text ? `
${relativeTime.text}
` : ''} +
${isFocusTime ? '🧘 ' : ''}${this.escapeHtml(event.title || '(No Title)')}
${event.description ? `
${this.escapeHtml(this.stripHtml(event.description).substring(0, 100))}
` : ''} ${event.location ? `
📍 ${this.escapeHtml(event.location)}
` : ''} ${participantsHtml} ${hasConferencing ? ` -
- - 📹 ${event.conferencing.provider || 'Video Call'} + ` : ''} @@ -304,6 +310,53 @@ renderEventCard(event) { `; }, +getRelativeTime(timestamp) { + const now = Date.now() / 1000; + const diff = timestamp - now; + const diffMins = Math.floor(diff / 60); + const diffHours = Math.floor(diff / 3600); + + // Past events + if (diff < 0) { + return { text: '', class: '' }; + } + + // Starting now (within 5 minutes) + if (diffMins <= 5) { + return { text: 'Starting now', class: 'starting-now' }; + } + + // Starting soon (within 30 minutes) + if (diffMins <= 30) { + return { text: `in ${diffMins} min`, class: 'starting-soon' }; + } + + // Within the hour + if (diffMins < 60) { + return { text: `in ${diffMins} min`, class: 'upcoming' }; + } + + // Within a few hours + if (diffHours <= 3) { + return { text: `in ${diffHours} hr${diffHours > 1 ? 's' : ''}`, class: 'upcoming' }; + } + + // Later today or tomorrow + const eventDate = new Date(timestamp * 1000); + const today = new Date(); + if (eventDate.toDateString() === today.toDateString()) { + return { text: 'Today', class: '' }; + } + + const tomorrow = new Date(today); + tomorrow.setDate(tomorrow.getDate() + 1); + if (eventDate.toDateString() === tomorrow.toDateString()) { + return { text: 'Tomorrow', class: '' }; + } + + return { text: '', class: '' }; +}, + formatEventTime(timestamp) { if (!timestamp) return ''; const date = new Date(timestamp * 1000); From 6ff818421aec512a7350470294205617b2e66411 Mon Sep 17 00:00:00 2001 From: Qasim Date: Fri, 30 Jan 2026 20:59:06 -0500 Subject: [PATCH 3/4] fix(calendar): add edit button and fix Join Meeting click behavior - Add gear icon edit button to event cards in top-right corner - Button appears on hover, opens Edit Event modal when clicked - Fix Join Meeting link to only open meeting URL without triggering edit modal (added event.stopPropagation()) - Add CSS styles for edit button positioning and hover states - Use inline styles to override conflicting button styles from buttons.css The edit button provides quick access to modify events directly from the calendar week view, while the Join Meeting button now correctly opens only the meeting link without side effects. --- internal/air/static/css/calendar-grid.css | 30 +++++++++++++++++++++++ internal/air/static/js/calendar-ui.js | 16 +++++++++++- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/internal/air/static/css/calendar-grid.css b/internal/air/static/css/calendar-grid.css index 617a21d..af32338 100644 --- a/internal/air/static/css/calendar-grid.css +++ b/internal/air/static/css/calendar-grid.css @@ -271,6 +271,7 @@ border: 1px solid var(--border); border-radius: 14px; transition: all 0.2s; + position: relative; } .event-card:hover { @@ -278,6 +279,35 @@ transform: translateY(-2px); } + /* Edit button - top right corner */ + .event-card .event-edit-btn { + position: absolute !important; + top: 8px; + right: 8px; + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text-muted); + cursor: pointer; + opacity: 0; + transition: all 0.2s; + } + + .event-card:hover .event-edit-btn { + opacity: 1; + } + + .event-edit-btn:hover { + background: var(--bg-hover); + color: var(--text-primary); + border-color: var(--border-light); + } + .event-card.focus-time { background: linear-gradient(135deg, rgba(34, 197, 94, 0.1) 0%, rgba(16, 185, 129, 0.05) 100%); border-color: rgba(34, 197, 94, 0.3); diff --git a/internal/air/static/js/calendar-ui.js b/internal/air/static/js/calendar-ui.js index 7a13f5e..504126f 100644 --- a/internal/air/static/js/calendar-ui.js +++ b/internal/air/static/js/calendar-ui.js @@ -291,6 +291,12 @@ renderEventCard(event) { return `
+
${event.is_all_day ? 'All Day' : `${startTime} - ${endTime}`}
${relativeTime.text ? `
${relativeTime.text}
` : ''} @@ -301,7 +307,7 @@ renderEventCard(event) { ${participantsHtml} ${hasConferencing ? ` @@ -385,4 +391,12 @@ stripHtml(html) { text = text.replace(/\s+/g, ' ').trim(); return text; }, + +openEditModal(eventId) { + // Find the event by ID + const event = this.events.find(e => e.id === eventId); + if (event && typeof EventModal !== 'undefined') { + EventModal.open(event); + } +}, }); From 32dce044dd6c4adef8dd253e19df961a12df9397 Mon Sep 17 00:00:00 2001 From: Qasim Date: Fri, 30 Jan 2026 21:05:11 -0500 Subject: [PATCH 4/4] test(calendar): add Playwright tests for event card UI features Add comprehensive E2E tests for the new calendar event card functionality: Edit Button: - Verify edit button appears on hover with correct opacity transition - Verify absolute positioning in top-right corner (8px from edges) - Verify click opens event modal with populated title - Verify click event does not propagate to parent card Join Meeting Button: - Verify button exists for events with conferencing URLs - Verify target="_blank" attribute for external links - Verify click does not trigger event modal (stopPropagation) Event Count Badge: - Verify badges appear on calendar days with events - Verify absolute positioning in bottom-right corner Today Indicator: - Verify pulsing indicator appears on current day - Verify todayPulse animation is applied Relative Time Indicator: - Verify indicators show for upcoming events - Verify starting-soon has gradient warning styling - Verify starting-now has urgentPulse animation Also adds new selectors to air-selectors.js for event card elements. --- .../e2e/calendar-operations-eventcard.spec.js | 260 ++++++++++++++++++ tests/shared/helpers/air-selectors.js | 7 + 2 files changed, 267 insertions(+) create mode 100644 tests/air/e2e/calendar-operations-eventcard.spec.js diff --git a/tests/air/e2e/calendar-operations-eventcard.spec.js b/tests/air/e2e/calendar-operations-eventcard.spec.js new file mode 100644 index 0000000..e551881 --- /dev/null +++ b/tests/air/e2e/calendar-operations-eventcard.spec.js @@ -0,0 +1,260 @@ +// @ts-check +const { test, expect } = require('@playwright/test'); +const selectors = require('../../shared/helpers/air-selectors'); + +/** + * Event Card UI tests - Edit button, Join Meeting, badges + */ +test.describe('Event Card UI', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await expect(page.locator(selectors.general.app)).toBeVisible(); + await page.waitForLoadState('domcontentloaded'); + + await page.click(selectors.nav.tabCalendar); + await expect(page.locator(selectors.views.calendar)).toHaveClass(/active/); + await page.waitForTimeout(1000); + }); + + test.describe('Edit Button', () => { + test('edit button appears on event card hover', async ({ page }) => { + const eventCards = page.locator(selectors.calendar.eventCard); + const count = await eventCards.count(); + + if (count > 0) { + const firstCard = eventCards.first(); + const editBtn = firstCard.locator(selectors.calendar.eventEditBtn); + + // Edit button should be hidden initially (opacity: 0) + await expect(editBtn).toHaveCSS('opacity', '0'); + + // Hover over the card + await firstCard.hover(); + await page.waitForTimeout(300); + + // Edit button should be visible on hover + await expect(editBtn).toHaveCSS('opacity', '1'); + } + }); + + test('edit button is positioned in top-right corner', async ({ page }) => { + const eventCards = page.locator(selectors.calendar.eventCard); + const count = await eventCards.count(); + + if (count > 0) { + const firstCard = eventCards.first(); + const editBtn = firstCard.locator(selectors.calendar.eventEditBtn); + + // Verify absolute positioning + await expect(editBtn).toHaveCSS('position', 'absolute'); + + // Verify top-right positioning + const topValue = await editBtn.evaluate(el => getComputedStyle(el).top); + const rightValue = await editBtn.evaluate(el => getComputedStyle(el).right); + + expect(topValue).toBe('8px'); + expect(rightValue).toBe('8px'); + } + }); + + test('edit button click opens event modal', async ({ page }) => { + const eventCards = page.locator(selectors.calendar.eventCard); + const count = await eventCards.count(); + + if (count > 0) { + const firstCard = eventCards.first(); + const editBtn = firstCard.locator(selectors.calendar.eventEditBtn); + + // Hover to make button visible + await firstCard.hover(); + await page.waitForTimeout(300); + + // Click edit button + await editBtn.click(); + await page.waitForTimeout(500); + + // Modal should open + const modal = page.locator(selectors.eventModal.modal); + await expect(modal).toBeVisible(); + + // Modal should have title populated + const titleField = modal.locator(selectors.eventModal.title); + const titleValue = await titleField.inputValue(); + expect(titleValue.length).toBeGreaterThan(0); + } + }); + + test('edit button click does not propagate to card', async ({ page }) => { + const eventCards = page.locator(selectors.calendar.eventCard); + const count = await eventCards.count(); + + if (count > 0) { + const firstCard = eventCards.first(); + const editBtn = firstCard.locator(selectors.calendar.eventEditBtn); + + // Hover to make button visible + await firstCard.hover(); + await page.waitForTimeout(300); + + // Click edit button + await editBtn.click(); + await page.waitForTimeout(500); + + // Only one modal should be open (not multiple from propagation) + const modals = page.locator(selectors.eventModal.modal); + expect(await modals.count()).toBeLessThanOrEqual(1); + } + }); + }); + + test.describe('Join Meeting Button', () => { + test('join meeting button exists for events with conferencing', async ({ page }) => { + const joinBtns = page.locator(selectors.calendar.joinMeetingBtn); + const count = await joinBtns.count(); + + // It's okay if no events have conferencing + expect(count >= 0).toBeTruthy(); + + if (count > 0) { + await expect(joinBtns.first()).toBeVisible(); + await expect(joinBtns.first()).toContainText('Join Meeting'); + } + }); + + test('join meeting button has target="_blank" for external link', async ({ page }) => { + const joinBtns = page.locator(selectors.calendar.joinMeetingBtn); + const count = await joinBtns.count(); + + if (count > 0) { + const target = await joinBtns.first().getAttribute('target'); + expect(target).toBe('_blank'); + } + }); + + test('join meeting button click does not open event modal', async ({ page }) => { + const joinBtns = page.locator(selectors.calendar.joinMeetingBtn); + const count = await joinBtns.count(); + + if (count > 0) { + // Get the href before clicking + const href = await joinBtns.first().getAttribute('href'); + expect(href).toBeTruthy(); + + // Mock the new tab opening by preventing default + await page.evaluate(() => { + document.querySelectorAll('.join-meeting-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + e.preventDefault(); + // Store that we clicked join meeting + window._joinMeetingClicked = true; + }, { capture: true }); + }); + }); + + // Click the join meeting button + await joinBtns.first().click(); + await page.waitForTimeout(500); + + // Event modal should NOT be open + const modal = page.locator(selectors.eventModal.modal); + const modalVisible = await modal.isVisible().catch(() => false); + expect(modalVisible).toBeFalsy(); + } + }); + }); + + test.describe('Event Count Badge', () => { + test('event count badge appears on days with events', async ({ page }) => { + const badges = page.locator(selectors.calendar.eventCountBadge); + const count = await badges.count(); + + // Should have badges if there are events + expect(count >= 0).toBeTruthy(); + + if (count > 0) { + // Badge should contain a number + const text = await badges.first().textContent(); + expect(parseInt(text)).toBeGreaterThan(0); + } + }); + + test('event count badge is positioned in bottom-right corner', async ({ page }) => { + const badges = page.locator(selectors.calendar.eventCountBadge); + const count = await badges.count(); + + if (count > 0) { + await expect(badges.first()).toHaveCSS('position', 'absolute'); + + const bottomValue = await badges.first().evaluate(el => getComputedStyle(el).bottom); + const rightValue = await badges.first().evaluate(el => getComputedStyle(el).right); + + expect(bottomValue).toBe('8px'); + expect(rightValue).toBe('8px'); + } + }); + }); + + test.describe('Today Indicator', () => { + test('today indicator appears on current day', async ({ page }) => { + const todayCell = page.locator(selectors.calendar.today); + const count = await todayCell.count(); + + if (count > 0) { + const indicator = todayCell.locator(selectors.calendar.todayIndicator); + await expect(indicator).toBeVisible(); + } + }); + + test('today indicator has pulsing animation', async ({ page }) => { + const todayCell = page.locator(selectors.calendar.today); + const count = await todayCell.count(); + + if (count > 0) { + const indicator = todayCell.locator(selectors.calendar.todayIndicator); + + if (await indicator.count() > 0) { + const animation = await indicator.evaluate(el => getComputedStyle(el).animationName); + expect(animation).toBe('todayPulse'); + } + } + }); + }); + + test.describe('Relative Time Indicator', () => { + test('relative time indicator shows for upcoming events', async ({ page }) => { + const relativeTimeIndicators = page.locator(selectors.calendar.eventRelativeTime); + const count = await relativeTimeIndicators.count(); + + // It's okay if no events are upcoming + expect(count >= 0).toBeTruthy(); + + if (count > 0) { + // Should have some text + const text = await relativeTimeIndicators.first().textContent(); + expect(text.length).toBeGreaterThan(0); + } + }); + + test('starting-soon indicator has warning styling', async ({ page }) => { + const startingSoon = page.locator('.event-relative-time.starting-soon'); + const count = await startingSoon.count(); + + if (count > 0) { + // Should have gradient background (starts with linear-gradient) + const background = await startingSoon.first().evaluate(el => getComputedStyle(el).backgroundImage); + expect(background).toContain('linear-gradient'); + } + }); + + test('starting-now indicator has urgent styling', async ({ page }) => { + const startingNow = page.locator('.event-relative-time.starting-now'); + const count = await startingNow.count(); + + if (count > 0) { + // Should have animation + const animation = await startingNow.first().evaluate(el => getComputedStyle(el).animationName); + expect(animation).toBe('urgentPulse'); + } + }); + }); +}); diff --git a/tests/shared/helpers/air-selectors.js b/tests/shared/helpers/air-selectors.js index 429e441..b008497 100644 --- a/tests/shared/helpers/air-selectors.js +++ b/tests/shared/helpers/air-selectors.js @@ -96,6 +96,13 @@ exports.calendar = { eventsPanel: '[data-testid="events-panel"]', eventsList: '#eventsList', conflictsPanel: '#conflictsPanel', + // Event card elements + eventCard: '.event-card', + eventEditBtn: '.event-edit-btn', + joinMeetingBtn: '.join-meeting-btn', + eventCountBadge: '.event-count-badge', + eventRelativeTime: '.event-relative-time', + todayIndicator: '.today-indicator', }; // Contacts View