diff --git a/internal/air/static/css/calendar-grid.css b/internal/air/static/css/calendar-grid.css index 036970b..af32338 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 { @@ -233,6 +271,7 @@ border: 1px solid var(--border); border-radius: 14px; transition: all 0.2s; + position: relative; } .event-card:hover { @@ -240,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); @@ -262,10 +330,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 +420,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-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..504126f 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}
` : ''}
`; } @@ -95,8 +97,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', @@ -258,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 ? `
@@ -271,16 +290,25 @@ 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'} + ` : ''} @@ -288,6 +316,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); @@ -316,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); + } +}, }); 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...
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