Skip to content
Open
128 changes: 99 additions & 29 deletions src/components/EventList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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
Expand Down Expand Up @@ -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>} Array of book objects or empty array on error
Expand Down Expand Up @@ -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;
}
});
Expand Down
170 changes: 170 additions & 0 deletions src/components/StaticEventList.astro
Original file line number Diff line number Diff line change
@@ -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<string, any[]> = {};

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, []);
---

<div>
<!-- Loading state (hidden by default, shown by Vue when needed) -->
<div class="loading-state" style="display: none;">
<sl-skeleton effect="sheen"></sl-skeleton>
<sl-skeleton effect="sheen"></sl-skeleton>
<sl-skeleton effect="sheen"></sl-skeleton>
<sl-skeleton effect="sheen"></sl-skeleton>
</div>

<!-- No events state (hidden by default, shown by Vue when needed) -->
<div class="no-events-state" style="display: none;">
<sl-alert open class="my-xl">
<sl-icon slot="icon" name="info-circle"></sl-icon>
{type === 'past'
? 'There are no past events to display.'
: 'There are no upcoming events to display.'
}
</sl-alert>
</div>

<!-- Events list (server-rendered, always visible) -->
<div id={`static-${type}-events`} class="flow flow-2xl">
{Object.entries(groupedEvents).map(([yearMonth, events]) => (
<section
id={`section-${yearMonth}`}
data-static-month={yearMonth}
class="month flow flow-m"
>
<h2 id={`heading-${yearMonth}`} class="month__heading">
{formatMonthHeading(yearMonth)}
</h2>
<ol
role="list"
class="flow flow-l"
aria-labelledby={`heading-${yearMonth}`}
>
{events.map((event) => (
<li>
{event._type === 'book' ? (
<EventBook book={event} />
) : (
<Event event={event} />
)}
</li>
))}
</ol>
</section>
))}
</div>
</div>

<style>
h2 {
font-size: var(--p-step-4);
}
</style>
16 changes: 15 additions & 1 deletion src/components/TimezoneSelector.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Loading