diff --git a/CardListing/6b49e9d4-70c2-43fc-85c3-f31c6d52bb3c.json b/CardListing/6b49e9d4-70c2-43fc-85c3-f31c6d52bb3c.json new file mode 100644 index 0000000..8e4286f --- /dev/null +++ b/CardListing/6b49e9d4-70c2-43fc-85c3-f31c6d52bb3c.json @@ -0,0 +1,79 @@ +{ + "data": { + "meta": { + "adoptsFrom": { + "name": "CardListing", + "module": "http://localhost:4201/catalog/catalog-app/listing/listing" + } + }, + "type": "card", + "attributes": { + "name": "Calendar Card with Embedded, Fitted, and Isolated Views", + "images": [], + "summary": "The CalendarCard defines a versatile calendar component for displaying date-based information and events. It supports multiple views (month, week, day) and allows for interaction such as navigating between periods and managing events. The card query fetches associated CalendarEvent items linked to the calendar, enabling dynamic event display across different formats. Embedded and fitted variations provide preconfigured, compact representations suitable for dashboards or snippets, featuring recent or upcoming events preview. The design includes detailed styling and templating to visualize calendar dates, events, and scheduling details, facilitating use cases like event planning, appointment tracking, and schedule management within a customizable interface.", + "cardInfo": { + "name": null, + "notes": null, + "summary": null, + "cardThumbnailURL": null + } + }, + "relationships": { + "skills": { + "links": { + "self": null + } + }, + "tags.0": { + "links": { + "self": "http://localhost:4201/catalog/Tag/140feda8-625b-4a24-9ddb-6f4da891aef2" + } + }, + "tags.1": { + "links": { + "self": "http://localhost:4201/catalog/Tag/4d0f9ae2-048e-4ce0-b263-7006602ce6a4" + } + }, + "license": { + "links": { + "self": "http://localhost:4201/catalog/License/4c5a023b-a72c-4f90-930b-da60a1de5b2d" + } + }, + "specs.0": { + "links": { + "self": "../Spec/233a92de-c7dd-45ef-9379-9fcbc4aedff2" + } + }, + "specs.1": { + "links": { + "self": "../Spec/3a92dec7-ddb5-4fd3-b99f-cbc4aedff282" + } + }, + "publisher": { + "links": { + "self": null + } + }, + "categories.0": { + "links": { + "self": "http://localhost:4201/catalog/Category/38b5d1dc-00d3-4a19-8998-29f0c19081de" + } + }, + "categories.1": { + "links": { + "self": "http://localhost:4201/catalog/Category/web-development" + } + }, + "examples.0": { + "links": { + "self": "../study-hub-4d8746e6-8dd6-452d-8001-6b574ea1de18/calendar/CalendarCard/2c3c78cd-fa4f-4988-b098-c8d099557fba" + } + }, + "cardInfo.theme": { + "links": { + "self": null + } + } + } + } +} \ No newline at end of file diff --git a/Spec/233a92de-c7dd-45ef-9379-9fcbc4aedff2.json b/Spec/233a92de-c7dd-45ef-9379-9fcbc4aedff2.json new file mode 100644 index 0000000..c29385d --- /dev/null +++ b/Spec/233a92de-c7dd-45ef-9379-9fcbc4aedff2.json @@ -0,0 +1,40 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": null, + "ref": { + "module": "../study-hub-4d8746e6-8dd6-452d-8001-6b574ea1de18/calendar/calendar", + "name": "CalendarEvent" + }, + "specType": "card", + "containedExamples": [], + "cardTitle": "Calendar Event", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "relationships": { + "linkedExamples": { + "links": { + "self": null + } + }, + "cardInfo.theme": { + "links": { + "self": null + } + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} \ No newline at end of file diff --git a/Spec/3a92dec7-ddb5-4fd3-b99f-cbc4aedff282.json b/Spec/3a92dec7-ddb5-4fd3-b99f-cbc4aedff282.json new file mode 100644 index 0000000..0a551f0 --- /dev/null +++ b/Spec/3a92dec7-ddb5-4fd3-b99f-cbc4aedff282.json @@ -0,0 +1,40 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": null, + "ref": { + "module": "../study-hub-4d8746e6-8dd6-452d-8001-6b574ea1de18/calendar/calendar", + "name": "CalendarCard" + }, + "specType": "card", + "containedExamples": [], + "cardTitle": "Calendar", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "relationships": { + "linkedExamples": { + "links": { + "self": null + } + }, + "cardInfo.theme": { + "links": { + "self": null + } + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} \ No newline at end of file diff --git a/study-hub-4d8746e6-8dd6-452d-8001-6b574ea1de18/calendar/CalendarCard/2c3c78cd-fa4f-4988-b098-c8d099557fba.json b/study-hub-4d8746e6-8dd6-452d-8001-6b574ea1de18/calendar/CalendarCard/2c3c78cd-fa4f-4988-b098-c8d099557fba.json new file mode 100644 index 0000000..7057ae3 --- /dev/null +++ b/study-hub-4d8746e6-8dd6-452d-8001-6b574ea1de18/calendar/CalendarCard/2c3c78cd-fa4f-4988-b098-c8d099557fba.json @@ -0,0 +1,31 @@ +{ + "data": { + "meta": { + "adoptsFrom": { + "name": "CalendarCard", + "module": "../calendar" + } + }, + "type": "card", + "attributes": { + "year": 2025, + "month": 9, + "cardInfo": { + "notes": null, + "title": null, + "description": null, + "thumbnailURL": null + }, + "viewMode": "month", + "calendarName": "Study Hub Calendar", + "selectedDate": "2025-09-10" + }, + "relationships": { + "cardInfo.theme": { + "links": { + "self": null + } + } + } + } +} \ No newline at end of file diff --git a/study-hub-4d8746e6-8dd6-452d-8001-6b574ea1de18/calendar/calendar.gts b/study-hub-4d8746e6-8dd6-452d-8001-6b574ea1de18/calendar/calendar.gts new file mode 100644 index 0000000..83fd138 --- /dev/null +++ b/study-hub-4d8746e6-8dd6-452d-8001-6b574ea1de18/calendar/calendar.gts @@ -0,0 +1,3717 @@ +// ═══ [EDIT TRACKING: ON] Mark all changes with ⁿ ═══ +import { + CardDef, + field, + contains, + Component, + realmURL, + linksTo, +} from 'https://cardstack.com/base/card-api'; // ¹ Core imports +import StringField from 'https://cardstack.com/base/string'; +import NumberField from 'https://cardstack.com/base/number'; +import DateField from 'https://cardstack.com/base/date'; +import DatetimeField from 'https://cardstack.com/base/datetime'; +import TextAreaField from 'https://cardstack.com/base/text-area'; +import { Button } from '@cardstack/boxel-ui/components'; // ² UI components +import { fn, concat } from '@ember/helper'; +import { on } from '@ember/modifier'; +import { action } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; +import { restartableTask } from 'ember-concurrency'; +import { htmlSafe } from '@ember/template'; +import { eq, lt, gt, subtract } from '@cardstack/boxel-ui/helpers'; +import { cached } from '@glimmer/tracking'; +import CalendarIcon from '@cardstack/boxel-icons/calendar'; +import type { LooseSingleCardDocument } from '@cardstack/runtime-common'; +import type { Query } from '@cardstack/runtime-common'; + +// Simple date formatting helper for calendar +function formatCalendarDate( + date: Date | string | number | null | undefined, + format?: string, +): string { + if (!date) return ''; + + let parsedDate: Date; + + if (typeof date === 'string') { + parsedDate = new Date(date); + } else if (typeof date === 'number') { + parsedDate = new Date(date); + } else if (date instanceof Date) { + parsedDate = date; + } else { + return ''; + } + + if (isNaN(parsedDate.getTime())) { + return ''; + } + + // Simple format options + switch (format) { + case 'short': + return parsedDate.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }); + case 'long': + return parsedDate.toLocaleDateString('en-US', { + weekday: 'long', + month: 'long', + day: 'numeric', + }); + case 'time': + return parsedDate.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true, + }); + case '24h': + return parsedDate.toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit', + hour12: false, + }); + case 'month': + return parsedDate.toLocaleDateString('en-US', { + month: 'long', + year: 'numeric', + }); + case 'day': + return parsedDate.toLocaleDateString('en-US', { weekday: 'short' }); + default: + return parsedDate.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); + } +} + +// ³ Calendar Event card definition +export class CalendarEvent extends CardDef { + static displayName = 'Calendar Event'; + static icon = CalendarIcon; + + @field title = contains(StringField); // ⁴ Event details + @field description = contains(TextAreaField); + @field startTime = contains(DatetimeField); + @field endTime = contains(DatetimeField); + @field location = contains(StringField); + @field isAllDay = contains(StringField); // "true" or "false" + @field eventType = contains(StringField); // meeting, appointment, reminder, etc. + @field eventColor = contains(StringField); // hex color + @field calendar = linksTo(() => CalendarCard); // ²² Link to parent calendar + + static embedded = class Embedded extends Component { + + }; + + static fitted = class Fitted extends Component { + + }; +} + +class CalendarIsolated extends Component { + // ¹¹ Isolated format with event management + @tracked currentDate = new Date(); + @tracked viewMode = 'month'; + @tracked showEventForm = false; + @tracked editingEvent: any = null; + @tracked showMoreEventsFor: any = null; + @tracked hoveredDate: Date | null = null; + @tracked newEventTitle = ''; + @tracked newEventDescription = ''; + @tracked newEventStartTime = ''; + @tracked newEventEndTime = ''; + @tracked newEventLocation = ''; + @tracked newEventIsAllDay = false; + @tracked newEventType = 'meeting'; + + constructor(owner: unknown, args: any) { + super(owner, args); + // ¹² Initialize from model data + if (this.args.model?.month && this.args.model?.year) { + this.currentDate = new Date( + this.args.model.year, + this.args.model.month - 1, + 1, + ); + } + if (this.args.model?.viewMode) { + this.viewMode = this.args.model.viewMode; + } + } + + get currentMonth() { + return this.currentDate.getMonth(); + } + + get currentYear() { + return this.currentDate.getFullYear(); + } + + get monthName() { + return this.currentDate.toLocaleDateString('en-US', { month: 'long' }); + } + + // ²⁴ Use getCards to query events for this calendar + eventsResult = this.args.context?.getCards( + this, + () => this.args.model?.eventsQuery, + () => this.args.model?.realmHrefs, + { isLive: true }, + ); + + // ²⁵ Dynamic event checking using queried events - cached to prevent infinite renders + @cached + get eventsOnDate() { + const events = (this.eventsResult?.instances as CalendarEvent[]) || []; + const eventMap = new Map(); + + events.forEach((event) => { + if (event?.startTime) { + const eventDate = new Date(event.startTime); + const dateKey = `${eventDate.getFullYear()}-${ + eventDate.getMonth() + 1 + }-${eventDate.getDate()}`; + if (!eventMap.has(dateKey)) { + eventMap.set(dateKey, []); + } + eventMap.get(dateKey).push(event); + } + }); + + return eventMap; + } + + // Helper method for template - returns cached array for specific date + getEventsForDate = (date: Date) => { + const dateKey = `${date.getFullYear()}-${ + date.getMonth() + 1 + }-${date.getDate()}`; + + return this.eventsOnDate.get(dateKey) || []; + }; + + // ¹⁵ Get events for the currently selected day in day view + get todaysEvents() { + return this.getEventsForDate(this.currentDate); + } + + // ¹⁶ Get events for current week (for week view) - improved with proper week calculation + get weekEvents() { + const weekStart = new Date(this.currentDate); + weekStart.setDate(weekStart.getDate() - weekStart.getDay()); + weekStart.setHours(0, 0, 0, 0); + + const events = []; + for (let i = 0; i < 7; i++) { + const date = new Date(weekStart); + date.setDate(date.getDate() + i); + const dayEvents = this.getEventsForDate(date); + + // Add day reference to each event for positioning + dayEvents.forEach((event: any) => { + event._weekDay = i; + event._date = new Date(date); + }); + + events.push(...dayEvents); + } + + return events.sort((a, b) => { + const timeA = a.startTime ? new Date(a.startTime).getTime() : 0; + const timeB = b.startTime ? new Date(b.startTime).getTime() : 0; + return timeA - timeB; + }); + } + + // ³⁶ Get current week days for week view + get currentWeekDays() { + const weekStart = new Date(this.currentDate); + weekStart.setDate(weekStart.getDate() - weekStart.getDay()); + weekStart.setHours(0, 0, 0, 0); + + const days = []; + for (let i = 0; i < 7; i++) { + const date = new Date(weekStart); + date.setDate(date.getDate() + i); + + days.push({ + date: new Date(date), + day: date.getDate(), + dayName: date.toLocaleDateString('en-US', { weekday: 'short' }), + isToday: this.isSameDay(date, new Date()), + events: this.getEventsForDate(date), + }); + } + + return days; + } + + // ³⁷ Generate time slots for week view (24-hour format) + get timeSlots() { + const slots = []; + for (let hour = 0; hour < 24; hour++) { + const time = new Date(); + time.setHours(hour, 0, 0, 0); + + slots.push({ + hour, + timeLabel: time.toLocaleTimeString('en-US', { + hour: 'numeric', + hour12: false, + }), + displayLabel: time.toLocaleTimeString('en-US', { + hour: 'numeric', + hour12: true, + }), + }); + } + return slots; + } + + // ³⁸ Get events for a specific hour across all days + getEventsForHour(hour: number) { + return this.weekEvents.filter((event) => { + if (!event.startTime) return false; + const eventTime = new Date(event.startTime); + return eventTime.getHours() === hour; + }); + } + + // Helper to check if event starts at specific hour + eventStartsAtHour(event: any, hour: number): boolean { + if (!event?.startTime) return false; + const eventTime = new Date(event.startTime); + return eventTime.getHours() === hour; + } + + get realmURL(): URL { + return this.args.model[realmURL]!; + } + + @cached + get calendarDays() { + // ⁷ Calendar day calculation with cached events + const year = this.currentYear; + const month = this.currentMonth; + const firstDay = new Date(year, month, 1); + const startDate = new Date(firstDay); + startDate.setDate(startDate.getDate() - firstDay.getDay()); + + const days = []; + const currentDate = new Date(startDate); + + for (let i = 0; i < 42; i++) { + const dayEvents = this.getEventsForDate(currentDate); + days.push({ + date: new Date(currentDate), + day: currentDate.getDate(), + isCurrentMonth: currentDate.getMonth() === month, + isToday: this.isSameDay(currentDate, new Date()), + + hasEvents: dayEvents.length > 0, + events: dayEvents as CalendarEvent[], // Include events array for each day + }); + currentDate.setDate(currentDate.getDate() + 1); + } + + return days; + } + + isSameDay(date1: Date, date2: Date): boolean { + return ( + date1.getFullYear() === date2.getFullYear() && + date1.getMonth() === date2.getMonth() && + date1.getDate() === date2.getDate() + ); + } + + hasEventsOnDate(date: Date): boolean { + // ⁸ Dynamic event detection using real events + return this.getEventsForDate(date).length > 0; + } + + @action + previousMonth() { + this.currentDate = new Date(this.currentYear, this.currentMonth - 1, 1); + this.updateModelState(); // ¹ʰ Persist state changes + } + + @action + nextMonth() { + this.currentDate = new Date(this.currentYear, this.currentMonth + 1, 1); + this.updateModelState(); // ¹ʰ Persist state changes + } + + @action + previousWeek() { + // Move to previous week + const prevWeek = new Date(this.currentDate); + prevWeek.setDate(prevWeek.getDate() - 7); + this.currentDate = prevWeek; + this.updateModelState(); + } + + @action + nextWeek() { + // Move to next week + const nextWeek = new Date(this.currentDate); + nextWeek.setDate(nextWeek.getDate() + 7); + this.currentDate = nextWeek; + this.updateModelState(); + } + + // ³⁹ Week date range display + get weekDateRange() { + const weekStart = new Date(this.currentDate); + weekStart.setDate(weekStart.getDate() - weekStart.getDay()); + + const weekEnd = new Date(weekStart); + weekEnd.setDate(weekEnd.getDate() + 6); + + const startMonth = weekStart.toLocaleDateString('en-US', { + month: 'short', + }); + const endMonth = weekEnd.toLocaleDateString('en-US', { month: 'short' }); + const year = weekStart.getFullYear(); + + if (startMonth === endMonth) { + return `${startMonth} ${weekStart.getDate()}-${weekEnd.getDate()}, ${year}`; + } else { + return `${startMonth} ${weekStart.getDate()} - ${endMonth} ${weekEnd.getDate()}, ${year}`; + } + } + + @action + selectDate(day: any) { + // Just show day view when clicking a date + this.viewMode = 'day'; + // Set the current date to the clicked day for day view + this.currentDate = new Date( + day.date.getFullYear(), + day.date.getMonth(), + day.date.getDate(), + ); + this.updateModelState(); + } + + @action + previousDay() { + const prevDay = new Date(this.currentDate); + prevDay.setDate(prevDay.getDate() - 1); + this.currentDate = prevDay; + this.updateModelState(); + } + + @action + nextDay() { + const nextDay = new Date(this.currentDate); + nextDay.setDate(nextDay.getDate() + 1); + this.currentDate = nextDay; + this.updateModelState(); + } + + @action + setViewMode(mode: string) { + this.viewMode = mode; + this.updateModelState(); // ¹⁷ Persist state changes + } + + private _addEvent = restartableTask(async () => { + const calendarEventSource = { + module: new URL(import.meta.url).href, + name: 'CalendarEvent', + }; + + // Use the current date being viewed, not today's date + const eventDate = new Date(this.currentDate); + eventDate.setHours(9, 0, 0, 0); // Default to 9 AM + const endDate = new Date(eventDate); + endDate.setHours(10, 0, 0, 0); // Default to 10 AM (1 hour duration) + + const doc: LooseSingleCardDocument = { + data: { + type: 'card', + attributes: { + title: 'New Event', + startTime: eventDate.toISOString(), + endTime: endDate.toISOString(), + eventType: 'meeting', + isAllDay: 'false', + }, + relationships: { + calendar: { + links: { + self: this.args.model.id ?? null, + }, + }, + }, + meta: { + adoptsFrom: calendarEventSource, + }, + }, + }; + + try { + await this.args.createCard?.( + calendarEventSource, + new URL(calendarEventSource.module), + { + realmURL: this.realmURL, + doc, + }, + ); + } catch (error) { + console.error('CalendarCard: Error creating event', error); + } + }); + + addEvent = () => { + this._addEvent.perform(); + }; + + @action + editEvent(event: any) { + // Open event card for editing + if (event && this.args.viewCard) { + this.args.viewCard(event, 'edit'); + } + } + + @action + showMoreEvents(day: any) { + this.showMoreEventsFor = day; + } + + @action + closeMoreEvents() { + this.showMoreEventsFor = null; + } + + @action + onDateHover(day: any) { + this.hoveredDate = day.date; + } + + @action + onDateLeave() { + this.hoveredDate = null; + } + + @action + handleEventClick(event: any, clickEvent: Event) { + if (clickEvent) { + clickEvent.stopPropagation(); + clickEvent.preventDefault(); + clickEvent.stopImmediatePropagation(); + } + this.editEvent(event); + } + + @action + handleMoreEventsClick(day: any, clickEvent: Event) { + if (clickEvent) { + clickEvent.stopPropagation(); + clickEvent.preventDefault(); + clickEvent.stopImmediatePropagation(); + } + this.showMoreEvents(day); + } + + resetEventForm() { + this.newEventTitle = ''; + this.newEventDescription = ''; + this.newEventStartTime = ''; + this.newEventEndTime = ''; + this.newEventLocation = ''; + this.newEventIsAllDay = false; + this.newEventType = 'meeting'; + } + + getEventTypeColor(type: string): string { + const colors = { + meeting: '#3b82f6', + appointment: '#10b981', + reminder: '#f59e0b', + task: '#8b5cf6', + personal: '#ef4444', + work: '#06b6d4', + }; + return colors[type as keyof typeof colors] || '#3b82f6'; + } + + // ¹⁸ Update model with current state + updateModelState() { + if (this.args.model) { + try { + this.args.model.month = this.currentDate.getMonth() + 1; + this.args.model.year = this.currentDate.getFullYear(); + this.args.model.viewMode = this.viewMode; + } catch (e) { + console.error('CalendarCard: Error updating model state', e); + } + } + } + + +} + +export class CalendarCard extends CardDef { + // ⁵ Generic Calendar card definition + static displayName = 'Calendar'; + static icon = CalendarIcon; + + @field month = contains(NumberField); // ⁶ Calendar state fields + @field year = contains(NumberField); + @field selectedDate = contains(DateField); + @field viewMode = contains(StringField); // month, week, day + @field calendarName = contains(StringField); // ⁷ Calendar identification + + // ⁹ Computed title + @field title = contains(StringField, { + computeVia: function (this: CalendarCard) { + try { + const name = this.calendarName || 'Calendar'; + const currentDate = new Date(); + const month = this.month || currentDate.getMonth() + 1; + const year = this.year || currentDate.getFullYear(); + return `${name} - ${month}/${year}`; + } catch (e) { + console.error('CalendarCard: Error computing title', e); + return 'Calendar'; + } + }, + }); + + // ²³ Query for events that belong to this calendar + get eventsQuery(): Query { + return { + filter: { + every: [ + { + type: { + module: new URL(import.meta.url).href, + name: 'CalendarEvent', + }, + }, + { + on: { + module: new URL(import.meta.url).href, + name: 'CalendarEvent', + }, + eq: { 'calendar.id': this.id }, + }, + ], + }, + sort: [ + { + by: 'startTime', + on: { + module: new URL(import.meta.url).href, + name: 'CalendarEvent', + }, + direction: 'asc', + }, + ], + }; + } + + get realmURL(): URL { + return this[realmURL]!; + } + + get realmHrefs() { + return [this.realmURL.href]; + } + + static isolated = CalendarIsolated; + get today() { + return new Date(); + } + + static embedded = class Embedded extends Component { + // ²⁰ Embedded format with event querying ²⁶ + get currentDate() { + return new Date(); + } + + // ²⁷ Query events for this calendar in embedded format + eventsResult = this.args.context?.getCards( + this, + () => this.args.model?.eventsQuery, + () => this.args.model?.realmHrefs, + { isLive: true }, + ); + + // ²⁸ Get today's events for embedded display + get todaysEvents() { + const today = new Date(); + const events = (this.eventsResult?.instances as CalendarEvent[]) || []; + return events + .filter((event) => { + if (!event?.startTime) return false; + const eventDate = new Date(event.startTime); + return ( + eventDate.getFullYear() === today.getFullYear() && + eventDate.getMonth() === today.getMonth() && + eventDate.getDate() === today.getDate() + ); + }) + .slice(0, 3); // Show max 3 events in embedded view + } + + + }; + + static fitted = class Fitted extends Component { + // ²¹ Fitted format with dynamic data and event querying ²⁹ + get currentDate() { + // Use model date if available, otherwise current date + if (this.args.model?.year && this.args.model?.month) { + return new Date(this.args.model.year, this.args.model.month - 1, 1); + } + return new Date(); + } + + get currentDayNumber() { + return this.currentDate.getDate(); + } + + // ³⁰ Query events for this calendar in fitted format + eventsResult = this.args.context?.getCards( + this, + () => this.args.model?.eventsQuery, + () => this.args.model?.realmHrefs, + { isLive: true }, + ); + + // ³¹ Get today's events for fitted display + get todaysEvents() { + const today = new Date(); + const events = (this.eventsResult?.instances as CalendarEvent[]) || []; + return events + .filter((event) => { + if (!event?.startTime) return false; + const eventDate = new Date(event.startTime); + return ( + eventDate.getFullYear() === today.getFullYear() && + eventDate.getMonth() === today.getMonth() && + eventDate.getDate() === today.getDate() + ); + }) + .slice(0, 3); // Show max 3 events + } + + // ³² Get total event count for displays + get totalEventCount() { + return this.eventsResult?.instances?.length || 0; + } + + + }; +}