diff --git a/src/components/EventList.vue b/src/components/EventList.vue index 3a8cee48..bc77b5ef 100644 --- a/src/components/EventList.vue +++ b/src/components/EventList.vue @@ -14,6 +14,9 @@ import filtersStore from '../store/filtersStore'; /** * @prop {string} type - Type of events to display ('past' or 'upcoming') + * @prop {Object} initialEvents - Initial events data from SSR + * @prop {Array} initialBooks - Initial books data from SSR + * @prop {Object} initialUserInfo - Initial user info from SSR */ const props = defineProps({ // 'past' or 'upcoming' @@ -22,6 +25,18 @@ const props = defineProps({ required: true, validator: (value) => ['past', 'upcoming'].includes(value), }, + initialEvents: { + type: Object, + default: null, + }, + initialBooks: { + type: Array, + default: () => [], + }, + initialUserInfo: { + type: Object, + default: null, + }, }); // Reactive references for component state @@ -145,6 +160,39 @@ const groupEvents = (events) => { groupedEvents.value = sortedGroups; }; +// Initialize immediately for SSR +// This runs during server-side rendering +if (props.initialEvents) { + // Process initial events for SSR + const eventsToProcess = + props.type === 'past' + ? props.initialEvents.past || [] + : [ + ...(props.initialEvents.future || []), + ...(props.initialEvents.today || []), + ]; + + // Add books to the events list (only for upcoming events) + const booksWithType = + (props.type === 'upcoming' && + props.initialBooks?.map((book) => ({ + ...book, + _type: 'book', + dateStart: book.date, + }))) || + []; + + // Group events immediately for SSR + if (eventsToProcess.length > 0 || booksWithType.length > 0) { + const allEvents = + props.type === 'past' + ? eventsToProcess + : [...eventsToProcess, ...booksWithType]; + groupEvents(allEvents); + loading.value = false; + } +} + /** * Fetches books from the API endpoint * @returns {Promise} Array of book objects or empty array on error @@ -175,51 +223,73 @@ const normalizeDate = (dateString) => { }; /** - * Lifecycle hook: initializes component data - * - Fetches and sets user info if needed - * - Gets events from store - * - Fetches and merges books with events - * - Groups combined items by month + * Client-side initialization + * Syncs SSR data with stores and handles client-side fetching */ onMounted(async () => { - loading.value = true; error.value = null; + // Remove static server-rendered content to prevent duplicates + const staticContainer = document.getElementById('static-events-container'); + if (staticContainer) { + staticContainer.remove(); + } + try { - if (!userStore.userInfoFetched) { - const response = await fetch('/api/get-user-info'); - if (!response.ok) throw new Error('Failed to fetch user info'); - const data = await response.json(); - userStore.setUserInfo(data.timezone, data.acceptLanguage, data.geo); + // If we have initial SSR data and stores are empty, populate them + if (props.initialEvents && !filtersStore.events.length) { + // Set user info from SSR + if (props.initialUserInfo && !userStore.userInfoFetched) { + userStore.setUserInfo( + props.initialUserInfo.timezone, + props.initialUserInfo.acceptLanguage, + props.initialUserInfo.geo + ); + } + + // Set events from SSR + filtersStore.setEvents( + props.initialEvents.future || [], + props.initialEvents.today || [], + props.initialEvents.past || [] + ); + + // Set books from SSR + if (props.initialBooks && props.initialBooks.length > 0) { + filtersStore.books = props.initialBooks.map((book) => ({ + ...book, + _type: 'book', + dateStart: book.date, + })); + } + } else if (!filtersStore.events.length && !props.initialEvents) { + // No SSR data and no store data - fetch from API + loading.value = true; + await filtersStore.fetchEvents(); + + // Get user info if not already fetched + if (!userStore.userInfoFetched) { + const response = await fetch('/api/get-user-info'); + if (response.ok) { + const data = await response.json(); + userStore.setUserInfo(data.timezone, data.acceptLanguage, data.geo); + } + } } - // Get events from the store - let events = + // Update grouped events from store + const events = props.type === 'past' ? filtersStore.pastEvents : filtersStore.filteredEvents; - // Process events if we have them - if (events && events.length > 0) { + if (events) { groupEvents(events); } + loading.value = false; } catch (e) { error.value = `Unable to load ${props.type} events. Please try again later.`; console.error('Error:', e); - } finally { - if (error.value || Object.keys(groupedEvents.value).length > 0) { - loading.value = false; - } - } -}); - -onMounted(() => { - const events = - props.type === 'past' - ? filtersStore.pastEvents - : filtersStore.filteredEvents; - if (events !== undefined) { - groupEvents(events); loading.value = false; } }); diff --git a/src/components/StaticEventList.astro b/src/components/StaticEventList.astro new file mode 100644 index 00000000..f80ed8f9 --- /dev/null +++ b/src/components/StaticEventList.astro @@ -0,0 +1,170 @@ +--- +import Event from './Event.vue'; +import EventBook from './EventBook.vue'; + +interface Props { + type: 'upcoming' | 'past'; + initialEvents: any; + initialBooks: any[]; + initialUserInfo: any; +} + +const { type, initialEvents, initialBooks, initialUserInfo } = Astro.props; + +// Process and group events by month (server-side) +function groupEventsByMonth(events: any[], books: any[]) { + const allItems = [...events, ...books]; + const grouped: Record = {}; + + for (const item of allItems) { + if (!item.dateStart) continue; + + const date = new Date(item.dateStart); + if (isNaN(date.getTime())) continue; // Skip invalid dates + + // Use UTC methods for books since their dates are in UTC + const yearMonth = item._type === 'book' + ? `${date.getUTCFullYear()}-${date.getUTCMonth() + 1}` + : `${date.getFullYear()}-${date.getMonth() + 1}`; + + if (!grouped[yearMonth]) { + grouped[yearMonth] = []; + } + grouped[yearMonth].push(item); + } + + // Sort months + const sortedEntries = Object.entries(grouped).sort((a, b) => { + const [yearA, monthA] = a[0].split('-').map(Number); + const [yearB, monthB] = b[0].split('-').map(Number); + const dateA = new Date(yearA, monthA - 1); + const dateB = new Date(yearB, monthB - 1); + + return type === 'past' ? dateB.getTime() - dateA.getTime() : dateA.getTime() - dateB.getTime(); + }); + + // Sort events within each month + sortedEntries.forEach(([yearMonth, events]) => { + events.sort((a, b) => { + // If both items are the same type (both books or both events) + if ((a._type === 'book') === (b._type === 'book')) { + // Calculate chronological comparison (earlier date comes first) + const comparison = new Date(a.dateStart).getTime() - new Date(b.dateStart).getTime(); + + // For past events: reverse chronological order (newest first) + // For upcoming events: chronological order (oldest first) + return type === 'past' ? -comparison : comparison; + } + // Different types: Books always go first within their month + return a._type === 'book' ? -1 : 1; + }); + }); + + // Filter out past months for upcoming events + const filteredEntries = type !== 'past' + ? sortedEntries.filter(([yearMonth]) => { + const [year, month] = yearMonth.split('-').map(Number); + const groupDate = new Date(year, month - 1); + const now = new Date(); + const currentMonthDate = new Date(now.getFullYear(), now.getMonth(), 1); + return groupDate >= currentMonthDate; + }) + : sortedEntries; + + return Object.fromEntries(filteredEntries); +} + +function formatMonthHeading(yearMonth: string) { + const [year, month] = yearMonth.split('-'); + const date = new Date(parseInt(year), parseInt(month) - 1); + const now = new Date(); + + if (date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth()) { + return 'This month'; + } + + const formatter = new Intl.DateTimeFormat('default', { + month: 'long', + year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined, + }); + + return formatter.format(date); +} + +// Use pre-filtered data from the API +const filteredEvents = type === 'upcoming' + ? [...(initialEvents.future || []), ...(initialEvents.today || [])] + : (initialEvents.past || []).filter(event => event.type !== 'deadline'); + +// Add books only to upcoming events (transform book.date to dateStart) +const booksWithType = type === 'upcoming' + ? (initialBooks || []).map(book => ({ + ...book, + _type: 'book', + dateStart: book.date, + })) + : []; + +const itemsToGroup = type === 'upcoming' + ? [...filteredEvents, ...booksWithType] + : filteredEvents; + +const groupedEvents = groupEventsByMonth(itemsToGroup, []); +--- + +
+ + + + + + + +
+ {Object.entries(groupedEvents).map(([yearMonth, events]) => ( +
+

+ {formatMonthHeading(yearMonth)} +

+
    + {events.map((event) => ( +
  1. + {event._type === 'book' ? ( + + ) : ( + + )} +
  2. + ))} +
+
+ ))} +
+
+ + \ No newline at end of file diff --git a/src/components/TimezoneSelector.vue b/src/components/TimezoneSelector.vue index 74ab98f0..f77fd0f2 100644 --- a/src/components/TimezoneSelector.vue +++ b/src/components/TimezoneSelector.vue @@ -90,8 +90,22 @@ function updateTimezone(event) { /** * Initialize timezone on component mount * Sets user's detected timezone if useLocalTimezone is true + * Fetches user info if it hasn't been fetched yet */ -onMounted(() => { +onMounted(async () => { + // If user info hasn't been fetched yet, fetch it + if (!userStore.userInfoFetched) { + try { + const response = await fetch('/api/get-user-info'); + if (response.ok) { + const data = await response.json(); + userStore.setUserInfo(data.timezone, data.acceptLanguage, data.geo); + } + } catch (error) { + console.error('Error fetching user info in TimezoneSelector:', error); + } + } + if (userStore.useLocalTimezone && userStore.geo?.timezone) { userStore.setTimezone(userTimezone.value, true); } diff --git a/src/pages/index.astro b/src/pages/index.astro index 5ba8245d..99de1554 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -1,6 +1,7 @@ --- import DefaultLayout from '../layouts/default.astro'; import Today from '../components/Today.vue'; +import StaticEventList from '../components/StaticEventList.astro'; import EventList from '../components/EventList.vue'; import FilterBar from '../components/FilterBar.vue'; import Filters from '../components/Filters.vue'; @@ -9,6 +10,33 @@ import MonthNav from '../components/MonthNav.vue'; // Disable prerendering to enable SSR export const prerender = false; +// Fetch events server-side for SSR +let eventsData = { future: [], today: [], past: [] }; +let booksData = []; +// Don't fetch user info during SSR - it requires client-specific headers +// The client will fetch this on mount +let userInfo = null; + +try { + // Get the base URL for API calls + const baseUrl = new URL(Astro.request.url).origin; + + // Fetch events and books in parallel (no user info during SSR) + const [eventsResponse, booksResponse] = await Promise.all([ + fetch(`${baseUrl}/api/get-events`), + fetch(`${baseUrl}/api/get-books`) + ]); + + if (eventsResponse.ok) { + eventsData = await eventsResponse.json(); + } + if (booksResponse.ok) { + booksData = await booksResponse.json(); + } +} catch (error) { + console.error('Error fetching data server-side:', error); +} + --- @@ -18,7 +46,24 @@ export const prerender = false;

Upcoming accessibility events

- + +
+ +
+ + +
diff --git a/src/pages/past-events.astro b/src/pages/past-events.astro index c944119d..e0c78db1 100644 --- a/src/pages/past-events.astro +++ b/src/pages/past-events.astro @@ -1,18 +1,63 @@ --- import DefaultLayout from '../layouts/default.astro'; +import StaticEventList from '../components/StaticEventList.astro'; import EventList from '../components/EventList.vue'; import MonthNav from '../components/MonthNav.vue'; // Disable prerendering to enable SSR export const prerender = false; +// Fetch events server-side for SSR +let eventsData = { future: [], today: [], past: [] }; +let booksData = []; +// Don't fetch user info during SSR - it requires client-specific headers +// The client will fetch this on mount +let userInfo = null; + +try { + // Get the base URL for API calls + const baseUrl = new URL(Astro.request.url).origin; + + // Fetch events and books in parallel (no user info during SSR) + const [eventsResponse, booksResponse] = await Promise.all([ + fetch(`${baseUrl}/api/get-events`), + fetch(`${baseUrl}/api/get-books`) + ]); + + if (eventsResponse.ok) { + eventsData = await eventsResponse.json(); + } + if (booksResponse.ok) { + booksData = await booksResponse.json(); + } +} catch (error) { + console.error('Error fetching data server-side:', error); +} + ---

Past accessibility events

- + +
+ +
+ + +
diff --git a/tests/filters.spec.ts b/tests/filters.spec.ts index 38234cde..9297fbd8 100644 --- a/tests/filters.spec.ts +++ b/tests/filters.spec.ts @@ -23,20 +23,23 @@ test('filter button is visible', async ({ page }) => { }); test('filter drawer opens when filter button is clicked', async ({ page }) => { - // Wait for initial page load - await page.waitForLoadState('domcontentloaded'); + // Wait for initial page load and network idle to ensure Vue components are hydrated + await page.waitForLoadState('networkidle'); // Get filter button and wait for it to be ready const filterButton = page.getByRole('button', { name: 'Filter' }); - await filterButton.waitFor({ state: 'visible', timeout: 5000 }); + await filterButton.waitFor({ state: 'visible', timeout: 10000 }); + + // Wait a bit more to ensure Vue component is fully hydrated + await page.waitForTimeout(500); // Click and wait for drawer await filterButton.click(); - // Get drawer and verify state + // Get drawer and verify state with increased timeout const drawer = page.locator('#filter-drawer'); - await expect(drawer).toHaveAttribute('open', ''); - await expect(drawer).toBeVisible(); + await expect(drawer).toHaveAttribute('open', '', { timeout: 10000 }); + await expect(drawer).toBeVisible({ timeout: 10000 }); // Optional: Wait for transition await page.waitForTimeout(300); diff --git a/tests/timezone.spec.ts b/tests/timezone.spec.ts index 0f4b0898..2d70d2f3 100644 --- a/tests/timezone.spec.ts +++ b/tests/timezone.spec.ts @@ -32,6 +32,15 @@ test.describe('Timezone Selector', () => { test('timezone can be changed between local and event times', async ({ page, }) => { + // Wait for page to be fully loaded including API calls + await page.waitForLoadState('networkidle'); + + // Wait for timezone to load (no longer shows "Loading timezone...") + const triggerButton = page.locator('#timezone-dropdown sl-button'); + await expect(triggerButton).not.toContainText('Loading timezone...', { + timeout: 15000, + }); + // Open dropdown const dropdown = page.locator('#timezone-dropdown'); await dropdown.click(); @@ -41,7 +50,6 @@ test.describe('Timezone Selector', () => { await expect(menu).toBeVisible({ timeout: 5000 }); // Get initial selection text - const triggerButton = page.locator('#timezone-dropdown sl-button'); const initialText = await triggerButton.textContent(); // Find which option is not selected and click it @@ -103,6 +111,15 @@ test.describe('Timezone Selector', () => { }); test('timezone selection persists after page reload', async ({ page }) => { + // Wait for page to be fully loaded including API calls + await page.waitForLoadState('networkidle'); + + // Wait for timezone to load (no longer shows "Loading timezone...") + const triggerButton = page.locator('#timezone-dropdown sl-button'); + await expect(triggerButton).not.toContainText('Loading timezone...', { + timeout: 15000, + }); + // Open dropdown const dropdown = page.locator('#timezone-dropdown'); await expect(dropdown).toBeVisible({ timeout: 5000 }); @@ -181,20 +198,20 @@ test.describe('Timezone Selector', () => { await page.waitForTimeout(2000); // Get the current selection text - const triggerButton = page.locator('#timezone-dropdown sl-button'); + const buttonElement = page.locator('#timezone-dropdown sl-button'); // Make sure the dropdown has closed and the button is visible - await expect(triggerButton).toBeVisible({ timeout: 5000 }); + await expect(buttonElement).toBeVisible({ timeout: 5000 }); // Check button text and retry if it's still loading - let selectedText = await triggerButton.textContent(); + let selectedText = await buttonElement.textContent(); console.log(`Initial button text after selection: "${selectedText}"`); // If text shows "Loading", wait a bit more and retry if (selectedText?.includes('Loading')) { console.log('Still loading timezone, waiting more time...'); await page.waitForTimeout(3000); - selectedText = await triggerButton.textContent(); + selectedText = await buttonElement.textContent(); console.log(`Button text after additional wait: "${selectedText}"`); } console.log(`Selected timezone before reload: "${selectedText}"`);