// ============================================================================ // TRANSLATIONS // ============================================================================ // To add a new language: // 1. Add a new entry to the TRANSLATIONS object below // 2. Set the locale (e.g., 'es-ES' for Spanish) // 3. Copy the strings from 'en' and translate each value // ============================================================================ const DAYLIGHT_CALENDAR_CARD_VERSION = 'v4.6.0'; function getDaylightCalendarCardVersion() { return DAYLIGHT_CALENDAR_CARD_VERSION.includes('__') ? 'dev' : DAYLIGHT_CALENDAR_CARD_VERSION; } console.info(`Daylight Calendar Card ${getDaylightCalendarCardVersion()} loaded from skylight-calendar-card.js`); const TRANSLATIONS = { en: { locale: 'en-US', strings: { defaultTitle: 'Family Calendar', addEvent: 'Add Event', today: 'Today', month: 'Month', week: 'Week', schedule: 'Schedule', agenda: 'Agenda', resetAgenda: 'Jump to Today', openDashboard: 'Open dashboard', calendars: 'Calendars', calendar: 'Calendar', eventTitle: 'Event Title', eventTitlePlaceholder: 'Team Meeting', allDayEvent: 'All-day event', recurring: 'Recurring', eventOptions: 'Event Options', recurringEventOptions: 'Recurring options', recurrenceFrequency: 'Repeat', recurrenceEvery: 'Every', recurrenceIntervalSuffix: 'interval(s)', recurrenceEndsOn: 'Ends on', recurrenceCount: 'Occurrences (COUNT)', recurrenceWeekdays: 'Weekdays', recurrenceNoEndDate: 'No end date (optional)', recurrenceDaily: 'Daily', recurrenceWeekly: 'Weekly', recurrenceMonthly: 'Monthly', recurrenceYearly: 'Yearly', recurrenceNever: 'Never', recurrenceOn: 'On', recurrenceAfter: 'After', recurrenceOccurrences: 'occurrences', recurrenceSelectWeekday: 'Select at least one weekday for weekly recurring events', start: 'Start', end: 'End', startDate: 'Start Date', endDate: 'End Date', location: 'Location', locationPlaceholder: 'Conference Room A', description: 'Description', descriptionPlaceholder: 'Event details...', cancel: 'Cancel', createEvent: 'Create Event', creating: 'Creating...', forwardEvent: 'Forward Event', forwardEventTitle: 'Forward Event', forwardEventPrompt: 'Select one or more new calendars to forward this event to.', forwardEventAlreadyExists: 'Already has this event', forwardEventNoNewCalendars: 'Select at least one new calendar to forward this event to.', continue: 'Continue', editEvent: 'Edit Event', saveChanges: 'Save Changes', saving: 'Saving...', delete: 'Delete', deleting: 'Deleting...', deleteEventTitle: 'Delete Event', deleteRecurringEventTitle: 'Delete Recurring Event', deleteEventConfirm: 'Are you sure you want to delete "{title}"? This action cannot be undone.', deleteRecurringPrompt: '"{title}" is a recurring event. How would you like to delete it?', editRecurringEventTitle: 'Edit Recurring Event', editRecurringPrompt: '"{title}" is a recurring event. How would you like to edit it?', editThisOccurrence: 'Edit just this occurrence', editThisOccurrenceAndFuture: 'Edit this occurrence and all future occurrences', editEntireSeries: 'Edit the entire recurring series', deleteThisEventOnly: 'This event only', deleteThisOccurrence: 'Delete just this occurrence', deleteThisAndFutureEvents: 'This and future events', deleteThisOccurrenceAndFuture: 'Delete this occurrence and all future occurrences', deleteAllEvents: 'All events', deleteEntireSeries: 'Delete the entire recurring series', noEvents: 'No events', allDay: 'All Day', at: 'at', duration: 'Duration', attendees: 'Attendees', recurrence: 'Recurrence', recurringEvent: 'Recurring Event', unknownAttendee: 'Unknown', googleCalendarLimitationTitle: 'ℹ️ Google Calendar Limitation:', googleCalendarLimitationBody: 'Editing events is not currently supported for Google Calendar through Home Assistant. You can delete events from here, but to edit please use the Google Calendar app or website.', cannotModifyTitle: 'ℹ️ Cannot Modify:', cannotModifyBody: 'This event is missing required information (UID) for editing or deletion. You may need to recreate it.', untitledEvent: 'Untitled Event', noWritableCalendars: 'No writable calendars available', eventTitleRequired: 'Event title is required', startEndDatesRequired: 'Start and end dates are required', endDateBeforeStart: 'End date cannot be before start date', startEndTimesRequired: 'Start and end times are required', endTimeBeforeStart: 'End time must be after start time', failedCreateEvent: 'Failed to create event. Please try again.', failedUpdateEvent: 'Failed to update event. Please try again.', failedDeleteEvent: 'Failed to delete event. Please try again.', homeAssistantUnavailable: 'Home Assistant not available', googleCalendarEditError: 'Google Calendar does not support editing events through Home Assistant. Please use the Google Calendar app or website.', missingUidError: 'This event is missing required information (UID) and cannot be edited.', calendarNoModifyError: 'This calendar does not support event modifications. Try creating a new event instead.', createEventServiceError: 'Failed to create event', deleteEventServiceError: 'Failed to delete event', updateEventServiceError: 'Failed to update event. The calendar may not support modifications.', durationHour: '{count} hour', durationHours: '{count} hours', durationMinute: '{count} minute', durationMinutes: '{count} minutes', moreEvents: '+{count} more', eventTitleWithStartTime: '{title}, {time}', monthWeekPrefix: 'CW' } }, fr: { locale: 'fr-FR', strings: { defaultTitle: 'Calendrier familial', addEvent: 'Ajouter un événement', today: "Aujourd'hui", month: 'Mois', week: 'Semaine', schedule: 'Planning', agenda: 'Agenda', resetAgenda: "Retour à aujourd'hui", openDashboard: 'Ouvrir le tableau de bord', calendars: 'Calendriers', calendar: 'Calendrier', eventTitle: "Titre de l'événement", eventTitlePlaceholder: "Réunion d'équipe", allDayEvent: 'Événement sur toute la journée', recurring: 'Récurrent', eventOptions: "Options de l'événement", recurringEventOptions: 'Options de récurrence', recurrenceFrequency: 'Répéter', recurrenceEvery: 'Chaque', recurrenceIntervalSuffix: 'intervalle(s)', recurrenceEndsOn: 'Se termine le', recurrenceCount: 'Occurrences (COUNT)', recurrenceWeekdays: 'Jours de la semaine', recurrenceNoEndDate: 'Pas de date de fin (optionnel)', recurrenceDaily: 'Quotidien', recurrenceWeekly: 'Hebdomadaire', recurrenceMonthly: 'Mensuel', recurrenceYearly: 'Annuel', recurrenceNever: 'Jamais', recurrenceOn: 'Le', recurrenceAfter: 'Après', recurrenceOccurrences: 'occurrences', recurrenceSelectWeekday: 'Sélectionnez au moins un jour pour les événements hebdomadaires', start: 'Début', end: 'Fin', startDate: 'Date de début', endDate: 'Date de fin', location: 'Lieu', locationPlaceholder: 'Salle de conférence A', description: 'Description', descriptionPlaceholder: "Détails de l'événement...", cancel: 'Annuler', createEvent: 'Créer un événement', creating: 'Création...', forwardEvent: "Transférer l'événement", forwardEventTitle: "Transférer l'événement", forwardEventPrompt: 'Sélectionnez un ou plusieurs nouveaux calendriers vers lesquels transférer cet événement.', forwardEventAlreadyExists: 'Contient déjà cet événement', forwardEventNoNewCalendars: 'Sélectionnez au moins un nouveau calendrier vers lequel transférer cet événement.', continue: 'Continuer', editEvent: "Modifier l'événement", saveChanges: 'Enregistrer les modifications', saving: 'Enregistrement...', delete: 'Supprimer', deleting: 'Suppression...', deleteEventTitle: "Supprimer l'événement", deleteRecurringEventTitle: "Supprimer l'événement récurrent", deleteEventConfirm: 'Voulez-vous vraiment supprimer "{title}" ? Cette action est irréversible.', deleteRecurringPrompt: '"{title}" est un événement récurrent. Comment souhaitez-vous le supprimer ?', editRecurringEventTitle: 'Modifier un événement récurrent', editRecurringPrompt: '"{title}" est un événement récurrent. Comment souhaitez-vous le modifier ?', editThisOccurrence: 'Modifier uniquement cette occurrence', editThisOccurrenceAndFuture: 'Modifier cette occurrence et toutes les occurrences futures', editEntireSeries: 'Modifier toute la série récurrente', deleteThisEventOnly: 'Cet événement uniquement', deleteThisOccurrence: 'Supprimer uniquement cette occurrence', deleteThisAndFutureEvents: 'Cet événement et les suivants', deleteThisOccurrenceAndFuture: 'Supprimer cette occurrence et toutes les occurrences futures', deleteAllEvents: 'Tous les événements', deleteEntireSeries: 'Supprimer toute la série récurrente', noEvents: 'Aucun événement', allDay: 'Toute la journée', at: 'à', duration: 'Durée', attendees: 'Participants', recurrence: 'Récurrence', recurringEvent: 'Événement récurrent', unknownAttendee: 'Inconnu', googleCalendarLimitationTitle: 'ℹ️ Limitation Google Agenda :', googleCalendarLimitationBody: "La modification des événements n'est pas prise en charge pour Google Agenda via Home Assistant. Vous pouvez supprimer des événements ici, mais pour les modifier veuillez utiliser l'application ou le site Google Agenda.", cannotModifyTitle: 'ℹ️ Impossible de modifier :', cannotModifyBody: 'Cet événement ne contient pas les informations requises (UID) pour être modifié ou supprimé. Vous devrez peut-être le recréer.', untitledEvent: 'Événement sans titre', noWritableCalendars: 'Aucun calendrier modifiable disponible', eventTitleRequired: "Le titre de l'événement est requis", startEndDatesRequired: 'Les dates de début et de fin sont requises', endDateBeforeStart: 'La date de fin ne peut pas être antérieure à la date de début', startEndTimesRequired: 'Les heures de début et de fin sont requises', endTimeBeforeStart: "L'heure de fin doit être après l'heure de début", failedCreateEvent: "Impossible de créer l'événement. Veuillez réessayer.", failedUpdateEvent: "Impossible de modifier l'événement. Veuillez réessayer.", failedDeleteEvent: "Impossible de supprimer l'événement. Veuillez réessayer.", homeAssistantUnavailable: "Home Assistant n'est pas disponible", googleCalendarEditError: "Google Agenda ne permet pas la modification des événements via Home Assistant. Veuillez utiliser l'application ou le site Google Agenda.", missingUidError: 'Cet événement ne contient pas les informations requises (UID) et ne peut pas être modifié.', calendarNoModifyError: "Ce calendrier ne prend pas en charge les modifications d'événements. Essayez plutôt de créer un nouvel événement.", createEventServiceError: "Impossible de créer l'événement", deleteEventServiceError: "Impossible de supprimer l'événement", updateEventServiceError: "Impossible de modifier l'événement. Le calendrier ne prend peut-être pas en charge les modifications.", durationHour: '{count} heure', durationHours: '{count} heures', durationMinute: '{count} minute', durationMinutes: '{count} minutes', moreEvents: '+{count} de plus', eventTitleWithStartTime: '{title}, {time}', monthWeekPrefix: 'Sem' } }, de: { locale: 'de-DE', strings: { defaultTitle: 'Familienkalender', addEvent: 'Termin hinzufügen', today: 'Heute', month: 'Monat', week: 'Woche', schedule: 'Zeitplan', agenda: 'Agenda', resetAgenda: 'Zu heute springen', openDashboard: 'Dashboard öffnen', calendars: 'Kalender', calendar: 'Kalender', eventTitle: 'Terminname', eventTitlePlaceholder: 'Team-Meeting', allDayEvent: 'Ganztägiges Ereignis', recurring: 'Wiederkehrend', eventOptions: 'Terminoptionen', recurringEventOptions: 'Wiederholungsoptionen', recurrenceFrequency: 'Wiederholen', recurrenceEvery: 'Alle', recurrenceIntervalSuffix: 'Intervall(e)', recurrenceEndsOn: 'Endet am', recurrenceCount: 'Anzahl (COUNT)', recurrenceWeekdays: 'Wochentage', recurrenceNoEndDate: 'Kein Enddatum (optional)', recurrenceDaily: 'Täglich', recurrenceWeekly: 'Wöchentlich', recurrenceMonthly: 'Monatlich', recurrenceYearly: 'Jährlich', recurrenceNever: 'Nie', recurrenceOn: 'Am', recurrenceAfter: 'Nach', recurrenceOccurrences: 'Vorkommen', recurrenceSelectWeekday: 'Wählen Sie mindestens einen Wochentag für wöchentliche Termine aus', start: 'Beginn', end: 'Ende', startDate: 'Startdatum', endDate: 'Enddatum', location: 'Ort', locationPlaceholder: 'Konferenzraum A', description: 'Beschreibung', descriptionPlaceholder: 'Ereignisdetails...', cancel: 'Abbrechen', createEvent: 'Termin erstellen', creating: 'Wird erstellt...', forwardEvent: 'Termin weiterleiten', forwardEventTitle: 'Termin weiterleiten', forwardEventPrompt: 'Wählen Sie einen oder mehrere neue Kalender aus, an die dieser Termin weitergeleitet werden soll.', forwardEventAlreadyExists: 'Enthält diesen Termin bereits', forwardEventNoNewCalendars: 'Wählen Sie mindestens einen neuen Kalender aus, an den dieser Termin weitergeleitet werden soll.', continue: 'Weiter', editEvent: 'Termin bearbeiten', saveChanges: 'Änderungen speichern', saving: 'Wird gespeichert...', delete: 'Löschen', deleting: 'Wird gelöscht...', deleteEventTitle: 'Termin löschen', deleteRecurringEventTitle: 'Wiederkehrenden Termin löschen', deleteEventConfirm: 'Möchten Sie "{title}" wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.', deleteRecurringPrompt: '"{title}" ist ein wiederkehrender Termin. Wie möchten Sie ihn löschen?', editRecurringEventTitle: 'Wiederkehrenden Termin bearbeiten', editRecurringPrompt: '"{title}" ist ein wiederkehrender Termin. Wie möchten Sie ihn bearbeiten?', editThisOccurrence: 'Nur dieses Vorkommen bearbeiten', editThisOccurrenceAndFuture: 'Dieses und alle zukünftigen Vorkommen bearbeiten', editEntireSeries: 'Die gesamte Serie bearbeiten', deleteThisEventOnly: 'Nur dieses Ereignis', deleteThisOccurrence: 'Nur dieses Vorkommen löschen', deleteThisAndFutureEvents: 'Dieses und zukünftige Ereignisse', deleteThisOccurrenceAndFuture: 'Dieses Vorkommen und alle zukünftigen Vorkommen löschen', deleteAllEvents: 'Alle Ereignisse', deleteEntireSeries: 'Die gesamte Serie löschen', noEvents: 'Keine Ereignisse', allDay: 'Ganztägig', at: 'um', duration: 'Dauer', attendees: 'Teilnehmende', recurrence: 'Wiederholung', recurringEvent: 'Wiederkehrender Termin', unknownAttendee: 'Unbekannt', googleCalendarLimitationTitle: 'ℹ️ Google-Kalender-Einschränkung:', googleCalendarLimitationBody: 'Das Bearbeiten von Terminen wird für Google Kalender in Home Assistant derzeit nicht unterstützt. Sie können Termine hier löschen, zum Bearbeiten nutzen Sie bitte die Google Kalender App oder Website.', cannotModifyTitle: 'ℹ️ Kann nicht bearbeitet werden:', cannotModifyBody: 'Diesem Ereignis fehlen erforderliche Informationen (UID) zum Bearbeiten oder Löschen. Möglicherweise müssen Sie es neu erstellen.', untitledEvent: 'Unbenannter Termin', noWritableCalendars: 'Keine beschreibbaren Kalender verfügbar', eventTitleRequired: 'Ein Terminname ist erforderlich', startEndDatesRequired: 'Start- und Enddatum sind erforderlich', endDateBeforeStart: 'Das Enddatum darf nicht vor dem Startdatum liegen', startEndTimesRequired: 'Start- und Endzeit sind erforderlich', endTimeBeforeStart: 'Die Endzeit muss nach der Startzeit liegen', failedCreateEvent: 'Termin konnte nicht erstellt werden. Bitte erneut versuchen.', failedUpdateEvent: 'Termin konnte nicht aktualisiert werden. Bitte erneut versuchen.', failedDeleteEvent: 'Termin konnte nicht gelöscht werden. Bitte erneut versuchen.', homeAssistantUnavailable: 'Home Assistant nicht verfügbar', googleCalendarEditError: 'Google Kalender unterstützt das Bearbeiten von Terminen über Home Assistant nicht. Bitte verwenden Sie die Google Kalender App oder Website.', missingUidError: 'Dieses Ereignis enthält keine erforderlichen Informationen (UID) und kann nicht bearbeitet werden.', calendarNoModifyError: 'Dieser Kalender unterstützt keine Terminänderungen. Bitte erstellen Sie stattdessen einen neuen Termin.', createEventServiceError: 'Termin konnte nicht erstellt werden', deleteEventServiceError: 'Termin konnte nicht gelöscht werden', updateEventServiceError: 'Termin konnte nicht aktualisiert werden. Der Kalender unterstützt möglicherweise keine Änderungen.', durationHour: '{count} Stunde', durationHours: '{count} Stunden', durationMinute: '{count} Minute', durationMinutes: '{count} Minuten', moreEvents: '+{count} mehr', eventTitleWithStartTime: '{title}, {time}', monthWeekPrefix: 'KW' } }, nl: { locale: 'nl-NL', strings: { defaultTitle: 'Familie agenda', addEvent: 'Nieuwe afspraak', today: 'Vandaag', month: 'Maand', week: 'Week', schedule: 'Schema', agenda: 'Agenda', resetAgenda: 'Ga naar vandaag', openDashboard: 'Dashboard openen', calendars: "Agenda's", calendar: 'agenda', eventTitle: 'Afspraak onderwerp', eventTitlePlaceholder: 'Groepsafspraak', allDayEvent: 'Hele dag', recurring: 'Terugkerend', eventOptions: 'Afspraakopties', recurringEventOptions: 'terugkerend mogelijkheden', recurrenceFrequency: 'Herhaal', recurrenceEvery: 'Elke', recurrenceIntervalSuffix: 'herhalen elke', recurrenceEndsOn: 'Stop op', recurrenceCount: 'Gebeurtenissen (COUNT)', recurrenceWeekdays: 'Werkdagen', recurrenceNoEndDate: 'Geen einddatum (optioneel)', recurrenceDaily: 'Dagelijks', recurrenceWeekly: 'Wekelijks', recurrenceMonthly: 'Maandelijks', recurrenceYearly: 'Jaarlijks', recurrenceNever: 'Nooit', recurrenceOn: 'Op', recurrenceAfter: 'Na', recurrenceOccurrences: 'gebeurtenissen', recurrenceSelectWeekday: 'Selecteer ten minste één dag voor wekelijks terugkerende afspraken', start: 'Start', end: 'Einde', startDate: 'Begindatum', endDate: 'Einddatum', location: 'Locatie', locationPlaceholder: 'Vergaderruimte A', description: 'Omschrijving', descriptionPlaceholder: 'Afspraak details...', cancel: 'Annuleren', createEvent: 'Afspraak toevoegen', creating: 'Aanmaken...', forwardEvent: 'Afspraak doorsturen', forwardEventTitle: 'Afspraak doorsturen', forwardEventPrompt: 'Selecteer een of meer nieuwe agenda’s waarnaar deze afspraak moet worden doorgestuurd.', forwardEventAlreadyExists: 'Bevat deze afspraak al', forwardEventNoNewCalendars: 'Selecteer minstens één nieuwe agenda om deze afspraak naar door te sturen.', continue: 'Doorgaan', editEvent: 'Afspraak bewerken', saveChanges: 'Wijzigingen opslaan', saving: 'Opslaan...', delete: 'Verwijder', deleting: 'Verwijderen...', deleteEventTitle: 'Afspraak verwijderen', deleteRecurringEventTitle: 'Herhaalafspraak verwijderen', deleteEventConfirm: 'Ben je er zeker van dat je "{title}" wil verwijderen? Deze actie is onomkeerbaar.', deleteRecurringPrompt: '"{title}" is een herhalende afspraak. Hoe wil je hem verwijderen?', editRecurringEventTitle: 'Herhalende afspraak bewerken', editRecurringPrompt: '"{title}" is een herhalende afspraak. Hoe wil je hem bewerken?', editThisOccurrence: 'Alleen deze afspraak wijzigen', editThisOccurrenceAndFuture: 'Deze afspraak en alle toekomstige afspraken bewerken', editEntireSeries: 'Bewerk de volledige afspraken reeks', deleteThisEventOnly: 'Alleen deze afspraak', deleteThisOccurrence: 'Verwijder alleen dit moment', deleteThisAndFutureEvents: 'Deze en alle toekomstige afspraken', deleteThisOccurrenceAndFuture: 'Verwijder deze en alle toekomstige afspraken', deleteAllEvents: 'Alle afspraken', deleteEntireSeries: 'Verwijder de volledige reeks', noEvents: 'Geen afspraken', allDay: 'Hele dag', at: 'op', duration: 'Duur', attendees: 'Deelnemers', recurrence: 'Terugkerend', recurringEvent: 'Terugkerende afpraak', unknownAttendee: 'Onbekend', googleCalendarLimitationTitle: 'ℹ️ Google Calendar beperking:', googleCalendarLimitationBody: 'Het bewerken van Google Calendar afspraken wordt momenteel niet ondersteund in Home Assistant. Je kunt afspraken verwijderen, maar voor het bewerken kun je de Google Calendar app of website gebruiken.', cannotModifyTitle: 'ℹ️ Kan het volgende niet aanpassen:', cannotModifyBody: 'Deze afspraak mist de vereiste informatie (UID) om te kunnen bewerken of verwijderen. Mogelijk moet je hem opnieuw aanmaken.', untitledEvent: 'Afspraak zonder onderwerp', noWritableCalendars: "Geen bewerkbare agenda's beschikbaar", eventTitleRequired: 'Afspraak onderwerp is verplicht', startEndDatesRequired: 'Begin- en einddatum zijn verplicht', endDateBeforeStart: 'Einddatum mag niet voor begindatum zijn', startEndTimesRequired: 'Begin- en eindtijd is verplicht', endTimeBeforeStart: 'Eindtijd mag niet voor begintijd zijn', failedCreateEvent: 'Niet gelukt om afspraak aan te maken. Probeer opnieuw.', failedUpdateEvent: 'Niet gelukt om afspraak bij te werken. Probeer opnieuw.', failedDeleteEvent: 'Niet gelukt om afspraak te verwijderen. Probeer opnieuw.', homeAssistantUnavailable: 'Home Assistant niet beschikbaar', googleCalendarEditError: 'Het wordt niet ondersteund om Google Calendar afspraken te bewerken binnen Home Assistant. Maak gebruik van de Google Calendar app of website.', missingUidError: 'Deze afspraak mist de vereiste informatie (UID) en kan daarom niet bewerkt worden.', calendarNoModifyError: 'Het bewerken van afspraken wordt niet ondersteund in deze agenda. Maak een nieuwe afspraak aan.', createEventServiceError: 'Niet gelukt om afspraak aan te maken', deleteEventServiceError: 'Niet gelukt om afspraak te verwijderen', updateEventServiceError: 'Niet gelukt om afspraak bij te werken. Mogelijk wordt dit niet ondersteund.', durationHour: '{count} uur', durationHours: '{count} uren', durationMinute: '{count} minuut', durationMinutes: '{count} minuten', moreEvents: '+{count} meer', eventTitleWithStartTime: '{title}, {time}', monthWeekPrefix: 'KW' } }, es: { locale: 'es-ES', strings: { defaultTitle: 'Calendario Familiar', addEvent: 'Añadir evento', today: 'Hoy', month: 'Mes', week: 'Semana', schedule: 'Horario', agenda: 'Agenda', resetAgenda: 'Ir a hoy', openDashboard: 'Abrir panel', calendars: 'Calendarios', calendar: 'Calendario', eventTitle: 'Título del evento', eventTitlePlaceholder: 'Reunión de equipo', allDayEvent: 'Evento de todo el día', recurring: 'Recurrente', eventOptions: 'Opciones del evento', recurringEventOptions: 'Opciones de recurrencia', recurrenceFrequency: 'Repetir', recurrenceEvery: 'Cada', recurrenceIntervalSuffix: 'intervalo(s)', recurrenceEndsOn: 'Termina el', recurrenceCount: 'Ocurrencias (CANTIDAD)', recurrenceWeekdays: 'Días de la semana', recurrenceNoEndDate: 'Sin fecha de finalización (opcional)', recurrenceDaily: 'Diariamente', recurrenceWeekly: 'Semanalmente', recurrenceMonthly: 'Mensualmente', recurrenceYearly: 'Anualmente', recurrenceNever: 'Nunca', recurrenceOn: 'El', recurrenceAfter: 'Después de', recurrenceOccurrences: 'ocurrencias', recurrenceSelectWeekday: 'Selecciona al menos un día de la semana para los eventos recurrentes semanales', start: 'Inicio', end: 'Fin', startDate: 'Fecha de inicio', endDate: 'Fecha de fin', location: 'Ubicación', locationPlaceholder: 'Sala de conferencias A', description: 'Descripción', descriptionPlaceholder: 'Detalles del evento...', cancel: 'Cancelar', createEvent: 'Crear evento', creating: 'Creando...', forwardEvent: 'Reenviar evento', forwardEventTitle: 'Reenviar evento', forwardEventPrompt: 'Selecciona uno o más calendarios nuevos a los que reenviar este evento.', forwardEventAlreadyExists: 'Ya contiene este evento', forwardEventNoNewCalendars: 'Selecciona al menos un calendario nuevo al que reenviar este evento.', continue: 'Continuar', editEvent: 'Editar evento', saveChanges: 'Guardar cambios', saving: 'Guardando...', delete: 'Eliminar', deleting: 'Eliminando...', deleteEventTitle: 'Eliminar evento', deleteRecurringEventTitle: 'Eliminar evento recurrente', deleteEventConfirm: '¿Estás seguro de que quieres eliminar "{title}"? Esta acción no se puede deshacer.', deleteRecurringPrompt: '"{title}" es un evento recurrente. ¿Cómo te gustaría eliminarlo?', editRecurringEventTitle: 'Editar evento recurrente', editRecurringPrompt: '"{title}" es un evento recurrente. ¿Cómo te gustaría editarlo?', editThisOccurrence: 'Editar solo esta ocurrencia', editThisOccurrenceAndFuture: 'Editar esta ocurrencia y todas las futuras', editEntireSeries: 'Editar toda la serie recurrente', deleteThisEventOnly: 'Solo este evento', deleteThisOccurrence: 'Eliminar solo esta ocurrencia', deleteThisAndFutureEvents: 'Este y los eventos futuros', deleteThisOccurrenceAndFuture: 'Eliminar esta ocurrencia y todas las futuras', deleteAllEvents: 'Todos los eventos', deleteEntireSeries: 'Eliminar toda la serie recurrente', noEvents: 'No hay eventos', allDay: 'Todo el día', at: 'a las', duration: 'Duración', attendees: 'Asistentes', recurrence: 'Recurrencia', recurringEvent: 'Evento recurrente', unknownAttendee: 'Desconocido', googleCalendarLimitationTitle: 'ℹ️ Limitación de Google Calendar:', googleCalendarLimitationBody: 'Actualmente no se admite la edición de eventos para Google Calendar a través de Home Assistant. Puedes eliminar eventos desde aquí, pero para editarlos, utiliza la aplicación o el sitio web de Google Calendar.', cannotModifyTitle: 'ℹ️ No se puede modificar:', cannotModifyBody: 'A este evento le falta información obligatoria (UID) para su edición o eliminación. Es posible que tengas que volver a crearlo.', untitledEvent: 'Evento sin título', noWritableCalendars: 'No hay calendarios editables disponibles', eventTitleRequired: 'El título del evento es obligatorio', startEndDatesRequired: 'Las fechas de inicio y fin son obligatorias', endDateBeforeStart: 'La fecha de fin no puede ser anterior a la fecha de inicio', startEndTimesRequired: 'Las horas de inicio y fin son obligatorias', endTimeBeforeStart: 'La hora de fin debe ser posterior a la hora de inicio', failedCreateEvent: 'Error al crear el evento. Por favor, inténtalo de nuevo.', failedUpdateEvent: 'Error al actualizar el evento. Por favor, inténtalo de nuevo.', failedDeleteEvent: 'Error al eliminar el evento. Por favor, inténtalo de nuevo.', homeAssistantUnavailable: 'Home Assistant no está disponible', googleCalendarEditError: 'Google Calendar no admite la edición de eventos a través de Home Assistant. Por favor, utiliza la aplicación o el sitio web de Google Calendar.', missingUidError: 'A este evento le falta información obligatoria (UID) y no se puede editar.', calendarNoModifyError: 'Este calendario no admite modificaciones de eventos. Intenta crear un nuevo evento en su lugar.', createEventServiceError: 'Error al crear el evento', deleteEventServiceError: 'Error al eliminar el evento', updateEventServiceError: 'Error al actualizar el evento. Es posible que el calendario no admita modificaciones.', durationHour: '{count} hora', durationHours: '{count} horas', durationMinute: '{count} minuto', durationMinutes: '{count} minutos', moreEvents: '+{count} más', eventTitleWithStartTime: '{title}, {time}', monthWeekPrefix: 'Sem.' } }, ca: { locale: 'ca-ES', strings: { defaultTitle: 'Calendari Familiar', addEvent: 'Afegir esdeveniment', today: 'Avui', month: 'Mes', week: 'Setmana', schedule: 'Horari', agenda: 'Agenda', resetAgenda: "Anar a avui", openDashboard: 'Obrir tauler', calendars: 'Calendaris', calendar: 'Calendari', eventTitle: "Títol de l'esdeveniment", eventTitlePlaceholder: "Reunió d'equip", allDayEvent: 'Esdeveniment de tot el dia', recurring: 'Recurrent', eventOptions: "Opcions de l'esdeveniment", recurringEventOptions: 'Opcions de recurrència', recurrenceFrequency: 'Repetir', recurrenceEvery: 'Cada', recurrenceIntervalSuffix: 'interval(s)', recurrenceEndsOn: 'Acaba el', recurrenceCount: 'Ocurrències (QUANTITAT)', recurrenceWeekdays: 'Dies de la setmana', recurrenceNoEndDate: "Sense data de finalització (opcional)", recurrenceDaily: 'Diàriament', recurrenceWeekly: 'Setmanalment', recurrenceMonthly: 'Mensualment', recurrenceYearly: 'Anualment', recurrenceNever: 'Mai', recurrenceOn: 'El', recurrenceAfter: 'Després de', recurrenceOccurrences: 'ocurrències', recurrenceSelectWeekday: "Selecciona almenys un dia de la setmana per als esdeveniments recurrents setmanals", start: 'Inici', end: 'Fi', startDate: "Data d'inici", endDate: 'Data de fi', location: 'Ubicació', locationPlaceholder: 'Sala de conferències A', description: 'Descripció', descriptionPlaceholder: "Detalls de l'esdeveniment...", cancel: 'Cancel·lar', createEvent: 'Crear esdeveniment', creating: 'Creant...', forwardEvent: "Reenviar esdeveniment", forwardEventTitle: "Reenviar esdeveniment", forwardEventPrompt: 'Selecciona un o més calendaris nous als quals reenviar aquest esdeveniment.', forwardEventAlreadyExists: 'Ja conté aquest esdeveniment', forwardEventNoNewCalendars: 'Selecciona almenys un calendari nou al qual reenviar aquest esdeveniment.', continue: 'Continuar', editEvent: "Editar esdeveniment", saveChanges: 'Desar canvis', saving: 'Desant...', delete: 'Eliminar', deleting: 'Eliminant...', deleteEventTitle: "Eliminar esdeveniment", deleteRecurringEventTitle: 'Eliminar esdeveniment recurrent', deleteEventConfirm: 'Estàs segur que vols eliminar "{title}"? Aquesta acció no es pot desfer.', deleteRecurringPrompt: '"{title}" és un esdeveniment recurrent. Com vols eliminar-lo?', editRecurringEventTitle: 'Editar esdeveniment recurrent', editRecurringPrompt: '"{title}" és un esdeveniment recurrent. Com vols editar-lo?', editThisOccurrence: 'Editar només aquesta ocurrència', editThisOccurrenceAndFuture: 'Editar aquesta ocurrència i totes les futures', editEntireSeries: 'Editar tota la sèrie recurrent', deleteThisEventOnly: 'Només aquest esdeveniment', deleteThisOccurrence: 'Eliminar només aquesta ocurrència', deleteThisAndFutureEvents: 'Aquest i els esdeveniments futurs', deleteThisOccurrenceAndFuture: 'Eliminar aquesta ocurrència i totes les futures', deleteAllEvents: 'Tots els esdeveniments', deleteEntireSeries: 'Eliminar tota la sèrie recurrent', noEvents: 'No hi ha esdeveniments', allDay: 'Tot el dia', at: 'a les', duration: 'Durada', attendees: 'Assistents', recurrence: 'Recurrència', recurringEvent: 'Esdeveniment recurrent', unknownAttendee: 'Desconegut', googleCalendarLimitationTitle: 'ℹ️ Limitació de Google Calendar:', googleCalendarLimitationBody: "Actualment no es permet l'edició d'esdeveniments de Google Calendar a través de Home Assistant. Pots eliminar esdeveniments des d'aquí, però per editar-los, utilitza l'aplicació o el lloc web de Google Calendar.", cannotModifyTitle: 'ℹ️ No es pot modificar:', cannotModifyBody: "A aquest esdeveniment li falta informació obligatòria (UID) per a la seva edició o eliminació. Potser cal que el tornis a crear.", untitledEvent: 'Esdeveniment sense títol', noWritableCalendars: 'No hi ha calendaris editables disponibles', eventTitleRequired: "El títol de l'esdeveniment és obligatori", startEndDatesRequired: "Les dates d'inici i fi són obligatòries", endDateBeforeStart: "La data de fi no pot ser anterior a la data d'inici", startEndTimesRequired: "Les hores d'inici i fi són obligatòries", endTimeBeforeStart: "L'hora de fi ha de ser posterior a l'hora d'inici", failedCreateEvent: "Error en crear l'esdeveniment. Si us plau, torna-ho a provar.", failedUpdateEvent: "Error en actualitzar l'esdeveniment. Si us plau, torna-ho a provar.", failedDeleteEvent: "Error en eliminar l'esdeveniment. Si us plau, torna-ho a provar.", homeAssistantUnavailable: 'Home Assistant no està disponible', googleCalendarEditError: "Google Calendar no permet l'edició d'esdeveniments a través de Home Assistant. Si us plau, utilitza l'aplicació o el lloc web de Google Calendar.", missingUidError: "A aquest esdeveniment li falta informació obligatòria (UID) i no es pot editar.", calendarNoModifyError: "Aquest calendari no admet modificacions d'esdeveniments. Prova de crear un nou esdeveniment.", createEventServiceError: "Error en crear l'esdeveniment", deleteEventServiceError: "Error en eliminar l'esdeveniment", updateEventServiceError: "Error en actualitzar l'esdeveniment. És possible que el calendari no admeti modificacions.", durationHour: '{count} hora', durationHours: '{count} hores', durationMinute: '{count} minut', durationMinutes: '{count} minuts', moreEvents: '+{count} més', eventTitleWithStartTime: '{title}, {time}', monthWeekPrefix: 'Set.' } }, da: { locale: 'da-DK', strings: { defaultTitle: 'Familiekalender', addEvent: 'Tilføj begivenhed', today: 'I dag', month: 'Måned', week: 'Uge', schedule: 'Skema', agenda: 'Agenda', resetAgenda: 'Gå til i dag', openDashboard: 'Åbn dashboard', calendars: 'Kalendere', calendar: 'Kalender', eventTitle: 'Begivenhedstitel', eventTitlePlaceholder: 'Teammøde', allDayEvent: 'Heldagsbegivenhed', recurring: 'Gentagende', eventOptions: 'Begivenhedsindstillinger', recurringEventOptions: 'Gentagelsesindstillinger', recurrenceFrequency: 'Gentag', recurrenceEvery: 'Hver', recurrenceIntervalSuffix: 'interval(er)', recurrenceEndsOn: 'Slutter den', recurrenceCount: 'Antal forekomster', recurrenceWeekdays: 'Ugedage', recurrenceNoEndDate: 'Ingen slutdato (valgfrit)', recurrenceDaily: 'Dagligt', recurrenceWeekly: 'Ugentligt', recurrenceMonthly: 'Månedligt', recurrenceYearly: 'Årligt', recurrenceNever: 'Aldrig', recurrenceOn: 'Den', recurrenceAfter: 'Efter', recurrenceOccurrences: 'forekomster', recurrenceSelectWeekday: 'Vælg mindst én ugedag for ugentligt gentagende begivenheder', start: 'Start', end: 'Slut', startDate: 'Startdato', endDate: 'Slutdato', location: 'Sted', locationPlaceholder: 'Konferencerum A', description: 'Beskrivelse', descriptionPlaceholder: 'Begivenhedsdetaljer...', cancel: 'Annuller', createEvent: 'Opret begivenhed', creating: 'Opretter...', forwardEvent: 'Kopiér begivenhed', forwardEventTitle: 'Kopiér begivenhed', forwardEventPrompt: 'Vælg en eller flere kalendere, som begivenheden skal kopieres til.', forwardEventAlreadyExists: 'Denne begivenhed findes allerede', forwardEventNoNewCalendars: 'Vælg mindst én ny kalender, som begivenheden skal kopieres til.', continue: 'Fortsæt', editEvent: 'Rediger begivenhed', saveChanges: 'Gem ændringer', saving: 'Gemmer...', delete: 'Slet', deleting: 'Sletter...', deleteEventTitle: 'Slet begivenhed', deleteRecurringEventTitle: 'Slet gentagende begivenhed', deleteEventConfirm: 'Er du sikker på, at du vil slette "{title}"? Denne handling kan ikke fortrydes.', deleteRecurringPrompt: '"{title}" er en gentagende begivenhed. Hvordan vil du slette den?', editRecurringEventTitle: 'Rediger gentagende begivenhed', editRecurringPrompt: '"{title}" er en gentagende begivenhed. Hvordan vil du redigere den?', editThisOccurrence: 'Rediger kun denne forekomst', editThisOccurrenceAndFuture: 'Rediger denne forekomst og alle fremtidige forekomster', editEntireSeries: 'Rediger hele serien', deleteThisEventOnly: 'Kun denne begivenhed', deleteThisOccurrence: 'Slet kun denne forekomst', deleteThisAndFutureEvents: 'Denne og fremtidige begivenheder', deleteThisOccurrenceAndFuture: 'Slet denne forekomst og alle fremtidige forekomster', deleteAllEvents: 'Alle forekomster', deleteEntireSeries: 'Slet hele serien', noEvents: 'Ingen begivenheder', allDay: 'Hele dagen', at: 'kl.', duration: 'Varighed', attendees: 'Deltagere', recurrence: 'Gentagelse', recurringEvent: 'Gentagende begivenhed', unknownAttendee: 'Ukendt', googleCalendarLimitationTitle: 'ℹ️ Begrænsning i Google Kalender:', googleCalendarLimitationBody: 'Redigering af begivenheder understøttes i øjeblikket ikke for Google Kalender via Home Assistant. Du kan slette begivenheder herfra, men brug Google Kalender-appen eller webstedet for at redigere dem.', cannotModifyTitle: 'ℹ️ Kan ikke ændres:', cannotModifyBody: 'Denne begivenhed mangler nødvendige oplysninger (UID) til redigering eller sletning. Du skal muligvis oprette den igen.', untitledEvent: 'Begivenhed uden titel', noWritableCalendars: 'Ingen redigerbare kalendere tilgængelige', eventTitleRequired: 'Begivenhedstitel er påkrævet', startEndDatesRequired: 'Start- og slutdatoer er påkrævede', endDateBeforeStart: 'Slutdato kan ikke være før startdato', startEndTimesRequired: 'Start- og sluttidspunkter er påkrævede', endTimeBeforeStart: 'Sluttidspunkt skal være efter starttidspunkt', failedCreateEvent: 'Kunne ikke oprette begivenhed. Prøv igen.', failedUpdateEvent: 'Kunne ikke opdatere begivenhed. Prøv igen.', failedDeleteEvent: 'Kunne ikke slette begivenhed. Prøv igen.', homeAssistantUnavailable: 'Home Assistant er ikke tilgængelig', googleCalendarEditError: 'Google Kalender understøtter ikke redigering af begivenheder via Home Assistant. Brug Google Kalender-appen eller webstedet.', missingUidError: 'Denne begivenhed mangler nødvendige oplysninger (UID) og kan ikke redigeres.', calendarNoModifyError: 'Denne kalender understøtter ikke ændringer af begivenheder. Prøv at oprette en ny begivenhed i stedet.', createEventServiceError: 'Kunne ikke oprette begivenhed', deleteEventServiceError: 'Kunne ikke slette begivenhed', updateEventServiceError: 'Kunne ikke opdatere begivenhed. Kalenderen understøtter muligvis ikke ændringer.', durationHour: '{count} time', durationHours: '{count} timer', durationMinute: '{count} minut', durationMinutes: '{count} minutter', moreEvents: '+{count} flere', eventTitleWithStartTime: '{title}, {time}', monthWeekPrefix: 'Uge' } }, sv: { locale: 'sv-SE', strings: { defaultTitle: 'Familjekalender', addEvent: 'Ny händelse', today: 'Idag', month: 'Månad', week: 'Vecka', schedule: 'Schema', agenda: 'Agenda', resetAgenda: 'Gå till idag', openDashboard: 'Öppna översikt', calendars: 'Kalendrar', calendar: 'Kalender', eventTitle: 'Händelsetitel', eventTitlePlaceholder: 'Teams-möte', allDayEvent: 'Hela dagen', recurring: 'Återkommande', eventOptions: 'Alternativ', recurringEventOptions: 'Återkommande alternativ', recurrenceFrequency: 'Upprepa', recurrenceEvery: 'Varje', recurrenceIntervalSuffix: 'intervall(er)', recurrenceEndsOn: 'Slutar', recurrenceCount: 'Upprepningar (COUNT)', recurrenceWeekdays: 'Veckodagar', recurrenceNoEndDate: 'Inget slutdatum (valfritt)', recurrenceDaily: 'Dagligen', recurrenceWeekly: 'Veckovis', recurrenceMonthly: 'Månadsvis', recurrenceYearly: 'Årligen', recurrenceNever: 'Aldrig', recurrenceOn: 'På', recurrenceAfter: 'Efter', recurrenceOccurrences: 'Upprepningar', recurrenceSelectWeekday: 'Välj minst en veckodag för återkommande händelser.', start: 'Start', end: 'Slut', startDate: 'Startdatum', endDate: 'Slutdatum', location: 'Plats', locationPlaceholder: 'Konferensrum A', description: 'Beskrivning', descriptionPlaceholder: 'Händelsebeskrivning...', cancel: 'Avbryt', createEvent: 'Skapa händelse', creating: 'Skapar...', editEvent: 'Redigera händelse', saveChanges: 'Spara ändringar', saving: 'Sparar...', delete: 'Ta bort', deleting: 'Tar bort...', deleteEventTitle: 'Ta bort händelse', deleteRecurringEventTitle: 'Ta bort återkommande händelser', deleteEventConfirm: 'Är du säker på att du vill radera "{title}"? Detta går inte att ångra.', deleteRecurringPrompt: '"{title}" är en återkommande händelse. Hur vill du ta bort den?', editRecurringEventTitle: 'Redigera återkommande händelse', editRecurringPrompt: '"{title}" är en återkommande händelse. Hur vill du redigera den?', editThisOccurrence: 'Redigera enbart den här händelsen i serien', editThisOccurrenceAndFuture: 'Redigera den här och alla återkommande händelser i serien', editEntireSeries: 'Redigera alla händelser i serien', deleteThisEventOnly: 'Den här händelsen enbart', deleteThisOccurrence: 'Ta bort enbart den här händelsen', deleteThisAndFutureEvents: 'Den här och framtida händelser', deleteThisOccurrenceAndFuture: 'Ta bort den här och alla framtida händelser i den här serien.', deleteAllEvents: 'Alla händelser', deleteEntireSeries: 'Ta bort alla händelser i serien.', noEvents: 'Inga händelser', allDay: 'Hela dagen', at: 'vid', duration: 'Varaktighet', attendees: 'Deltagare', recurrence: 'Upprepningar', recurringEvent: 'Återkommande händelse', unknownAttendee: 'Okänt', googleCalendarLimitationTitle: 'ℹ️ Begränsning i Google Kalender', googleCalendarLimitationBody: 'Redigering av händelser stöds inte för Google Kalender via Home Assistant. Du kan ta bort händelser, men för att redigera dem måste du använda Google Kalender-appen eller webbplatsen.', cannotModifyTitle: 'ℹ️ Kan inte ändra:', cannotModifyBody: 'Den här händelsen saknar nödvändig information (UID) för redigering eller borttagning. Du kan behöva skapa den på nytt.', untitledEvent: 'Ingen rubrik', noWritableCalendars: 'Inga tillgängliga kalendrar', eventTitleRequired: 'Händelserubrik saknas', startEndDatesRequired: 'Fyll i start- och slutdatum', endDateBeforeStart: 'Slutdatum måste vara efter startdatum', startEndTimesRequired: 'Fyll i start- och sluttider', endTimeBeforeStart: 'Sluttid måste vara efter starttid', failedCreateEvent: 'Misslyckades att skapa händelse. Försök igen.', failedUpdateEvent: 'Misslyckades att uppdatera händelse. Försök igen.', failedDeleteEvent: 'Misslyckades att radera händelse. Försök igen.', homeAssistantUnavailable: 'Home Assistant är inte tillgängligt', googleCalendarEditError: 'Redigering av händelser stöds inte för Google Kalender via Home Assistant. Använd Google Kalender-appen eller webbplatsen i stället.', missingUidError: 'Den här händelsen saknar nödvändig information (UID) och kan inte redigeras', calendarNoModifyError: 'Den här kalendern stöder inte ändringar av händelser. Försök skapa en ny händelse.', createEventServiceError: 'Skapa händelse misslyckades', deleteEventServiceError: 'Ta bort händelse misslyckades', updateEventServiceError: 'Uppdatera händelse misslyckades. Kalendern kanske inte har stöd för ändringar.', durationHour: '{count} timme', durationHours: '{count} timmar', durationMinute: '{count} minut', durationMinutes: '{count} minuter', moreEvents: '+{count} fler', eventTitleWithStartTime: '{title}, {time}', monthWeekPrefix: 'v.' } } }; // ============================================================================ // TRANSLATION HELPER FUNCTIONS // ============================================================================ const DEFAULT_LANGUAGE = 'en'; const normalizeLanguage = (language) => { if (!language) return DEFAULT_LANGUAGE; return language.toLowerCase().split('-')[0]; }; const resolveLanguage = (language) => { const normalized = normalizeLanguage(language); return TRANSLATIONS[normalized] ? normalized : DEFAULT_LANGUAGE; }; const interpolate = (template, params = {}) => template.replace(/\{(\w+)\}/g, (_, key) => (params[key] !== undefined ? params[key] : '')); const translate = (language, key, params = {}) => { const resolved = resolveLanguage(language); const fallbackStrings = TRANSLATIONS[DEFAULT_LANGUAGE]?.strings || {}; const strings = TRANSLATIONS[resolved]?.strings || fallbackStrings; const fallback = fallbackStrings[key] || key; return interpolate(strings[key] || fallback, params); }; // ============================================================================ // MAIN CALENDAR CARD CLASS // ============================================================================ class SkylightCalendarCard extends HTMLElement { static COMMON_NAMED_COLORS = { black: '#000000', white: '#FFFFFF', red: '#FF0000', lime: '#00FF00', green: '#008000', 'lime/green': '#00FF00', limegreen: '#00FF00', blue: '#0000FF', yellow: '#FFFF00', cyan: '#00FFFF', aqua: '#00FFFF', 'cyan/aqua': '#00FFFF', magenta: '#FF00FF', fuchsia: '#FF00FF', 'magenta/fuchsia': '#FF00FF', silver: '#C0C0C0', gray: '#808080', grey: '#808080', maroon: '#800000', olive: '#808000', darkgreen: '#008000', 'dark green': '#008000', 'green dark': '#008000', greendark: '#008000', purple: '#800080', teal: '#008080', navy: '#000080', orange: '#FFA500', pink: '#FFC0CB' }; constructor() { super(); this._root = this; this._config = {}; this._events = []; this._currentDate = new Date(); this._viewMode = 'month'; // 'month', 'week-compact', 'week-standard', or 'agenda' this._weekStart = new Date(); this._fetching = false; this._lastFetch = null; this._loadedEventRange = null; this._calendarDataSignatures = {}; // Track per-calendar data for change detection this._lastUnchangedDataRender = null; // Throttle unchanged-data UI refreshes this._hiddenCalendars = new Set(); // Track which calendars are hidden this._calendarCapabilities = {}; // Track calendar capabilities this._activeLanguage = DEFAULT_LANGUAGE; this._hasCustomTitle = false; this._isDarkMode = false; this._themeMode = 'auto'; this._systemThemeMediaQuery = null; this._handleSystemThemeChange = (event) => { if (this._themeMode !== 'auto') { return; } this._isDarkMode = !!event.matches; this.render(); }; this._weekStandardFixedOffsetHeight = null; this._weekStandardContainerTopInViewport = null; this._monthContainerTopInViewport = null; this._agendaContainerTopInViewport = null; this._agendaStartDate = null; this._agendaEndDate = null; this._agendaVisibleStartDate = null; this._agendaVisibleEndDate = null; this._agendaDaysPerScrollLoad = 7; this._agendaScrollLoadLock = false; this._agendaSuppressScrollHandling = false; this._agendaPendingScrollTop = null; this._swipeStartX = null; this._swipeStartY = null; this._swipeTracking = false; this._swipeStartedOnInteractive = false; this._activeModalBackHandler = null; this._combinedEditTargets = null; this._combinedDeleteTargets = null; this._pendingHeaderSensorRender = false; this._weatherForecastByEntity = new Map(); this._weatherForecastSubscriptionEntityId = null; this._weatherForecastUnsubscribe = null; this._weatherForecastSubscriptionInFlight = null; this._weatherForecastSubscriptionInFlightEntityId = null; this._weatherForecastSubscriptionGeneration = 0; this._weatherForecastRefreshInFlight = false; this._weatherForecastRefreshRetryAtByEntity = new Map(); this._modalVisibilityObserver = null; this._monthMeasureRaf = null; this._monthMeasureRenderRaf = null; this._wrapMeasureRaf1 = null; this._wrapMeasureRaf2 = null; this._monthGridResizeObserver = null; this._headerResizeObserver = null; this._hostResizeObserver = null; this._hostResizeRaf = null; this._observedResizeParent = null; this._lastObservedHostSize = null; this._monthCompactMeasurementDirty = true; this._lastCompactMonthViewportHeight = null; this._handleViewportResize = () => { if (this.isEventManagementDialogOpen()) { return; } if (this._config.compact_height && (this._viewMode === 'week-standard' || this._viewMode === 'agenda')) { this.render(); return; } if (this._viewMode === 'month' && this._config.compact_height && !this.shouldShowAllEventsInMonth()) { this._monthCompactMeasurementDirty = true; this.scheduleMonthCompactTopMeasurement(); return; } this.updateCompactHeaderWrapState(); this.updateCalendarBadgesScrollState(); }; } getRootElementById(id) { return this._root?.querySelector(`#${id}`) || null; } shouldShowAllEventsInMonth() { return !!(this._config?.show_all_events_month || this._config?.show_all_details_month); } shouldRenderMonthEventsAsWeekCompact() { return this._viewMode === 'month' && !!this._config?.show_all_details_month; } getDashboardScopeKey() { const pathnameSegments = (window.location?.pathname || '').split('/').filter(Boolean); if (pathnameSegments.length > 0) { return pathnameSegments[0]; } const hashPath = (window.location?.hash || '').replace(/^#/, ''); const hashSegments = hashPath.split('/').filter(Boolean); if (hashSegments.length > 0) { return hashSegments[0]; } return 'default'; } getPreferenceStorageKey() { const dashboardScope = this.getDashboardScopeKey(); const baseKey = this._config.preference_storage_key || (this._config.entities || []).join('|'); if (!baseKey) { return null; } return `skylight-calendar-card:${dashboardScope}:${baseKey}`; } normalizeDashboardPath(pathValue) { if (typeof pathValue !== 'string') return null; const trimmedPath = pathValue.trim(); if (!trimmedPath) return null; return trimmedPath.startsWith('/') ? trimmedPath : `/${trimmedPath}`; } getConfiguredDashboardPath() { return this.normalizeDashboardPath(this._config?.header_dashboard_path); } shouldShowDashboardNavButton() { return !!(this._config?.show_dashboard_nav_button && this.getConfiguredDashboardPath()); } normalizeEnumValue(value, { aliases = {}, allowed = [], fallback }) { const normalizedValue = String(value ?? '').trim().toLowerCase(); const mappedValue = aliases[normalizedValue] ?? normalizedValue; return allowed.includes(mappedValue) ? mappedValue : fallback; } normalizeDefaultDarkMode(value) { if (value === true) return 'dark'; if (value === false || value === undefined || value === null || value === '') return 'auto'; return this.normalizeEnumValue(value, { allowed: ['auto', 'light', 'dark'], fallback: 'auto' }); } normalizeEventTitlePrefixMode(value) { return this.normalizeEnumValue(value, { aliases: { icon: 'badge_icon', badge: 'badge_icon', badgeicon: 'badge_icon', friendly: 'friendly_name', friendlyname: 'friendly_name' }, allowed: ['friendly_name', 'badge_icon', 'none'], fallback: 'none' }); } normalizePastEventMode(value) { return this.normalizeEnumValue(value, { allowed: ['none', 'hide', 'muted'], fallback: 'none' }); } normalizeEntityStringMap(value) { if (!value || typeof value !== 'object' || Array.isArray(value)) { return {}; } return Object.entries(value).reduce((acc, [key, mappedValue]) => { const normalizedKey = typeof key === 'string' ? key.trim() : ''; const normalizedValue = typeof mappedValue === 'string' ? mappedValue.trim() : ''; if (normalizedKey && normalizedValue) { acc[normalizedKey] = normalizedValue; } return acc; }, {}); } normalizeBooleanStyleValue(value) { if (typeof value === 'boolean') return value; if (typeof value === 'string') { const normalizedValue = value.trim().toLowerCase(); if (normalizedValue === 'true') return true; if (normalizedValue === 'false') return false; } return null; } applyThemeMode(mode = this._themeMode) { this._themeMode = this.normalizeDefaultDarkMode(mode); if (this._themeMode === 'dark') { this._isDarkMode = true; return; } if (this._themeMode === 'light') { this._isDarkMode = false; return; } const mediaQuery = window.matchMedia?.('(prefers-color-scheme: dark)'); this._isDarkMode = !!mediaQuery?.matches; } attachSystemThemeListener() { const mediaQuery = window.matchMedia?.('(prefers-color-scheme: dark)'); if (!mediaQuery || this._systemThemeMediaQuery === mediaQuery) { return; } this.detachSystemThemeListener(); this._systemThemeMediaQuery = mediaQuery; if (typeof mediaQuery.addEventListener === 'function') { mediaQuery.addEventListener('change', this._handleSystemThemeChange); } else if (typeof mediaQuery.addListener === 'function') { mediaQuery.addListener(this._handleSystemThemeChange); } } detachSystemThemeListener() { if (!this._systemThemeMediaQuery) { return; } if (typeof this._systemThemeMediaQuery.removeEventListener === 'function') { this._systemThemeMediaQuery.removeEventListener('change', this._handleSystemThemeChange); } else if (typeof this._systemThemeMediaQuery.removeListener === 'function') { this._systemThemeMediaQuery.removeListener(this._handleSystemThemeChange); } this._systemThemeMediaQuery = null; } getDefaultHiddenCalendarSet() { const knownEntities = new Set(this._config.entities || []); return new Set((this._config.default_hidden_calendars || []).filter((entityId) => knownEntities.has(entityId))); } normalizeDefaultHiddenCalendars(config = {}) { const knownEntities = new Set(Array.isArray(config.entities) ? config.entities : []); const hiddenCalendars = new Set(); if (Array.isArray(config.default_hidden_calendars)) { config.default_hidden_calendars.forEach((entityId) => { if (knownEntities.has(entityId)) hiddenCalendars.add(entityId); }); } const visibilityMap = config.default_calendar_visibility || config.calendar_visibility || {}; if (visibilityMap && typeof visibilityMap === 'object' && !Array.isArray(visibilityMap)) { Object.entries(visibilityMap).forEach(([entityId, value]) => { if (!knownEntities.has(entityId)) return; const normalizedValue = typeof value === 'string' ? value.trim().toLowerCase() : value; if (normalizedValue === false || normalizedValue === 'hide' || normalizedValue === 'hidden' || normalizedValue === 'off') { hiddenCalendars.add(entityId); } else if (normalizedValue === true || normalizedValue === 'show' || normalizedValue === 'shown' || normalizedValue === 'visible' || normalizedValue === 'on') { hiddenCalendars.delete(entityId); } }); } return Array.from(hiddenCalendars); } loadPersistedPreferences() { const storageKey = this.getPreferenceStorageKey(); if (!storageKey) return false; try { const raw = window.localStorage?.getItem(storageKey); if (!raw) return false; const parsed = JSON.parse(raw); if (Array.isArray(parsed.hiddenCalendars)) { const knownEntities = new Set(this._config.entities || []); this._hiddenCalendars = new Set(parsed.hiddenCalendars.filter((entityId) => knownEntities.has(entityId))); return true; } } catch (error) { console.warn('Failed to load persisted calendar preferences:', error); } return false; } persistPreferences() { const storageKey = this.getPreferenceStorageKey(); if (!storageKey) return; try { const payload = { hiddenCalendars: Array.from(this._hiddenCalendars) }; window.localStorage?.setItem(storageKey, JSON.stringify(payload)); } catch (error) { console.warn('Failed to persist calendar preferences:', error); } } updateCompactHeaderWrapState() { if (!this._root) return; if (typeof window.requestAnimationFrame !== 'function') { this.measureAndApplyHeaderWrapState(); return; } if (this._wrapMeasureRaf1 !== null) window.cancelAnimationFrame(this._wrapMeasureRaf1); if (this._wrapMeasureRaf2 !== null) window.cancelAnimationFrame(this._wrapMeasureRaf2); this._wrapMeasureRaf1 = window.requestAnimationFrame(() => { this._wrapMeasureRaf1 = null; this._wrapMeasureRaf2 = window.requestAnimationFrame(() => { this._wrapMeasureRaf2 = null; this.measureAndApplyHeaderWrapState(); }); }); } shouldMarkWrappedFromChildren(children) { const visibleChildren = children.filter((child) => child.offsetParent !== null); if (visibleChildren.length <= 1) return false; const firstTop = visibleChildren[0].offsetTop; return visibleChildren.some((child) => Math.abs(child.offsetTop - firstTop) > 1); } getElementContentWidth(element) { if (!element) return 0; const computed = window.getComputedStyle(element); const paddingLeft = parseFloat(computed.paddingLeft) || 0; const paddingRight = parseFloat(computed.paddingRight) || 0; return Math.max(0, element.clientWidth - paddingLeft - paddingRight); } getOuterWidth(element) { if (!element) return 0; const rect = element.getBoundingClientRect(); const computed = window.getComputedStyle(element); const marginLeft = parseFloat(computed.marginLeft) || 0; const marginRight = parseFloat(computed.marginRight) || 0; return rect.width + marginLeft + marginRight; } measureNaturalGroupWidth(group) { if (!group) return 0; const computed = window.getComputedStyle(group); const children = Array.from(group.children || []) .filter((child) => child.offsetParent !== null); if (!children.length) { return Math.ceil(group.scrollWidth || 0); } const childWidths = children.reduce((sum, child) => { return sum + this.getOuterWidth(child); }, 0); const internalGap = parseFloat(computed.columnGap) || parseFloat(computed.gap) || 0; return Math.ceil(childWidths + internalGap * Math.max(children.length - 1, 0)); } shouldMarkHeaderWrappedFromWidth(header, leftGroup, controlsGroup) { if (!header || !leftGroup || !controlsGroup) return false; const computedStyle = window.getComputedStyle(header); const gap = parseFloat(computedStyle.columnGap) || parseFloat(computedStyle.gap) || 0; const requiredWidth = this.measureNaturalGroupWidth(leftGroup) + this.measureNaturalGroupWidth(controlsGroup) + gap; const availableWidth = this.getElementContentWidth(header); const tolerance = 2; return requiredWidth > (availableWidth + tolerance); } shouldMarkGroupWrappedFromWidth(group) { if (!group) return false; const requiredWidth = this.measureNaturalGroupWidth(group); const availableWidth = this.getElementContentWidth(group); const tolerance = 2; return requiredWidth > (availableWidth + tolerance); } measureAndApplyHeaderWrapState() { if (!this._root) return; const headerSelector = this._config.compact_header ? '.header-compact' : '.header'; const controlsSelector = this._config.compact_header ? '.compact-header-controls' : '.header-controls'; const header = this._root.querySelector(headerSelector); const controls = this._root.querySelector(controlsSelector); const compactControls = this._root.querySelector('.compact-header-controls'); const standardControls = this._root.querySelector('.header-controls'); const badges = this._root.querySelector('.calendar-badges-inline'); header?.classList.remove('is-wrapped'); standardControls?.classList.remove('is-wrapped'); compactControls?.classList.remove('is-wrapped'); badges?.classList.remove('is-wrapped'); if (header) { const leftGroup = this._config.compact_header ? header.querySelector('.compact-header-left') : header.querySelector('.header-left'); const controlsGroup = this._config.compact_header ? header.querySelector('.compact-header-controls') : header.querySelector('.header-controls'); header.classList.toggle('is-wrapped', this.shouldMarkHeaderWrappedFromWidth(header, leftGroup, controlsGroup)); } if (controls) { controls.classList.toggle('is-wrapped', this.shouldMarkGroupWrappedFromWidth(controls)); } if (this._config.compact_header && badges) { badges.classList.toggle('is-wrapped', this.shouldMarkWrappedFromChildren(Array.from(badges.children))); } } updateCalendarBadgesScrollState() { if (!this._root || this._config.compact_header) return; const badgesContainer = this._root.querySelector('.calendar-badges-container'); const badges = this._root.querySelector('.calendar-badges'); if (!badgesContainer || !badges) return; const maxScrollLeft = badges.scrollWidth - badges.clientWidth; const hasOverflow = maxScrollLeft > 1; const showLeftIndicator = hasOverflow && badges.scrollLeft > 1; const showRightIndicator = hasOverflow && badges.scrollLeft < (maxScrollLeft - 1); badgesContainer.classList.toggle('has-overflow', hasOverflow); badgesContainer.classList.toggle('show-left-indicator', showLeftIndicator); badgesContainer.classList.toggle('show-right-indicator', showRightIndicator); } isEventManagementDialogOpen() { const modal = this.getRootElementById('event-modal'); return !!modal && modal.classList.contains('show'); } getConfigNormalizationSchema() { return [ { key: 'title', defaultValue: ({ rawConfig, language }) => this._hasCustomTitle ? rawConfig.title : translate(language, 'defaultTitle') }, { key: 'entities', defaultValue: ({ rawConfig }) => rawConfig.entities }, { key: 'firstDayOfWeek', defaultValue: ({ rawConfig }) => rawConfig.first_day_of_week || 0 }, { key: 'colors', defaultValue: ({ derived }) => derived.normalizedCalendarColors }, { key: 'calendar_names', defaultValue: ({ rawConfig }) => rawConfig.calendar_names || {} }, { key: 'calendar_badge_icons', defaultValue: ({ rawConfig }) => rawConfig.calendar_badge_icons || {} }, { key: 'calendar_person_entities', defaultValue: ({ derived }) => derived.normalizedCalendarPersonEntities, normalize: ({ derived }) => derived.normalizedCalendarPersonEntities }, { key: 'max_events', defaultValue: ({ rawConfig }) => rawConfig.max_events }, { key: 'default_view', defaultValue: ({ derived }) => derived.normalizedDefaultView || 'month', normalize: ({ derived }) => derived.normalizedDefaultView || 'month' }, { key: 'week_days', defaultValue: ({ rawConfig }) => rawConfig.week_days || [0, 1, 2, 3, 4, 5, 6] }, { key: 'rolling_days_week_compact', defaultValue: ({ rawConfig }) => rawConfig.rolling_days_week_compact ?? null }, { key: 'rolling_days_schedule', defaultValue: ({ rawConfig }) => rawConfig.rolling_days_schedule ?? null }, { key: 'rolling_days_agenda', defaultValue: ({ rawConfig }) => rawConfig.rolling_days_agenda ?? null, normalize: ({ rawConfig }) => rawConfig.rolling_days_agenda ?? null }, { key: 'rolling_weeks', defaultValue: ({ rawConfig }) => rawConfig.rolling_weeks || null }, { key: 'show_week_numbers_month', defaultValue: ({ rawConfig }) => rawConfig.show_week_numbers_month || false }, { key: 'show_all_events_month', defaultValue: ({ rawConfig }) => rawConfig.show_all_events_month || false }, { key: 'show_all_details_month', defaultValue: ({ rawConfig }) => rawConfig.show_all_details_month || false }, { key: 'hide_the_past', defaultValue: ({ rawConfig }) => rawConfig.hide_the_past || false, normalize: ({ rawConfig }) => rawConfig.hide_the_past || false }, { key: 'past_event_mode', defaultValue: ({ derived }) => derived.normalizedPastEventMode, normalize: ({ derived }) => derived.normalizedPastEventMode }, { key: 'hide_empty_days', defaultValue: ({ rawConfig }) => rawConfig.hide_empty_days || false }, { key: 'agenda_compact_events', defaultValue: ({ rawConfig }) => rawConfig.agenda_compact_events ?? false, normalize: ({ rawConfig }) => rawConfig.agenda_compact_events ?? false }, { key: 'display_full_weekday_names', defaultValue: ({ rawConfig }) => rawConfig.display_full_weekday_names ?? false }, { key: 'shorten_event_times', defaultValue: ({ rawConfig }) => rawConfig.shorten_event_times ?? false }, { key: 'disable_swipe_controls', defaultValue: ({ rawConfig }) => rawConfig.disable_swipe_controls ?? false }, { key: 'week_start_hour', defaultValue: ({ derived }) => derived.normalizedWeekStartHour }, { key: 'week_end_hour', defaultValue: ({ derived }) => derived.normalizedWeekEndHour }, { key: 'lock_schedule_hours', defaultValue: ({ rawConfig }) => rawConfig.lock_schedule_hours ?? false }, { key: 'compact_height', defaultValue: ({ rawConfig }) => rawConfig.compact_height || false }, { key: 'compact_width', defaultValue: ({ rawConfig }) => rawConfig.compact_width || false }, { key: 'height_scale', defaultValue: ({ rawConfig }) => rawConfig.height_scale || 1.0 }, { key: 'compact_header', defaultValue: ({ rawConfig }) => rawConfig.compact_header || false }, { key: 'hide_year', defaultValue: ({ rawConfig }) => rawConfig.hide_year || false }, { key: 'hide_calendars', defaultValue: ({ rawConfig }) => rawConfig.hide_calendars || false }, { key: 'hide_header', defaultValue: ({ rawConfig }) => rawConfig.hide_header || false }, { key: 'hide_calendar_names', defaultValue: ({ rawConfig }) => rawConfig.hide_calendar_names || false }, { key: 'hide_controls', defaultValue: ({ rawConfig }) => rawConfig.hide_controls || false }, { key: 'hide_navigation_buttons', defaultValue: ({ rawConfig }) => rawConfig.hide_navigation_buttons || false }, { key: 'hide_add_event_button', defaultValue: ({ rawConfig }) => rawConfig.hide_add_event_button || false }, { key: 'hide_view_selector', defaultValue: ({ rawConfig }) => rawConfig.hide_view_selector || false }, { key: 'hide_dark_mode_toggle', defaultValue: ({ rawConfig }) => rawConfig.hide_dark_mode_toggle || false }, { key: 'show_dashboard_nav_button', defaultValue: ({ rawConfig }) => rawConfig.show_dashboard_nav_button || false }, { key: 'header_dashboard_path', defaultValue: ({ rawConfig }) => this.normalizeDashboardPath(rawConfig.header_dashboard_path), normalize: ({ rawConfig }) => this.normalizeDashboardPath(rawConfig.header_dashboard_path) }, { key: 'header_time_sensor', defaultValue: ({ derived }) => derived.normalizedHeaderTimeSensor, normalize: ({ derived }) => derived.normalizedHeaderTimeSensor }, { key: 'header_weather_sensor', defaultValue: ({ derived }) => derived.normalizedHeaderWeatherSensor, normalize: ({ derived }) => derived.normalizedHeaderWeatherSensor }, { key: 'hide_event_calendar_bubble', defaultValue: ({ rawConfig }) => rawConfig.hide_event_calendar_bubble || false }, { key: 'show_event_location', defaultValue: ({ rawConfig }) => rawConfig.show_event_location || false }, { key: 'use_short_location', defaultValue: ({ rawConfig }) => rawConfig.use_short_location || false }, { key: 'event_font_size', defaultValue: ({ rawConfig }) => rawConfig.event_font_size ?? 11 }, { key: 'event_time_font_size', defaultValue: ({ rawConfig }) => rawConfig.event_time_font_size ?? 9 }, { key: 'event_location_font_size', defaultValue: ({ rawConfig }) => rawConfig.event_location_font_size ?? 9 }, { key: 'event_calendar_friendly_name', defaultValue: ({ rawConfig }) => rawConfig.event_calendar_friendly_name || false }, { key: 'event_title_prefix', defaultValue: ({ derived }) => derived.normalizedEventTitlePrefix, normalize: ({ derived }) => derived.normalizedEventTitlePrefix }, { key: 'event_font_colors', defaultValue: ({ derived }) => derived.normalizedEventFontColors }, { key: 'event_styles', defaultValue: ({ derived }) => derived.normalizedEventStyles, normalize: ({ derived }) => derived.normalizedEventStyles }, { key: 'day_styles', defaultValue: ({ derived }) => derived.normalizedDayStyles, normalize: ({ derived }) => derived.normalizedDayStyles }, { key: 'day_badges', defaultValue: ({ derived }) => derived.normalizedDayBadges, normalize: ({ derived }) => derived.normalizedDayBadges }, { key: 'hide_times_for_calendars', defaultValue: ({ rawConfig }) => rawConfig.hide_times_for_calendars || [] }, { key: 'show_current_time_bar', defaultValue: ({ rawConfig }) => rawConfig.show_current_time_bar || false }, { key: 'header_color', defaultValue: ({ derived }) => derived.normalizedHeaderColor !== undefined ? derived.normalizedHeaderColor : 'var(--primary-color)' }, { key: 'header_text_color', defaultValue: ({ derived }) => derived.normalizedHeaderTextColor }, { key: 'header_background_transparent', defaultValue: ({ derived }) => derived.normalizedHeaderBackgroundOpacity >= 100, normalize: ({ derived }) => derived.normalizedHeaderBackgroundOpacity >= 100 }, { key: 'header_background_opacity', defaultValue: ({ derived }) => derived.normalizedHeaderBackgroundOpacity, normalize: ({ derived }) => derived.normalizedHeaderBackgroundOpacity }, { key: 'background_transparent', defaultValue: ({ derived }) => derived.normalizedBackgroundOpacity >= 100, normalize: ({ derived }) => derived.normalizedBackgroundOpacity >= 100 }, { key: 'background_opacity', defaultValue: ({ derived }) => derived.normalizedBackgroundOpacity, normalize: ({ derived }) => derived.normalizedBackgroundOpacity }, { key: 'background_image_url', defaultValue: ({ rawConfig }) => rawConfig.background_image_url || null }, { key: 'background_image_size', defaultValue: ({ rawConfig }) => rawConfig.background_image_size || 'cover' }, { key: 'background_image_position', defaultValue: ({ rawConfig }) => rawConfig.background_image_position || 'center' }, { key: 'background_image_repeat', defaultValue: ({ rawConfig }) => rawConfig.background_image_repeat || 'no-repeat' }, { key: 'combine_calendars', defaultValue: ({ rawConfig }) => rawConfig.combine_calendars ?? false }, { key: 'combine_style', defaultValue: ({ rawConfig }) => this.normalizeCombineStyle(rawConfig.combine_style ?? 'bars') }, { key: 'combine_background', defaultValue: ({ rawConfig }) => this.normalizeCombineBackground(rawConfig.combine_background ?? 'primary') }, { key: 'combine_calendars_width', defaultValue: ({ derived }) => derived.normalizedCombineWidth, normalize: ({ derived }) => derived.normalizedCombineWidth }, { key: 'event_color_bar_width', defaultValue: ({ derived }) => derived.normalizedEventBarWidth, normalize: ({ derived }) => derived.normalizedEventBarWidth }, { key: 'event_color_mode', defaultValue: ({ rawConfig }) => this.normalizeEventColorMode(rawConfig.event_color_mode ?? 'classic'), normalize: ({ rawConfig }) => this.normalizeEventColorMode(rawConfig.event_color_mode ?? 'classic') }, { key: 'event_neutral_background', defaultValue: ({ rawConfig }) => this.normalizeSingleColor(rawConfig.event_neutral_background) || '#F8F3E9', normalize: ({ rawConfig }) => this.normalizeSingleColor(rawConfig.event_neutral_background) || '#F8F3E9' }, { key: 'event_tint_opacity', defaultValue: ({ rawConfig }) => this.normalizeBackgroundOpacity(rawConfig.event_tint_opacity, 80), normalize: ({ rawConfig }) => this.normalizeBackgroundOpacity(rawConfig.event_tint_opacity, 80) }, { key: 'enable_event_management', defaultValue: ({ rawConfig }) => rawConfig.enable_event_management !== false }, { key: 'event_modal_size', defaultValue: ({ rawConfig }) => this.normalizeEventModalSize(rawConfig.event_modal_size), normalize: ({ rawConfig }) => this.normalizeEventModalSize(rawConfig.event_modal_size) }, { key: 'readonly_calendars', defaultValue: ({ rawConfig }) => rawConfig.readonly_calendars || [] }, { key: 'hide_badge_calendars', defaultValue: ({ rawConfig }) => rawConfig.hide_badge_calendars || [] }, { key: 'default_hidden_calendars', defaultValue: ({ derived }) => derived.normalizedDefaultHiddenCalendars, normalize: ({ derived }) => derived.normalizedDefaultHiddenCalendars }, { key: 'virtual_calendars', defaultValue: ({ rawConfig }) => this.normalizeVirtualCalendars(rawConfig.virtual_calendars || []) }, { key: 'language', defaultValue: ({ rawConfig }) => rawConfig.language || null }, { key: 'locale', defaultValue: ({ rawConfig }) => rawConfig.locale || null }, { key: 'color_scheme', defaultValue: ({ rawConfig }) => this.normalizeDefaultDarkMode(rawConfig.color_scheme), normalize: ({ rawConfig }) => this.normalizeDefaultDarkMode(rawConfig.color_scheme) }, { key: 'preference_storage_key', defaultValue: ({ rawConfig }) => rawConfig.preference_storage_key || null } ]; } getConfigNormalizationContext(rawConfig, language) { const normalizedDefaultView = rawConfig.default_view === 'week' ? 'week-compact' : rawConfig.default_view === 'schedule' ? 'week-standard' : rawConfig.default_view; const hasConfiguredHeaderBackgroundOpacity = rawConfig.header_background_opacity !== undefined && rawConfig.header_background_opacity !== null && rawConfig.header_background_opacity !== ''; const normalizedHeaderBackgroundOpacity = hasConfiguredHeaderBackgroundOpacity ? this.normalizeBackgroundOpacity(rawConfig.header_background_opacity, 0) : (rawConfig.header_background_transparent ? 100 : 0); const hasConfiguredBackgroundOpacity = rawConfig.background_opacity !== undefined && rawConfig.background_opacity !== null && rawConfig.background_opacity !== ''; const normalizedBackgroundOpacity = hasConfiguredBackgroundOpacity ? this.normalizeBackgroundOpacity(rawConfig.background_opacity, 0) : (rawConfig.background_transparent ? 100 : 0); const configuredWeekStartHour = Number(rawConfig.week_start_hour); const normalizedWeekStartHour = Number.isFinite(configuredWeekStartHour) ? Math.min(23, Math.max(0, configuredWeekStartHour)) : 0; const configuredWeekEndHour = Number(rawConfig.week_end_hour); const normalizedWeekEndHour = Number.isFinite(configuredWeekEndHour) ? Math.min(23, Math.max(0, configuredWeekEndHour)) : 23; const rawCombineWidth = Number(rawConfig.combine_calendars_width); const rawEventBarWidth = Number(rawConfig.event_color_bar_width); const hasCombineWidth = Number.isFinite(rawCombineWidth) && rawCombineWidth > 0; const hasEventBarWidth = Number.isFinite(rawEventBarWidth) && rawEventBarWidth > 0; const normalizedCombineWidth = hasCombineWidth ? rawCombineWidth : (hasEventBarWidth ? rawEventBarWidth : 18); return { normalizedDefaultView, normalizedCalendarColors: this.normalizeColorMap(rawConfig.colors || {}), normalizedEventFontColors: this.normalizeColorMap(rawConfig.event_font_colors || {}), normalizedEventStyles: this.normalizeEventStyles(rawConfig.event_styles || []), normalizedDayStyles: this.normalizeDayStyles( this.buildDayStyleRules(rawConfig), resolveLanguage(rawConfig.locale || rawConfig.language || this._hass?.locale?.language || this._hass?.language) ), normalizedDayBadges: this.normalizeDayBadges(rawConfig.day_badges || []), normalizedHeaderColor: this.normalizeSingleColor(rawConfig.header_color), normalizedHeaderTextColor: this.normalizeSingleColor(rawConfig.header_text_color), normalizedHeaderBackgroundOpacity, normalizedBackgroundOpacity, normalizedWeekStartHour, normalizedWeekEndHour, normalizedEventTitlePrefix: this.normalizeEventTitlePrefixMode(rawConfig.event_title_prefix), normalizedPastEventMode: rawConfig.past_event_mode !== undefined && rawConfig.past_event_mode !== null && rawConfig.past_event_mode !== '' ? this.normalizePastEventMode(rawConfig.past_event_mode) : (rawConfig.hide_the_past ? 'hide' : 'none'), normalizedCombineWidth, normalizedEventBarWidth: hasEventBarWidth ? rawEventBarWidth : normalizedCombineWidth, normalizedCalendarPersonEntities: this.normalizeEntityStringMap(rawConfig.calendar_person_entities || {}), normalizedDefaultHiddenCalendars: this.normalizeDefaultHiddenCalendars(rawConfig), normalizedHeaderTimeSensor: typeof rawConfig.header_time_sensor === 'string' && rawConfig.header_time_sensor.trim() ? rawConfig.header_time_sensor.trim() : null, normalizedHeaderWeatherSensor: typeof rawConfig.header_weather_sensor === 'string' && rawConfig.header_weather_sensor.trim() ? rawConfig.header_weather_sensor.trim() : null, language }; } normalizeConfig(rawConfig, language = resolveLanguage(rawConfig.language || this._hass?.language || this._hass?.locale?.language)) { const derived = this.getConfigNormalizationContext(rawConfig, language); const schemaContext = { rawConfig, language, derived }; const schema = this.getConfigNormalizationSchema(); const defaults = schema.reduce((acc, field) => { acc[field.key] = field.defaultValue(schemaContext); return acc; }, {}); const normalizedOverrides = schema.reduce((acc, field) => { if (field.normalize) { acc[field.key] = field.normalize(schemaContext); } return acc; }, {}); const normalizedConfig = { ...defaults, ...rawConfig, ...normalizedOverrides }; if (!Object.prototype.hasOwnProperty.call(rawConfig, 'use_24hr_schedule')) { delete normalizedConfig.use_24hr_schedule; // Preserve locale-based hour cycle defaults when unset } return normalizedConfig; } setConfig(config) { const previousHeaderWeatherSensor = this._config?.header_weather_sensor || null; if (!config.entities || !Array.isArray(config.entities)) { throw new Error('You need to define calendar entities'); } const language = resolveLanguage(config.language || this._hass?.language || this._hass?.locale?.language); this._hasCustomTitle = config.title !== undefined && config.title !== null; this._config = this.normalizeConfig(config, language); this._viewMode = this._config.default_view; this.applyThemeMode(this._config.color_scheme); this._hiddenCalendars = this.getDefaultHiddenCalendarSet(); this.loadPersistedPreferences(); this._loadedEventRange = null; this._calendarDataSignatures = {}; this._lastUnchangedDataRender = null; if (previousHeaderWeatherSensor !== this._config.header_weather_sensor) { this.teardownWeatherForecastSubscription(); this._weatherForecastByEntity.clear(); this._weatherForecastRefreshRetryAtByEntity.clear(); } this.ensureWeatherForecastSubscription(); this.setWeekStart(); this.resetAgendaWindowToToday(); this.render(); this._activeLanguage = language; } set hass(hass) { const oldHass = this._hass; this._hass = hass; let shouldRender = false; // Check calendar capabilities when hass is set if (!oldHass || this._hass !== oldHass) { this.checkAllCalendarCapabilities(); } if (this._themeMode === 'auto') { const hassDarkMode = this._hass?.themes?.darkMode; if (typeof hassDarkMode === 'boolean' && this._isDarkMode !== hassDarkMode) { this._isDarkMode = hassDarkMode; shouldRender = true; } } const resolvedLanguage = this.getLanguage(); if (resolvedLanguage !== this._activeLanguage) { this._activeLanguage = resolvedLanguage; if (!this._hasCustomTitle) { this._config.title = translate(this._activeLanguage, 'defaultTitle'); } shouldRender = true; } const configuredHeaderTimeSensor = this._config?.header_time_sensor; const configuredHeaderWeatherSensor = this._config?.header_weather_sensor; const previousHeaderTimeSensorState = configuredHeaderTimeSensor ? this.getHeaderEntityRenderSignature(oldHass?.states?.[configuredHeaderTimeSensor]) : null; const nextHeaderTimeSensorState = configuredHeaderTimeSensor ? this.getHeaderEntityRenderSignature(hass?.states?.[configuredHeaderTimeSensor]) : null; const previousHeaderWeatherSensorState = configuredHeaderWeatherSensor ? this.getHeaderEntityRenderSignature(oldHass?.states?.[configuredHeaderWeatherSensor]) : null; const nextHeaderWeatherSensorState = configuredHeaderWeatherSensor ? this.getHeaderEntityRenderSignature(hass?.states?.[configuredHeaderWeatherSensor]) : null; const headerSensorChanged = previousHeaderTimeSensorState !== nextHeaderTimeSensorState || previousHeaderWeatherSensorState !== nextHeaderWeatherSensorState; const badgePersonStateChanged = this.getCalendarBadgePersonRenderSignature(oldHass) !== this.getCalendarBadgePersonRenderSignature(hass); if (headerSensorChanged || badgePersonStateChanged) { if (this.isEventManagementDialogOpen()) { this._pendingHeaderSensorRender = true; } else { shouldRender = true; this._pendingHeaderSensorRender = false; } } this.ensureWeatherForecastSubscription(); this.refreshWeatherForecastData(); if (shouldRender) { this.renderPreservingAgendaScroll(); } // Refresh only when stale or when current view needs dates outside loaded range. if (!oldHass) { this.ensureEventsForCurrentRange({ force: true }); } else { this.ensureEventsForCurrentRange(); } } async checkAllCalendarCapabilities() { if (!this._hass) return; for (const entityId of this._config.entities) { const entity = this._hass.states[entityId]; if (entity) { const features = entity.attributes?.supported_features || 0; // Check if this is a Google Calendar (which doesn't support UPDATE/DELETE services) const isGoogleCalendar = entityId.includes('google') || entity.attributes?.integration === 'google'; this._calendarCapabilities[entityId] = { canCreate: true, // Most calendars support creation canUpdate: (features & 2) !== 0, // UPDATE_EVENT = 2 canDelete: (features & 4) !== 0, // DELETE_EVENT = 4 isReadonly: this._config.readonly_calendars.includes(entityId), isGoogleCalendar: isGoogleCalendar // Track Google Calendar separately }; } } } normalizeColorMap(colorMap) { if (!colorMap || typeof colorMap !== 'object') return {}; return Object.entries(colorMap).reduce((acc, [entityId, color]) => { const normalized = this.normalizeSingleColor(color); if (normalized !== undefined && normalized !== null && normalized !== '') { acc[entityId] = normalized; } return acc; }, {}); } normalizeSingleColor(colorValue) { if (colorValue === undefined || colorValue === null) { return colorValue; } const trimmed = String(colorValue).trim(); if (!trimmed) return trimmed; const normalizedName = trimmed .toLowerCase() .replace(/[()]/g, '') .replace(/\s*\/\s*/g, '/') .replace(/\s+/g, ' ') .trim(); const mappedColor = SkylightCalendarCard.COMMON_NAMED_COLORS[normalizedName]; if (mappedColor) { return mappedColor; } return trimmed; } colorToHex(color) { if (!color) return null; const normalizedColor = this.normalizeSingleColor(color); if (typeof normalizedColor !== 'string') return null; const hex3Match = normalizedColor.match(/^#([\da-fA-F]{3})$/); if (hex3Match) { const [r, g, b] = hex3Match[1].split(''); return `#${r}${r}${g}${g}${b}${b}`.toUpperCase(); } const hex6Match = normalizedColor.match(/^#([\da-fA-F]{6})$/); if (hex6Match) { return `#${hex6Match[1].toUpperCase()}`; } return null; } colorToRgb(color) { const normalizedColor = this.normalizeSingleColor(color); if (typeof normalizedColor === 'string') { const rgbMatch = normalizedColor .match(/^rgba?\((.+)\)$/i); if (rgbMatch) { const normalizedChannels = rgbMatch[1] .replace(/\s*\/\s*.*/, '') .replace(/,/g, ' ') .trim() .split(/\s+/) .slice(0, 3) .map((channel) => Number(channel)); if (normalizedChannels.length === 3 && normalizedChannels.every((value) => Number.isFinite(value))) { const [r, g, b] = normalizedChannels.map((value) => Math.max(0, Math.min(255, Math.round(value)))); return { r, g, b }; } } } const hex = this.colorToHex(normalizedColor); if (hex) { return { r: parseInt(hex.slice(1, 3), 16), g: parseInt(hex.slice(3, 5), 16), b: parseInt(hex.slice(5, 7), 16) }; } return this.resolveComputedCssColorToRgb(normalizedColor); } resolveComputedCssColorToRgb(color) { if (typeof color !== 'string' || typeof window === 'undefined' || typeof document === 'undefined') { return null; } const probe = document.createElement('span'); probe.style.color = color; probe.style.position = 'absolute'; probe.style.pointerEvents = 'none'; probe.style.opacity = '0'; const parent = this.isConnected ? this : document.body; if (!parent) return null; parent.appendChild(probe); const computed = window.getComputedStyle(probe).color; probe.remove(); const match = computed.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)/i); if (!match) return null; return { r: Number(match[1]), g: Number(match[2]), b: Number(match[3]) }; } colorWithAlpha(color, alpha = 1) { const rgb = this.colorToRgb(color); if (!rgb) return color; const clamped = Math.max(0, Math.min(1, alpha)); return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${clamped})`; } blendRgb(top, bottom, topAlpha = 1) { if (!top && !bottom) return null; if (!top) return bottom; if (!bottom) return top; const clampedAlpha = Math.max(0, Math.min(1, topAlpha)); return { r: Math.round((top.r * clampedAlpha) + (bottom.r * (1 - clampedAlpha))), g: Math.round((top.g * clampedAlpha) + (bottom.g * (1 - clampedAlpha))), b: Math.round((top.b * clampedAlpha) + (bottom.b * (1 - clampedAlpha))) }; } normalizeCombineStyle(styleValue) { return this.normalizeEnumValue(styleValue, { allowed: ['stripes', 'bars', 'dots'], fallback: 'bars' }); } normalizeEventColorMode(modeValue) { return this.normalizeEnumValue(modeValue, { allowed: ['classic', 'left-neutral', 'left-tint'], fallback: 'classic' }); } normalizeCombineBackground(backgroundValue) { const normalized = String(backgroundValue || '').trim(); if (!normalized) return 'primary'; const lower = normalized.toLowerCase(); if (lower === 'neutral' || lower === 'primary') { return lower; } const hex = this.colorToHex(normalized); return hex || 'primary'; } getEmptyAdvancedMatch() { return { event: {}, day: {}, any: [], all: [], not: null }; } normalizeEventMatchConditions(rawMatch) { if (!rawMatch || typeof rawMatch !== 'object' || Array.isArray(rawMatch)) return null; const normalized = {}; const logicalKeys = new Set(['all', 'and', 'any', 'not']); const eventAliases = { title_contains: 'title', summary_contains: 'summary', location_contains: 'location', description_contains: 'description' }; const calendarAliases = new Set(['calendar_entity', 'entity_id', 'entity']); Object.entries(rawMatch).forEach(([key, value]) => { const normalizedKey = String(key || '').trim().toLowerCase(); if (!normalizedKey) return; if (logicalKeys.has(normalizedKey)) { if (normalizedKey === 'all' || normalizedKey === 'and') { const conditions = Array.isArray(value) ? value : [value]; const normalizedConditions = conditions .map((condition) => this.normalizeEventMatchConditions(condition)) .filter(Boolean); if (normalizedConditions.length) { if (!Array.isArray(normalized.all)) normalized.all = []; normalized.all.push(...normalizedConditions); } return; } if (normalizedKey === 'any') { const conditions = Array.isArray(value) ? value : [value]; const normalizedConditions = conditions .map((condition) => this.normalizeEventMatchConditions(condition)) .filter(Boolean); if (normalizedConditions.length) normalized.any = normalizedConditions; return; } if (normalizedKey === 'not') { if (Array.isArray(value)) { const normalizedConditions = value .map((condition) => this.normalizeEventMatchConditions(condition)) .filter(Boolean); if (normalizedConditions.length) normalized.not = normalizedConditions; } else { const normalizedCondition = this.normalizeEventMatchConditions(value); if (normalizedCondition) normalized.not = normalizedCondition; } return; } } if (eventAliases[normalizedKey]) { const canonicalKey = eventAliases[normalizedKey]; if (normalized[canonicalKey] === undefined) normalized[canonicalKey] = `contains:${value}`; return; } if (calendarAliases.has(normalizedKey)) { if (normalized.calendar === undefined) normalized.calendar = value; return; } if (normalizedKey === 'all_day_event') { if (normalized.all_day === undefined) normalized.all_day = value; return; } if (['title', 'summary', 'location', 'description', 'calendar', 'all_day', 'past'].includes(normalizedKey)) { normalized[normalizedKey] = value; } }); return Object.keys(normalized).length ? normalized : null; } normalizeDayMatchConditions(rawMatch, localeOverride = null) { if (!rawMatch || typeof rawMatch !== 'object' || Array.isArray(rawMatch)) return null; const normalized = {}; const logicalKeys = new Set(['all', 'and', 'any', 'not']); Object.entries(rawMatch).forEach(([key, value]) => { const normalizedKey = String(key || '').trim().toLowerCase(); if (!normalizedKey) return; if (logicalKeys.has(normalizedKey)) { if (normalizedKey === 'all' || normalizedKey === 'and') { const conditions = Array.isArray(value) ? value : [value]; const normalizedConditions = conditions .map((condition) => this.normalizeDayMatchConditions(condition, localeOverride)) .filter(Boolean); if (normalizedConditions.length) { if (!Array.isArray(normalized.all)) normalized.all = []; normalized.all.push(...normalizedConditions); } return; } if (normalizedKey === 'any') { const conditions = Array.isArray(value) ? value : [value]; const normalizedConditions = conditions .map((condition) => this.normalizeDayMatchConditions(condition, localeOverride)) .filter(Boolean); if (normalizedConditions.length) normalized.any = normalizedConditions; return; } if (normalizedKey === 'not') { if (Array.isArray(value)) { const normalizedConditions = value .map((condition) => this.normalizeDayMatchConditions(condition, localeOverride)) .filter(Boolean); if (normalizedConditions.length) normalized.not = normalizedConditions; } else { const normalizedCondition = this.normalizeDayMatchConditions(value, localeOverride); if (normalizedCondition) normalized.not = normalizedCondition; } return; } } if (['today', 'past', 'future', 'weekend', 'weekday'].includes(normalizedKey)) { normalized[normalizedKey] = value; return; } if (normalizedKey === 'day_of_week') { const dayOfWeek = this.normalizeDayOfWeekRule(value, localeOverride); if (dayOfWeek.length) normalized.day_of_week = dayOfWeek; return; } if (normalizedKey === 'has_event' || normalizedKey === 'no_event') { if (value === true || value === false) { normalized[normalizedKey] = value; } else { const eventMatch = this.normalizeEventMatchConditions(value); if (eventMatch) normalized[normalizedKey] = eventMatch; } } }); return Object.keys(normalized).length ? normalized : null; } normalizeAdvancedRuleMatch(rawMatch, defaultScope = 'event', localeOverride = null) { if (!rawMatch || typeof rawMatch !== 'object' || Array.isArray(rawMatch)) return null; const match = this.getEmptyAdvancedMatch(); const logicalKeys = new Set(['all', 'and', 'any', 'not']); const explicitKeys = new Set(['event', 'day', ...logicalKeys]); let hasMatch = false; if (rawMatch.event && typeof rawMatch.event === 'object' && !Array.isArray(rawMatch.event)) { const eventMatch = this.normalizeEventMatchConditions(rawMatch.event); if (eventMatch) { match.event = eventMatch; hasMatch = true; } } if (rawMatch.day && typeof rawMatch.day === 'object' && !Array.isArray(rawMatch.day)) { const dayMatch = this.normalizeDayMatchConditions(rawMatch.day, localeOverride); if (dayMatch) { match.day = dayMatch; hasMatch = true; } } const implicitRaw = Object.fromEntries(Object.entries(rawMatch).filter(([key]) => !explicitKeys.has(String(key || '').trim().toLowerCase()))); if (Object.keys(implicitRaw).length) { if (defaultScope === 'day') { const dayMatch = this.normalizeDayMatchConditions(implicitRaw, localeOverride); if (dayMatch) { match.day = { ...match.day, ...dayMatch }; hasMatch = true; } } else { const eventMatch = this.normalizeEventMatchConditions(implicitRaw); if (eventMatch) { match.event = { ...match.event, ...eventMatch }; hasMatch = true; } } } ['all', 'and'].forEach((key) => { if (rawMatch[key] === undefined) return; const conditions = Array.isArray(rawMatch[key]) ? rawMatch[key] : [rawMatch[key]]; const normalizedConditions = conditions .map((condition) => this.normalizeAdvancedRuleMatch(condition, defaultScope, localeOverride)) .filter(Boolean); if (normalizedConditions.length) { match.all.push(...normalizedConditions); hasMatch = true; } }); if (rawMatch.any !== undefined) { const conditions = Array.isArray(rawMatch.any) ? rawMatch.any : [rawMatch.any]; const normalizedConditions = conditions .map((condition) => this.normalizeAdvancedRuleMatch(condition, defaultScope, localeOverride)) .filter(Boolean); if (normalizedConditions.length) { match.any = normalizedConditions; hasMatch = true; } } if (rawMatch.not !== undefined) { if (Array.isArray(rawMatch.not)) { const normalizedConditions = rawMatch.not .map((condition) => this.normalizeAdvancedRuleMatch(condition, defaultScope, localeOverride)) .filter(Boolean); if (normalizedConditions.length) { match.not = normalizedConditions; hasMatch = true; } } else { const normalizedCondition = this.normalizeAdvancedRuleMatch(rawMatch.not, defaultScope, localeOverride); if (normalizedCondition) { match.not = normalizedCondition; hasMatch = true; } } } return hasMatch ? match : null; } normalizeEventStyles(rawRules) { if (!Array.isArray(rawRules)) return []; return rawRules .map((rule, index) => { if (!rule || typeof rule !== 'object') return null; const rawMatch = rule.match && typeof rule.match === 'object' ? rule.match : (rule.when && typeof rule.when === 'object' ? rule.when : null); const match = this.normalizeAdvancedRuleMatch(rawMatch, 'event'); let style = null; if (typeof rule.style === 'string' && rule.style.trim().toLowerCase() === 'hide') { style = { hide: true }; } else if (rule.style && typeof rule.style === 'object') { style = rule.style; } if (!match || !style) return null; const numericPriority = Number(rule.priority); const priority = Number.isFinite(numericPriority) ? numericPriority : 0; const normalizedStyle = this.normalizeEventStyleBlock(style); if (!Object.keys(normalizedStyle).length) return null; return { id: typeof rule.id === 'string' && rule.id.trim() ? rule.id.trim() : `event-style-${index + 1}`, type: 'event_style', priority, index, match, output: { style: normalizedStyle }, style: normalizedStyle }; }) .filter(Boolean); } normalizeLegacyDayStyleMatch(rule, localeOverride = null) { const rawCondition = String(rule.condition || '').trim().toLowerCase(); if (!rawCondition) return null; const isNegatedCondition = rawCondition.startsWith('!'); const condition = isNegatedCondition ? rawCondition.slice(1) : rawCondition; if (!condition) return null; if (!['today', 'past', 'future', 'weekend', 'weekday', 'day_of_week', 'has_event'].includes(condition)) return null; if (isNegatedCondition && condition !== 'has_event') return null; const dayMatch = {}; if (condition === 'has_event') { const eventMatch = {}; if (rule.calendar !== undefined && rule.calendar !== null && String(rule.calendar).trim()) { eventMatch.calendar = rule.calendar; } if (rule.title_match !== undefined && rule.title_match !== null && rule.title_match !== '') { eventMatch.title = rule.title_match; } if (!Object.keys(eventMatch).length) return null; dayMatch[isNegatedCondition ? 'no_event' : 'has_event'] = eventMatch; } else if (condition === 'day_of_week') { const dayOfWeek = this.normalizeDayOfWeekRule(rule.day_of_week ?? rule.day ?? rule.days, localeOverride); if (!dayOfWeek.length) return null; dayMatch.day_of_week = dayOfWeek; } else { dayMatch[condition] = true; } return this.normalizeAdvancedRuleMatch({ day: dayMatch }, 'day', localeOverride); } normalizeDayStyles(rawRules, localeOverride = null) { if (!Array.isArray(rawRules)) return []; return rawRules .map((rule, index) => { if (!rule || typeof rule !== 'object') return null; const rawExplicitMatch = rule.match && typeof rule.match === 'object' ? rule.match : (rule.when && typeof rule.when === 'object' ? rule.when : null); const match = rawExplicitMatch ? this.normalizeAdvancedRuleMatch(rawExplicitMatch, 'day', localeOverride) : this.normalizeLegacyDayStyleMatch(rule, localeOverride); if (!match) return null; const numericPriority = Number(rule.priority); const priority = Number.isFinite(numericPriority) ? numericPriority : 0; const style = this.normalizeDayStyleBlock(rule.style && typeof rule.style === 'object' ? rule.style : rule); if ( style.background === undefined && style.opacity === undefined && style.background_opacity === undefined && style.border_color === undefined && style.border_width === undefined ) return null; const normalized = { id: typeof rule.id === 'string' && rule.id.trim() ? rule.id.trim() : `day-style-${index + 1}`, type: 'day_style', priority, index, match, output: { style }, style }; // Backward-compatible mirrors used by older internal tests and helper paths. if (style.background !== undefined) normalized.background = style.background; if (style.opacity !== undefined) normalized.opacity = style.opacity; if (style.background_opacity !== undefined) normalized.background_opacity = style.background_opacity; if (style.border_color !== undefined) normalized.border_color = style.border_color; if (style.border_width !== undefined) normalized.border_width = style.border_width; const day = match.day || {}; if (day.today !== undefined) normalized.condition = 'today'; else if (day.past !== undefined) normalized.condition = 'past'; else if (day.future !== undefined) normalized.condition = 'future'; else if (day.weekend !== undefined) normalized.condition = 'weekend'; else if (day.weekday !== undefined) normalized.condition = 'weekday'; else if (day.day_of_week !== undefined) { normalized.condition = 'day_of_week'; normalized.day_of_week = day.day_of_week; } else if (day.has_event !== undefined) normalized.condition = 'has_event'; else if (day.no_event !== undefined) { normalized.condition = 'has_event'; normalized.negate = true; } return normalized; }) .filter(Boolean); } buildDayStyleRules(rawConfig = {}) { const rules = Array.isArray(rawConfig.day_styles) ? [...rawConfig.day_styles] : []; const todayStyle = this.buildTodayDayStyleRule(rawConfig); if (todayStyle) rules.push(todayStyle); return rules; } buildTodayDayStyleRule(rawConfig = {}) { const hasTodayBackgroundColor = rawConfig.today_background_color !== undefined && rawConfig.today_background_color !== null && rawConfig.today_background_color !== ''; const hasTodayStyle = rawConfig.today_style && typeof rawConfig.today_style === 'object' && !Array.isArray(rawConfig.today_style); if (!hasTodayBackgroundColor && !hasTodayStyle) return null; const style = { ...(hasTodayBackgroundColor ? { background_color: rawConfig.today_background_color } : {}), ...(hasTodayStyle ? rawConfig.today_style : {}) }; return { condition: 'today', priority: 0, style }; } normalizeDayStyleBlock(style = {}) { const normalized = {}; const backgroundValue = style.background !== undefined ? style.background : (style.background_color !== undefined ? style.background_color : style.color); const normalizedBackground = String(backgroundValue || '').trim().toLowerCase() === 'auto' ? 'auto' : this.normalizeSingleColor(backgroundValue); if (normalizedBackground) normalized.background = normalizedBackground; const numericOpacity = Number(style.opacity); if (Number.isFinite(numericOpacity)) { normalized.opacity = Math.max(0, Math.min(1, numericOpacity)); } const numericBackgroundOpacity = Number(style.background_opacity); if (Number.isFinite(numericBackgroundOpacity)) { normalized.background_opacity = Math.max(0, Math.min(1, numericBackgroundOpacity)); } const normalizedBorderColor = this.normalizeSingleColor(style.border_color); if (normalizedBorderColor) normalized.border_color = normalizedBorderColor; const normalizedBorderWidth = this.normalizeStyleBorderWidth(style.border_width); if (normalizedBorderWidth) normalized.border_width = normalizedBorderWidth; return normalized; } normalizeDayBadgeBlock(rule = {}) { const normalized = {}; const text = this.normalizeEventTextValue(rule.text); const icon = this.normalizeEventTextValue(rule.icon); const normalizedText = text || ''; const normalizedIcon = icon || ''; if (normalizedText) normalized.text = normalizedText; if (normalizedIcon) normalized.icon = normalizedIcon; const backgroundColor = this.normalizeDayBadgeDisplayColor(rule.background_color); if (backgroundColor) normalized.background_color = backgroundColor; const color = this.normalizeDayBadgeDisplayColor(rule.color); if (color) normalized.color = color; const size = this.normalizeStyleSizeValue(rule.size); if (size) normalized.size = size; const fontSize = this.normalizeStyleSizeValue(rule.font_size); if (fontSize) normalized.font_size = fontSize; return normalized; } isFullValueTemplate(value) { return typeof value === 'string' && /^\s*\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}\s*$/.test(value); } normalizeDayBadgeDisplayColor(value) { if (this.isFullValueTemplate(value)) return String(value).trim(); return this.normalizeSingleColor(value); } normalizeResolvedDayBadgeDisplayColor(value) { const normalized = this.normalizeSingleColor(value); if (typeof normalized !== 'string') return undefined; const trimmed = normalized.trim(); if (!trimmed) return undefined; if (/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(trimmed)) return trimmed; if (/^rgba?\(\s*[+-]?(?:\d+|\d*\.\d+)%?\s*(?:,|\s)\s*[+-]?(?:\d+|\d*\.\d+)%?\s*(?:,|\s)\s*[+-]?(?:\d+|\d*\.\d+)%?(?:\s*(?:,|\/)\s*(?:[01](?:\.\d+)?|\.\d+|\d+%))?\s*\)$/i.test(trimmed)) return trimmed; if (/^hsla?\(\s*[+-]?(?:\d+|\d*\.\d+)(?:deg|grad|rad|turn)?\s*(?:,|\s)\s*[+-]?(?:\d+|\d*\.\d+)%\s*(?:,|\s)\s*[+-]?(?:\d+|\d*\.\d+)%(?:\s*(?:,|\/)\s*(?:[01](?:\.\d+)?|\.\d+|\d+%))?\s*\)$/i.test(trimmed)) return trimmed; return undefined; } parseEventDescriptionJson(event) { const raw = String(event?.description || '').trim(); if (!raw.startsWith('{') || !raw.endsWith('}')) return undefined; try { const parsed = JSON.parse(raw); if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return undefined; return parsed; } catch { return undefined; } } buildDayBadgeResolutionContext(date, matchedEvent) { const event = matchedEvent && typeof matchedEvent === 'object' ? matchedEvent : {}; const calendar = event.entityId || event.entity_id || event.calendar; const title = event.summary || event.title; return { date: date instanceof Date && !Number.isNaN(date.getTime()) ? this.formatLocalDate(date) : date, calendar, title, event: { ...event, calendar, entity_id: event.entity_id || event.entityId, title, summary: event.summary || event.title, description_json: this.parseEventDescriptionJson(event) } }; } resolveSafePath(path, context) { if (typeof path !== 'string' || !path) return undefined; const blockedSegments = new Set(['__proto__', 'prototype', 'constructor']); const segments = path.split('.'); if (!segments.length) return undefined; let current = context; for (const segment of segments) { if (!/^[A-Za-z0-9_-]+$/.test(segment) || blockedSegments.has(segment)) return undefined; if (current === null || current === undefined || (typeof current !== 'object' && typeof current !== 'function')) return undefined; if (!Object.prototype.hasOwnProperty.call(current, segment)) return undefined; current = current[segment]; } if (current === null || current === undefined) return undefined; if (['string', 'number', 'boolean'].includes(typeof current)) return String(current); return undefined; } resolveDayBadgeDisplayValue(value, context) { if (typeof value !== 'string') return value; const match = value.match(/^\s*\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}\s*$/); if (!match) return value; return this.resolveSafePath(match[1], context); } resolveDayBadgeForRender(rule, date, matchedEvent) { const context = this.buildDayBadgeResolutionContext(date, matchedEvent); const resolved = { ...rule }; ['icon', 'text', 'background_color', 'color'].forEach((field) => { const value = this.resolveDayBadgeDisplayValue(rule[field], context); if (value === undefined || value === null || String(value).trim() === '') { delete resolved[field]; return; } if (field === 'background_color' || field === 'color') { const normalizedColor = this.normalizeResolvedDayBadgeDisplayColor(value); if (normalizedColor) { resolved[field] = normalizedColor; } else { delete resolved[field]; } return; } resolved[field] = String(value).trim(); }); return resolved; } normalizeDayBadgeConditions(rawConditions) { return this.normalizeEventMatchConditions(rawConditions); } normalizeDayBadges(rawRules) { if (!Array.isArray(rawRules)) return []; return rawRules .map((rule, index) => { if (!rule || typeof rule !== 'object') return null; const rawMatch = rule.match && typeof rule.match === 'object' ? rule.match : (rule.conditions && typeof rule.conditions === 'object' ? { event: rule.conditions } : null); const match = this.normalizeAdvancedRuleMatch(rawMatch, 'event'); if (!match) return null; const output = this.normalizeDayBadgeBlock(rule); if (!output.text && !output.icon) return null; const numericPriority = Number(rule.priority); const priority = Number.isFinite(numericPriority) ? numericPriority : 0; const normalized = { id: typeof rule.id === 'string' && rule.id.trim() ? rule.id.trim() : `day-badge-${index + 1}`, type: 'day_badge', priority, index, match, output, conditions: match.event }; Object.assign(normalized, output); return normalized; }) .filter(Boolean); } normalizeCssLength(value, { allowZero = false } = {}) { if (value === undefined || value === null || value === '') return null; if (typeof value === 'number' && Number.isFinite(value)) { if (allowZero) return `${Math.max(0, value)}px`; return value > 0 ? `${value}px` : null; } const trimmed = String(value).trim(); if (!trimmed) return null; if (/^\d*\.?\d+(px|rem|em|%)$/i.test(trimmed)) { return trimmed; } const parsed = Number(trimmed); if (Number.isFinite(parsed) && (allowZero ? parsed >= 0 : parsed > 0)) { return `${parsed}px`; } return null; } normalizeStyleSizeValue(value) { return this.normalizeCssLength(value, { allowZero: false }); } normalizeStyleBorderWidth(value) { return this.normalizeCssLength(value, { allowZero: true }); } normalizeDayOfWeekRule(value, localeOverride = null) { const dayMap = new Map([ ['sun', 0], ['sunday', 0], ['0', 0], ['mon', 1], ['monday', 1], ['1', 1], ['tue', 2], ['tues', 2], ['tuesday', 2], ['2', 2], ['wed', 3], ['weds', 3], ['wednesday', 3], ['3', 3], ['thu', 4], ['thur', 4], ['thurs', 4], ['thursday', 4], ['4', 4], ['fri', 5], ['friday', 5], ['5', 5], ['sat', 6], ['saturday', 6], ['6', 6] ]); this.getLocalizedWeekdayMap(localeOverride).forEach((dayIndexes, token) => { if (!dayMap.has(token)) { dayMap.set(token, dayIndexes.length === 1 ? dayIndexes[0] : dayIndexes); } }); const values = Array.isArray(value) ? value : [value]; const normalizedDays = []; values.forEach((entry) => { if (entry === undefined || entry === null || entry === '') return; if (typeof entry === 'number' && Number.isInteger(entry) && entry >= 0 && entry <= 6) { normalizedDays.push(entry); return; } const normalizedEntry = String(entry).trim().toLowerCase(); if (!normalizedEntry) return; if (dayMap.has(normalizedEntry)) { const mappedValue = dayMap.get(normalizedEntry); if (Array.isArray(mappedValue)) { normalizedDays.push(...mappedValue); } else { normalizedDays.push(mappedValue); } } }); return Array.from(new Set(normalizedDays)); } getLocalizedWeekdayMap(localeOverride = null) { const locale = resolveLanguage(localeOverride || this.getLocale()); const cacheKey = locale || 'default'; if (!this._localizedWeekdayMapCache) this._localizedWeekdayMapCache = new Map(); if (this._localizedWeekdayMapCache.has(cacheKey)) return this._localizedWeekdayMapCache.get(cacheKey); const map = new Map(); const formats = ['long', 'short', 'narrow']; const anchorSunday = new Date(Date.UTC(2024, 0, 7)); // Sunday formats.forEach((weekdayFormat) => { for (let dayIndex = 0; dayIndex < 7; dayIndex += 1) { const date = new Date(anchorSunday); date.setUTCDate(anchorSunday.getUTCDate() + dayIndex); const localizedName = new Intl.DateTimeFormat(locale, { weekday: weekdayFormat, timeZone: 'UTC' }).format(date); const normalizedName = String(localizedName || '').trim().toLowerCase(); if (!normalizedName) continue; if (!map.has(normalizedName)) { map.set(normalizedName, [dayIndex]); continue; } const existingDayIndexes = map.get(normalizedName); if (!existingDayIndexes.includes(dayIndex)) existingDayIndexes.push(dayIndex); } }); this._localizedWeekdayMapCache.set(cacheKey, map); return map; } normalizeEventStyleBlock(style = {}) { const normalized = {}; const setIfDefined = (key, value) => { if (value !== undefined && value !== null && value !== '') { normalized[key] = value; } }; const normalizedBackground = this.normalizeSingleColor(style.background_color ?? style.color); if (normalizedBackground) normalized.background_color = normalizedBackground; const normalizedFontColor = this.normalizeSingleColor(style.event_font_color ?? style.font_color); if (normalizedFontColor) normalized.event_font_color = normalizedFontColor; const normalizedOpacity = this.normalizeEventStyleOpacity(style.opacity); if (normalizedOpacity !== null) normalized.opacity = normalizedOpacity; const normalizedFilter = this.normalizeEventStyleFilter(style.filter); if (normalizedFilter !== null) normalized.filter = normalizedFilter; setIfDefined('event_font_size', style.event_font_size); setIfDefined('event_time_font_size', style.event_time_font_size); setIfDefined('event_location_font_size', style.event_location_font_size); const icon = this.normalizeEventIconName(style.icon); if (icon) normalized.icon = icon; const iconColor = this.normalizeEventIconColor(style.icon_color); if (iconColor) normalized.icon_color = iconColor; const iconSize = this.normalizeStyleSizeValue(style.icon_size); if (iconSize) normalized.icon_size = iconSize; const iconPosition = this.normalizeEventIconPosition(style.icon_position); if (iconPosition) normalized.icon_position = iconPosition; const showEventLocation = this.normalizeBooleanStyleValue(style.show_event_location); if (showEventLocation !== null) normalized.show_event_location = showEventLocation; const useShortLocation = this.normalizeBooleanStyleValue(style.use_short_location); if (useShortLocation !== null) normalized.use_short_location = useShortLocation; const hideTime = this.normalizeBooleanStyleValue(style.hide_time); if (hideTime !== null) normalized.hide_time = hideTime; const showTime = this.normalizeBooleanStyleValue(style.show_time); if (showTime !== null) normalized.show_time = showTime; const hideCalendarBubble = this.normalizeBooleanStyleValue(style.hide_event_calendar_bubble); if (hideCalendarBubble !== null) normalized.hide_event_calendar_bubble = hideCalendarBubble; const hideEvent = this.normalizeBooleanStyleValue(style.hide); if (hideEvent !== null) normalized.hide = hideEvent; if (style.event_title_prefix !== undefined) normalized.event_title_prefix = this.normalizeEventTitlePrefixMode(style.event_title_prefix); return normalized; } normalizeEventIconName(iconValue) { const normalized = this.normalizeEventTextValue(iconValue); if (!normalized) return null; if (!/^mdi:[a-z0-9]+(?:-[a-z0-9]+)*$/.test(normalized)) return null; return normalized; } normalizeEventIconColor(colorValue) { if (colorValue === undefined || colorValue === null) return null; const normalized = this.normalizeSingleColor(colorValue); const trimmed = String(normalized || '').trim(); if (!trimmed) return null; if (/[;{}<>\"']/.test(trimmed)) return null; return trimmed; } normalizeEventIconPosition(positionValue) { const normalized = String(positionValue || '').trim().toLowerCase(); if (!normalized) return null; if (normalized === 'before_title' || normalized === 'corner') return normalized; return null; } normalizeEventStyleOpacity(opacityValue) { if (opacityValue === undefined || opacityValue === null || opacityValue === '') return null; const numericOpacity = Number(opacityValue); if (!Number.isFinite(numericOpacity)) return null; return Math.max(0, Math.min(1, numericOpacity)); } normalizeEventStyleFilter(filterValue) { if (filterValue === undefined || filterValue === null) return null; const normalized = String(filterValue).trim(); if (!normalized) return null; if (/[;{}<>\"']/.test(normalized)) return null; return normalized; } eventMatchesRule(event, match) { const normalizedMatch = this.normalizeEventMatchConditions(match); return this.eventMatchesNormalizedRule(event, normalizedMatch); } eventMatchesNormalizedRule(event, match) { if (!event || !match || typeof match !== 'object') return false; const logicalKeys = new Set(['any', 'all', 'and', 'not']); const fieldKeys = Object.keys(match).filter((key) => !logicalKeys.has(key)); const fieldsPass = fieldKeys.every((field) => this.eventFieldMatches(event, field, match[field])); if (!fieldsPass) return false; const allConditions = Array.isArray(match.all) ? match.all : []; if (!allConditions.every((condition) => this.eventMatchesNormalizedRule(event, condition))) return false; const andConditions = Array.isArray(match.and) ? match.and : []; if (!andConditions.every((condition) => this.eventMatchesNormalizedRule(event, condition))) return false; if (Object.prototype.hasOwnProperty.call(match, 'any')) { const anyConditions = Array.isArray(match.any) ? match.any : []; if (anyConditions.length && !anyConditions.some((condition) => this.eventMatchesNormalizedRule(event, condition))) return false; } if (Object.prototype.hasOwnProperty.call(match, 'not')) { const notCondition = match.not; if (Array.isArray(notCondition)) { if (notCondition.some((condition) => this.eventMatchesNormalizedRule(event, condition))) return false; } else if (notCondition && this.eventMatchesNormalizedRule(event, notCondition)) { return false; } } return true; } getEventCalendarMatchTokens(event) { const tokens = []; const entityIds = new Set(); if (event?.entityId) entityIds.add(event.entityId); if (Array.isArray(event?.sourceEntityIds)) { event.sourceEntityIds.forEach((entityId) => entityId && entityIds.add(entityId)); } else if (Array.isArray(event?.sourceCalendars)) { event.sourceCalendars.forEach((calendar) => calendar?.entityId && entityIds.add(calendar.entityId)); } entityIds.forEach((entityId) => { tokens.push(entityId); tokens.push(this.getCalendarName(entityId)); const virtualCalendar = this.getVirtualBadgeForEntity(entityId); if (virtualCalendar) { tokens.push(`virtual:${virtualCalendar.id}`); tokens.push(virtualCalendar.id); tokens.push(virtualCalendar.name); } }); return Array.from(new Set(tokens.filter(Boolean))); } eventFieldMatches(event, field, condition) { const fieldName = String(field || '').trim().toLowerCase(); if (!fieldName) return false; if (fieldName === 'all_day') { const { isAllDay } = this.getEventDateTimeInfo(event); return this.matchPrimitiveCondition(isAllDay, condition); } if (fieldName === 'past') { return this.matchPrimitiveCondition(this.isPastEvent(event), condition); } if (fieldName === 'calendar') { return this.getEventCalendarMatchTokens(event).some((token) => this.matchTextCondition(token, condition)); } const valueByField = { title: event.summary, summary: event.summary, location: event.location, description: event.description }; return this.matchTextCondition(valueByField[fieldName], condition); } matchPrimitiveCondition(value, condition) { if (typeof condition === 'boolean') { return value === condition; } if (typeof condition === 'string') { const normalized = condition.trim().toLowerCase(); if (normalized === 'true') return value === true; if (normalized === 'false') return value === false; } return value === condition; } parseRegexCondition(value) { if (typeof value !== 'string') return null; const trimmed = value.trim(); if (!trimmed) return null; const prefixed = trimmed.match(/^regex:(.+)$/i); if (prefixed) { try { return new RegExp(prefixed[1].trim(), 'i'); } catch (error) { return null; } } const slashDelimited = trimmed.match(/^\/(.+)\/([dgimsuvy]*)$/); if (!slashDelimited) return null; try { return new RegExp(slashDelimited[1], slashDelimited[2] || 'i'); } catch (error) { return null; } } matchTextCondition(value, condition) { const rawNormalizedValue = this.normalizeEventTextValue(value); if (!rawNormalizedValue) return false; const normalizedValue = rawNormalizedValue.toLowerCase(); if (typeof condition === 'string') { const regex = this.parseRegexCondition(condition); if (regex) return regex.test(rawNormalizedValue); const normalizedCondition = condition.trim(); if (!normalizedCondition) return false; const exactMatch = normalizedCondition.match(/^exact:(.+)$/i); if (exactMatch) { return normalizedValue === exactMatch[1].trim().toLowerCase(); } const containsMatch = normalizedCondition.match(/^(?:contains|substring):(.+)$/i); if (containsMatch) { return normalizedValue.includes(containsMatch[1].trim().toLowerCase()); } return normalizedValue.includes(normalizedCondition.toLowerCase()); } if (condition && typeof condition === 'object' && !Array.isArray(condition)) { if (typeof condition.exact === 'string') { return normalizedValue === condition.exact.trim().toLowerCase(); } if (typeof condition.substring === 'string') { return normalizedValue.includes(condition.substring.trim().toLowerCase()); } if (typeof condition.contains === 'string') { return normalizedValue.includes(condition.contains.trim().toLowerCase()); } if (typeof condition.regex === 'string') { const regex = this.parseRegexCondition(`regex:${condition.regex}`); return !!regex && regex.test(rawNormalizedValue); } } return false; } findMatchingEventForCondition(condition, dayEvents = []) { if (!Array.isArray(dayEvents) || !dayEvents.length) return null; if (condition === true) return dayEvents[0] || null; if (condition === false) return null; return dayEvents.find((event) => this.eventMatchesNormalizedRule(event, condition)) || null; } dateMatchesDayCondition(date, conditionName, conditionValue, context = {}) { if (conditionValue === false) return false; const dayStart = new Date(date); dayStart.setHours(0, 0, 0, 0); const todayStart = new Date(); todayStart.setHours(0, 0, 0, 0); if (conditionName === 'today') return this.matchPrimitiveCondition(!!context.isToday, conditionValue); if (conditionName === 'past') return this.matchPrimitiveCondition(dayStart.getTime() < todayStart.getTime(), conditionValue); if (conditionName === 'future') return this.matchPrimitiveCondition(dayStart.getTime() > todayStart.getTime(), conditionValue); if (conditionName === 'weekend') return this.matchPrimitiveCondition(dayStart.getDay() === 0 || dayStart.getDay() === 6, conditionValue); if (conditionName === 'weekday') return this.matchPrimitiveCondition(dayStart.getDay() !== 0 && dayStart.getDay() !== 6, conditionValue); if (conditionName === 'day_of_week') return Array.isArray(conditionValue) && conditionValue.includes(dayStart.getDay()); return false; } dayMatchesNormalizedRule(dayMatch, context = {}) { if (!dayMatch || typeof dayMatch !== 'object') return { matches: true, matchedEvent: null }; let matchedEvent = null; const logicalKeys = new Set(['any', 'all', 'and', 'not']); const fieldKeys = Object.keys(dayMatch).filter((key) => !logicalKeys.has(key)); for (const field of fieldKeys) { const condition = dayMatch[field]; if (field === 'has_event') { const event = this.findMatchingEventForCondition(condition, context.dayEvents || []); if (!event) return { matches: false, matchedEvent: null }; if (!matchedEvent) matchedEvent = event; continue; } if (field === 'no_event') { const event = this.findMatchingEventForCondition(condition, context.dayEvents || []); if (event) return { matches: false, matchedEvent: null }; continue; } if (!this.dateMatchesDayCondition(context.date, field, condition, context)) { return { matches: false, matchedEvent: null }; } } const allConditions = Array.isArray(dayMatch.all) ? dayMatch.all : []; for (const condition of allConditions) { const result = this.dayMatchesNormalizedRule(condition, context); if (!result.matches) return { matches: false, matchedEvent: null }; if (!matchedEvent && result.matchedEvent) matchedEvent = result.matchedEvent; } const andConditions = Array.isArray(dayMatch.and) ? dayMatch.and : []; for (const condition of andConditions) { const result = this.dayMatchesNormalizedRule(condition, context); if (!result.matches) return { matches: false, matchedEvent: null }; if (!matchedEvent && result.matchedEvent) matchedEvent = result.matchedEvent; } if (Object.prototype.hasOwnProperty.call(dayMatch, 'any')) { const anyConditions = Array.isArray(dayMatch.any) ? dayMatch.any : []; if (!anyConditions.length) return { matches: true, matchedEvent }; let anyMatched = false; for (const condition of anyConditions) { const result = this.dayMatchesNormalizedRule(condition, context); if (result.matches) { anyMatched = true; if (!matchedEvent && result.matchedEvent) matchedEvent = result.matchedEvent; break; } } if (!anyMatched) return { matches: false, matchedEvent: null }; } if (Object.prototype.hasOwnProperty.call(dayMatch, 'not')) { const notCondition = dayMatch.not; if (Array.isArray(notCondition)) { for (const condition of notCondition) { if (this.dayMatchesNormalizedRule(condition, context).matches) return { matches: false, matchedEvent: null }; } } else if (notCondition && this.dayMatchesNormalizedRule(notCondition, context).matches) { return { matches: false, matchedEvent: null }; } } return { matches: true, matchedEvent }; } matchesAdvancedRule(ruleOrMatch, context = {}) { const match = ruleOrMatch?.match || ruleOrMatch; if (!match || typeof match !== 'object') return { matches: false, matchedEvent: null }; let matchedEvent = null; const eventMatch = match.event && Object.keys(match.event).length ? match.event : null; if (eventMatch) { if (context.event) { if (!this.eventMatchesNormalizedRule(context.event, eventMatch)) return { matches: false, matchedEvent: null }; matchedEvent = context.event; } else { const event = this.findMatchingEventForCondition(eventMatch, context.dayEvents || []); if (!event) return { matches: false, matchedEvent: null }; matchedEvent = event; } } const dayMatch = match.day && Object.keys(match.day).length ? match.day : null; if (dayMatch) { const dayResult = this.dayMatchesNormalizedRule(dayMatch, context); if (!dayResult.matches) return { matches: false, matchedEvent: null }; if (!matchedEvent && dayResult.matchedEvent) matchedEvent = dayResult.matchedEvent; } const allConditions = Array.isArray(match.all) ? match.all : []; for (const condition of allConditions) { const result = this.matchesAdvancedRule(condition, context); if (!result.matches) return { matches: false, matchedEvent: null }; if (!matchedEvent && result.matchedEvent) matchedEvent = result.matchedEvent; } const anyConditions = Array.isArray(match.any) ? match.any : []; if (anyConditions.length) { let anyMatched = false; for (const condition of anyConditions) { const result = this.matchesAdvancedRule(condition, context); if (result.matches) { anyMatched = true; if (!matchedEvent && result.matchedEvent) matchedEvent = result.matchedEvent; break; } } if (!anyMatched) return { matches: false, matchedEvent: null }; } if (Object.prototype.hasOwnProperty.call(match, 'not')) { const notCondition = match.not; if (Array.isArray(notCondition)) { for (const condition of notCondition) { if (this.matchesAdvancedRule(condition, context).matches) return { matches: false, matchedEvent: null }; } } else if (notCondition && this.matchesAdvancedRule(notCondition, context).matches) { return { matches: false, matchedEvent: null }; } } return { matches: true, matchedEvent }; } findMatchingDayStyleEvent(rule, dayEvents) { return this.matchesAdvancedRule(rule, { dayEvents }).matchedEvent; } getDayStyleConfig(date, dayEvents, isToday) { const rules = Array.isArray(this._config?.day_styles) ? this._config.day_styles : []; if (!rules.length) return null; const candidates = {}; const applyCandidate = (key, value, rule) => { if (value === undefined || value === null) return; const candidatePriority = Number.isFinite(rule.priority) ? rule.priority : 0; const existing = candidates[key]; if (!existing || candidatePriority > existing.priority || (candidatePriority === existing.priority && rule.index < existing.ruleIndex)) { candidates[key] = { value, priority: candidatePriority, ruleIndex: rule.index }; } }; rules.forEach((rule) => { const result = this.matchesAdvancedRule(rule, { date, dayEvents, isToday }); if (!result.matches) return; const dayStyle = rule.output?.style || rule.style || {}; if (dayStyle.background) { if (dayStyle.background === 'auto' && result.matchedEvent?.color) { applyCandidate('background', result.matchedEvent.color, rule); } else if (dayStyle.background !== 'auto') { applyCandidate('background', dayStyle.background, rule); } } applyCandidate('opacity', dayStyle.opacity, rule); applyCandidate('background_opacity', dayStyle.background_opacity, rule); applyCandidate('border_color', dayStyle.border_color, rule); applyCandidate('border_width', dayStyle.border_width, rule); }); const background = candidates.background?.value ?? null; const opacity = candidates.opacity?.value ?? null; const backgroundOpacity = candidates.background_opacity?.value ?? null; const borderColor = candidates.border_color?.value ?? null; const borderWidth = candidates.border_width?.value ?? null; if (!background && opacity === null && backgroundOpacity === null && !borderColor && !borderWidth) return null; return { background, opacity, background_opacity: backgroundOpacity, border_color: borderColor, border_width: borderWidth }; } getDayStyleAttributes(date, dayEvents, isToday) { const dayStyle = this.getDayStyleConfig(date, dayEvents, isToday); if (!dayStyle) return { className: '', style: '' }; const styles = []; if (dayStyle.background) { const backgroundColor = dayStyle.background_opacity !== null ? this.colorWithAlpha(dayStyle.background, dayStyle.background_opacity) : dayStyle.background; styles.push(`--day-conditional-background: ${dayStyle.background}`); styles.push(`background: ${backgroundColor} !important`); } if (dayStyle.opacity !== null) { styles.push(`--day-conditional-opacity: ${dayStyle.opacity}`); styles.push(`opacity: ${dayStyle.opacity}`); } if (dayStyle.border_color || dayStyle.border_width) { const borderWidth = dayStyle.border_width || '2px'; const borderColor = dayStyle.border_color || 'var(--divider-color, #d1d5db)'; styles.push(`--day-style-border-width: ${borderWidth}`); styles.push(`--day-style-border-color: ${borderColor}`); } const classNames = ['day-style-rule']; if (dayStyle.background) classNames.push('day-style-has-background'); if (dayStyle.border_color || dayStyle.border_width) classNames.push('day-style-has-border'); return { className: classNames.join(' '), style: styles.join('; ') }; } normalizeVirtualCalendars(virtualCalendars) { if (!Array.isArray(virtualCalendars)) return []; return virtualCalendars .map((entry, index) => { if (!entry || typeof entry !== 'object') return null; const id = typeof entry.id === 'string' && entry.id.trim() ? entry.id.trim() : `virtual_${index + 1}`; const entities = Array.isArray(entry.entities) ? Array.from(new Set(entry.entities .map((entityId) => typeof entityId === 'string' ? entityId.trim() : '') .filter(Boolean))) : []; if (entities.length === 0) return null; return { id, name: typeof entry.name === 'string' && entry.name.trim() ? entry.name.trim() : id, icon: typeof entry.icon === 'string' && entry.icon.trim() ? entry.icon.trim() : null, color: this.normalizeSingleColor(entry.color), entities }; }) .filter(Boolean); } getVirtualBadgeById(virtualId) { return (this._config.virtual_calendars || []).find((virtualCalendar) => virtualCalendar.id === virtualId) || null; } getVirtualBadgeForEntity(entityId) { return (this._config.virtual_calendars || []).find((virtualCalendar) => virtualCalendar.entities.includes(entityId)) || null; } getVirtualBadgeForEvent(event) { if (!event) return null; if (event.isCombinedCalendarEvent && Array.isArray(event.sourceEntityIds) && event.sourceEntityIds.length > 0) { const virtualCalendars = event.sourceEntityIds .map((entityId) => this.getVirtualBadgeForEntity(entityId)) .filter(Boolean); if (virtualCalendars.length > 0) { return virtualCalendars[0]; } return null; } return this.getVirtualBadgeForEntity(event.entityId); } getVirtualBadgeItems() { const hiddenBadgeCalendars = new Set(this._config.hide_badge_calendars || []); const items = []; const insertedVirtualIds = new Set(); this._config.entities.forEach((entityId, originalIndex) => { const virtualCalendar = this.getVirtualBadgeForEntity(entityId); if (virtualCalendar && !insertedVirtualIds.has(virtualCalendar.id)) { const configuredEntities = virtualCalendar.entities.filter((configuredEntityId) => this._config.entities.includes(configuredEntityId)); const hasVisibleEntity = configuredEntities.some((configuredEntityId) => !hiddenBadgeCalendars.has(configuredEntityId)); if (hasVisibleEntity) { const color = virtualCalendar.color || this.getCalendarColor(entityId, originalIndex); const isHidden = configuredEntities.every((configuredEntityId) => this._hiddenCalendars.has(configuredEntityId)); items.push({ id: virtualCalendar.id, entityId: `virtual:${virtualCalendar.id}`, name: virtualCalendar.name, icon: virtualCalendar.icon, color, entities: configuredEntities, isHidden, type: 'virtual' }); } insertedVirtualIds.add(virtualCalendar.id); return; } if (virtualCalendar || hiddenBadgeCalendars.has(entityId)) return; const color = this.getCalendarColor(entityId, originalIndex); items.push({ id: entityId, entityId, name: this.getCalendarName(entityId), icon: this.getCalendarBadgeIcon(entityId), color, entities: [entityId], isHidden: this._hiddenCalendars.has(entityId), type: 'entity' }); }); return items; } getWritableCalendars() { return this._config.entities.filter(entityId => { const caps = this._calendarCapabilities[entityId]; return caps && caps.canCreate && !caps.isReadonly; }); } getEventIdentityKey(entityId, event) { return `${entityId}|${event.uid || ''}|${event.recurring_event_id || ''}|${event.start?.dateTime || event.start?.date || event.start || ''}|${event.end?.dateTime || event.end?.date || event.end || ''}|${event.summary || ''}`; } async fetchEventsInRange(startDate, endDate) { const eventsByCalendar = await this.fetchEventsByCalendarInRange(startDate, endDate); return Object.values(eventsByCalendar).flat(); } async fetchEventsByCalendarInRange(startDate, endDate) { const chunks = this.getDateRangeChunks(startDate, endDate, 30); const eventsByCalendar = await Promise.all( this._config.entities.map((entityId, index) => this.fetchEventsForCalendar(entityId, index, chunks) ) ); return this._config.entities.reduce((acc, entityId, index) => { acc[entityId] = eventsByCalendar[index] || []; return acc; }, {}); } getCalendarColor(entityId, index = 0) { return this.normalizeSingleColor( this._config?.colors?.[entityId] || this.getDefaultColor(index) ); } async fetchEventsForCalendar(entityId, colorIndex, chunks) { const seen = new Set(); const color = this.getCalendarColor(entityId, colorIndex); const chunkEventLists = await Promise.all( chunks.map(chunk => this.fetchEventsForChunk(entityId, chunk)) ); const mergedEvents = []; chunkEventLists.forEach(events => { if (!events || !Array.isArray(events)) return; events.forEach(event => { const key = this.getEventIdentityKey(entityId, event); if (seen.has(key)) return; seen.add(key); mergedEvents.push({ ...event, entityId, color }); }); }); return mergedEvents; } async fetchEventsForChunk(entityId, chunk) { const chunkStartStr = chunk.startDate.toISOString(); const chunkEndStr = chunk.endDate.toISOString(); try { // Use WebSocket API to get calendar events. // Home Assistant command name varies by version. return await this.fetchEventsViaWebSocket(entityId, chunkStartStr, chunkEndStr); } catch (error) { // WebSocket API might not be available in older HA versions or for some integrations // Try REST API fallback without logging (this is expected) try { const startDateOnly = this.formatLocalDate(chunk.startDate); const endDateOnly = this.formatLocalDate(chunk.endDate); return await this._hass.callApi('GET', `calendars/${entityId}?start=${startDateOnly}T00:00:00Z&end=${endDateOnly}T23:59:59Z`); } catch (error2) { // Both methods failed - this is a real error console.error(`Failed to fetch events for ${entityId}:`, error2.message || error2); return []; } } } async fetchEventsViaWebSocket(entityId, chunkStartStr, chunkEndStr) { return this._hass.callWS({ type: 'calendar/events', entity_id: entityId, start_date_time: chunkStartStr, end_date_time: chunkEndStr }); } mergeEvents(existingEvents, incomingEvents) { const mergedByKey = new Map(); existingEvents.forEach(event => { mergedByKey.set(this.getEventIdentityKey(event.entityId, event), event); }); incomingEvents.forEach(event => { mergedByKey.set(this.getEventIdentityKey(event.entityId, event), event); }); const merged = Array.from(mergedByKey.values()); merged.sort((a, b) => this.getEventStartDate(a) - this.getEventStartDate(b)); return merged; } toStableString(value) { if (Array.isArray(value)) { return `[${value.map(item => this.toStableString(item)).join(',')}]`; } if (value && typeof value === 'object') { const entries = Object.keys(value) .sort() .map(key => `${JSON.stringify(key)}:${this.toStableString(value[key])}`); return `{${entries.join(',')}}`; } return JSON.stringify(value); } getCalendarDataSignature(events = []) { return events .map(event => { const { entityId, color, ...eventData } = event; return this.toStableString(eventData); }) .sort() .join('|'); } async updateEvents({ preserveScroll = false } = {}) { if (!this._hass || this._fetching) return; const { startDate, endDate } = this.getEventFetchRange(); this._fetching = true; this._lastFetch = Date.now(); try { const newEventsByCalendar = await this.fetchEventsByCalendarInRange(startDate, endDate); const changedCalendars = this._config.entities.filter(entityId => { const hasOldSignature = Object.prototype.hasOwnProperty.call(this._calendarDataSignatures, entityId); if (!hasOldSignature) { return true; } const oldSignature = this._calendarDataSignatures[entityId]; const newSignature = this.getCalendarDataSignature(newEventsByCalendar[entityId]); return oldSignature !== newSignature; }); if (changedCalendars.length === 0) { this._loadedEventRange = { startDate, endDate }; const now = Date.now(); const shouldRenderForUnchangedData = !this._lastUnchangedDataRender || (now - this._lastUnchangedDataRender >= 15 * 60 * 1000); if (shouldRenderForUnchangedData) { this._lastUnchangedDataRender = now; if (preserveScroll) { this.renderPreservingAgendaScroll(); } else { this.render(); } } return; } this._config.entities.forEach(entityId => { this._calendarDataSignatures[entityId] = this.getCalendarDataSignature(newEventsByCalendar[entityId]); }); const mergedEvents = Object.values(newEventsByCalendar) .flat() .sort((a, b) => this.getEventStartDate(a) - this.getEventStartDate(b)); this._events = mergedEvents; this._loadedEventRange = { startDate, endDate }; this._lastUnchangedDataRender = Date.now(); if (preserveScroll) { this.renderPreservingAgendaScroll(); } else { this.render(); } } finally { this._fetching = false; } } async extendEventsForRange(startDate, endDate, { render = true } = {}) { if (!this._hass || this._fetching) return; this._fetching = true; this._lastFetch = Date.now(); try { const additionalEvents = await this.fetchEventsInRange(startDate, endDate); this._events = this.mergeEvents(this._events, additionalEvents); if (render) { this.render(); } } finally { this._fetching = false; } } isDateRangeCoveredByLoadedEvents(targetStartDate, targetEndDate) { if (!this._loadedEventRange) return false; return targetStartDate >= this._loadedEventRange.startDate && targetEndDate <= this._loadedEventRange.endDate; } async ensureEventsForCurrentRange({ force = false, renderIfCovered = false } = {}) { const shouldRefreshForAge = !this._lastFetch || (Date.now() - this._lastFetch > 60000); const { startDate: visibleStartDate, endDate: visibleEndDate } = this.getVisibleDateRange(); // Background stale refreshes run through this path via hass updates. // Keep dialogs stable by postponing only those refreshes while modal is open. if (this.isEventManagementDialogOpen() && (force || shouldRefreshForAge)) { return; } if (force || shouldRefreshForAge || !this._loadedEventRange) { const shouldPreserveScrollDuringRefresh = this._viewMode === 'agenda' && !force && !renderIfCovered; await this.updateEvents({ preserveScroll: shouldPreserveScrollDuringRefresh }); return; } // Gate fetches on the actually visible range. If the user can already see // all required dates from loaded data, avoid any network call. if (this.isDateRangeCoveredByLoadedEvents(visibleStartDate, visibleEndDate)) { if (renderIfCovered) { this.render(); } return; } // Once visible range falls outside loaded coverage, fetch around current view // (with buffer) and only request missing leading/trailing segments. const { startDate, endDate } = this.getEventFetchRange(); const missingRanges = []; if (startDate < this._loadedEventRange.startDate) { const missingStartEnd = new Date(this._loadedEventRange.startDate); missingStartEnd.setDate(missingStartEnd.getDate() - 1); missingStartEnd.setHours(23, 59, 59, 999); missingRanges.push({ startDate, endDate: missingStartEnd }); } if (endDate > this._loadedEventRange.endDate) { const missingEndStart = new Date(this._loadedEventRange.endDate); missingEndStart.setDate(missingEndStart.getDate() + 1); missingEndStart.setHours(0, 0, 0, 0); missingRanges.push({ startDate: missingEndStart, endDate }); } for (const range of missingRanges) { await this.extendEventsForRange(range.startDate, range.endDate, { render: false }); } this._loadedEventRange = { startDate: new Date(Math.min(this._loadedEventRange.startDate.getTime(), startDate.getTime())), endDate: new Date(Math.max(this._loadedEventRange.endDate.getTime(), endDate.getTime())) }; this.render(); } getEventFetchRange() { const { startDate: visibleStart, endDate: visibleEnd } = this.getVisibleDateRange(); // Keep a small look-behind and look-ahead buffer. const startDate = new Date(visibleStart); startDate.setDate(startDate.getDate() - 7); const endDate = new Date(visibleEnd); endDate.setDate(endDate.getDate() + 30); return { startDate, endDate }; } getVisibleDateRange() { if (this._viewMode === 'agenda') { this.ensureAgendaWindowInitialized(); const startDate = new Date(this._agendaStartDate); startDate.setHours(0, 0, 0, 0); const endDate = new Date(this._agendaEndDate); endDate.setHours(23, 59, 59, 999); return { startDate, endDate }; } // Month rolling-weeks mode: from start of anchor week through configured weeks. if (this._viewMode === 'month' && this._config.rolling_weeks !== null) { const anchorDate = new Date(this._currentDate); anchorDate.setHours(0, 0, 0, 0); const currentDay = anchorDate.getDay(); const diff = (currentDay - this._config.firstDayOfWeek + 7) % 7; const startDate = new Date(anchorDate); startDate.setDate(anchorDate.getDate() - diff); startDate.setHours(0, 0, 0, 0); const endDate = new Date(startDate); endDate.setDate(startDate.getDate() + ((this._config.rolling_weeks + 1) * 7) - 1); endDate.setHours(23, 59, 59, 999); return { startDate, endDate }; } // Standard month mode: full rendered grid (including adjacent month cells). if (this._viewMode === 'month') { const year = this._currentDate.getFullYear(); const month = this._currentDate.getMonth(); const firstDay = new Date(year, month, 1).getDay(); const daysInMonth = new Date(year, month + 1, 0).getDate(); const startOffset = (firstDay - this._config.firstDayOfWeek + 7) % 7; const totalCells = startOffset + daysInMonth; const trailingCells = totalCells % 7 === 0 ? 0 : 7 - (totalCells % 7); const startDate = new Date(year, month, 1 - startOffset); startDate.setHours(0, 0, 0, 0); const endDate = new Date(year, month, daysInMonth + trailingCells); endDate.setHours(23, 59, 59, 999); return { startDate, endDate }; } // Week views: from first shown day to last shown day. const weekDays = this.getWeekDays(); const startDate = new Date(weekDays[0]); startDate.setHours(0, 0, 0, 0); const endDate = new Date(weekDays[weekDays.length - 1]); endDate.setHours(23, 59, 59, 999); return { startDate, endDate }; } getDateRangeChunks(startDate, endDate, chunkDays = 30) { const chunks = []; let cursor = new Date(startDate); cursor.setHours(0, 0, 0, 0); while (cursor <= endDate) { const chunkStart = new Date(cursor); const chunkEnd = new Date(cursor); chunkEnd.setDate(chunkEnd.getDate() + chunkDays - 1); if (chunkEnd > endDate) { chunkEnd.setTime(endDate.getTime()); } chunkEnd.setHours(23, 59, 59, 999); chunks.push({ startDate: chunkStart, endDate: chunkEnd }); cursor = new Date(chunkEnd); cursor.setDate(cursor.getDate() + 1); cursor.setHours(0, 0, 0, 0); } return chunks; } getEventStartDate(event) { if (event.start?.dateTime) return new Date(event.start.dateTime); if (event.start?.date) return this.parseLocalDate(event.start.date); return new Date(event.start); } parseLocalDate(dateStr) { if (!dateStr || typeof dateStr !== 'string') return new Date(dateStr); const [year, month, day] = dateStr.split('-').map(Number); if (![year, month, day].every(Number.isFinite)) return new Date(dateStr); return new Date(year, month - 1, day); } parsePossiblyLocalDateTime(value) { if (!value || typeof value !== 'string') return new Date(value); const hasTimezone = /(?:[zZ]|[+-]\d{2}:?\d{2})$/.test(value); if (hasTimezone) return new Date(value); const match = value.match(/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?$/); if (!match) return new Date(value); const [, year, month, day, hour, minute, second = '0'] = match; return new Date( Number(year), Number(month) - 1, Number(day), Number(hour), Number(minute), Number(second) ); } formatLocalDate(date) { if (!(date instanceof Date) || Number.isNaN(date.getTime())) return ''; const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); return `${year}-${month}-${day}`; } getDefaultColor(index) { const colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#FFA07A', '#98D8C8', '#F7DC6F', '#BB8FCE', '#85C1E2']; return colors[index % colors.length]; } connectedCallback() { window.addEventListener('resize', this._handleViewportResize); window.visualViewport?.addEventListener('resize', this._handleViewportResize); this.attachSystemThemeListener(); this.observeHostAndParentResize(); this.render(); } disconnectedCallback() { window.removeEventListener('resize', this._handleViewportResize); window.visualViewport?.removeEventListener('resize', this._handleViewportResize); this.cancelMonthCompactMeasurement(); if (this._monthGridResizeObserver) { this._monthGridResizeObserver.disconnect(); this._monthGridResizeObserver = null; } if (this._headerResizeObserver) { this._headerResizeObserver.disconnect(); this._headerResizeObserver = null; } if (this._hostResizeObserver) { this._hostResizeObserver.disconnect(); this._hostResizeObserver = null; } this._observedResizeParent = null; this._lastObservedHostSize = null; if (this._hostResizeRaf !== null) { window.cancelAnimationFrame(this._hostResizeRaf); this._hostResizeRaf = null; } this.detachSystemThemeListener(); this.teardownWeatherForecastSubscription(); this.updateEventModalOpenState(null); if (this._modalVisibilityObserver) { this._modalVisibilityObserver.disconnect(); this._modalVisibilityObserver = null; } if (this._wrapMeasureRaf1 !== null) { window.cancelAnimationFrame(this._wrapMeasureRaf1); this._wrapMeasureRaf1 = null; } if (this._wrapMeasureRaf2 !== null) { window.cancelAnimationFrame(this._wrapMeasureRaf2); this._wrapMeasureRaf2 = null; } } getCompactMaxHeight(containerTopInViewport = null) { if (!this._config.compact_height) return null; const viewportHeight = window.visualViewport?.height || window.innerHeight; const containerTop = Math.max( containerTopInViewport ?? this.getBoundingClientRect().top, 0 ); const bottomSpacing = 0; const minimumHeight = 180; return Math.max(minimumHeight, Math.floor(viewportHeight - containerTop - bottomSpacing)); } getElementSizeForAllocation(element) { if (!element || typeof element.getBoundingClientRect !== 'function') return { width: 0, height: 0 }; const rect = element.getBoundingClientRect(); return { width: Number.isFinite(rect.width) ? rect.width : 0, height: Number.isFinite(rect.height) ? rect.height : 0 }; } hasFixedHeightParentAllocation() { const parent = this.parentElement; if (!parent || typeof parent.getBoundingClientRect !== 'function') return false; const parentSize = this.getElementSizeForAllocation(parent); if (parentSize.height <= 0) return false; const parentStyle = typeof window.getComputedStyle === 'function' ? window.getComputedStyle(parent) : null; const parentMaxHeight = parentStyle?.maxHeight || ''; const parentDisplay = parentStyle?.display || ''; const parentOverflowY = parentStyle?.overflowY || parentStyle?.overflow || ''; const inlineStyle = parent.getAttribute?.('style') || ''; const hasExplicitCssHeight = Boolean( parent.style?.height || parent.style?.minHeight || parent.style?.maxHeight || /(?:^|;)\s*(?:height|min-height|max-height)\s*:/i.test(inlineStyle) || (parentMaxHeight && parentMaxHeight !== 'none' && parentMaxHeight !== '0px') ); const looksLikeGridAllocation = /grid/i.test(parentDisplay) || parent.hasAttribute?.('grid_options') || parent.classList?.contains('grid-cell'); const clipsOrScrollsOverflow = /(auto|hidden|scroll|clip)/.test(parentOverflowY); const hostSize = this.getElementSizeForAllocation(this); const parentHasExtraAllocatedHeight = hostSize.height > 0 && parentSize.height - hostSize.height > 1; return hasExplicitCssHeight || looksLikeGridAllocation || clipsOrScrollsOverflow || parentHasExtraAllocatedHeight; } getGridAwareCompactContainerStyle() { return 'height: 100%; min-height: 0; overflow-y: auto;'; } getCompactMonthGridStyle(monthWeekRows, compactMaxHeight = null) { const rowTemplate = `grid-template-rows: auto repeat(${monthWeekRows}, minmax(0, 1fr));`; if (this.hasFixedHeightParentAllocation()) { return `height: 100%; min-height: 0; overflow-y: auto; ${rowTemplate}`; } const resolvedMaxHeight = compactMaxHeight || this.getCompactMaxHeight(this._monthContainerTopInViewport); return resolvedMaxHeight ? `height: ${resolvedMaxHeight}px; overflow-y: auto; ${rowTemplate}` : ''; } getCompactContainerStyle(maxHeight = null) { if (!this._config.compact_height) return ''; if (this.hasFixedHeightParentAllocation()) { return this.getGridAwareCompactContainerStyle(); } const resolvedMaxHeight = maxHeight || this.getCompactMaxHeight(); if (!resolvedMaxHeight) return ''; return `height: ${resolvedMaxHeight}px; max-height: ${resolvedMaxHeight}px; overflow-y: auto;`; } preserveAgendaScrollForNextRender() { if (this._viewMode !== 'agenda' || Number.isFinite(this._agendaPendingScrollTop)) return; const agendaContainer = this.getRootElementById('agenda-container'); if (!agendaContainer) return; this._agendaPendingScrollTop = agendaContainer.scrollTop; } renderPreservingAgendaScroll() { this.preserveAgendaScrollForNextRender(); this.render(); } setAgendaScrollTopWithoutTriggeringLoad(container, scrollTop) { if (!container) return; this._agendaSuppressScrollHandling = true; container.scrollTop = scrollTop; window.requestAnimationFrame(() => { this._agendaSuppressScrollHandling = false; }); } updateWeekStandardFixedOffsetHeightFromDom() { if (this._viewMode !== 'week-standard' || !this._config.compact_height || !this._root) return; if (this.isEventManagementDialogOpen()) return; const container = this._root.querySelector('.week-standard-container'); const headerSpacer = this._root.querySelector('.time-column-header-spacer'); const extraSpacer = this._root.querySelector('.time-column-extra-spacer'); const allDaySpacer = this._root.querySelector('.time-column-allday-spacer'); if (!container || !headerSpacer || !extraSpacer) return; const computed = window.getComputedStyle(container); const containerPadding = (parseFloat(computed.paddingTop) || 0) + (parseFloat(computed.paddingBottom) || 0); const measuredOffset = Math.ceil( containerPadding + headerSpacer.getBoundingClientRect().height + extraSpacer.getBoundingClientRect().height + (allDaySpacer ? allDaySpacer.getBoundingClientRect().height : 0) ); const measuredContainerTop = Math.max(container.getBoundingClientRect().top, 0); if (!Number.isFinite(measuredOffset) || !Number.isFinite(measuredContainerTop)) return; const offsetChanged = this._weekStandardFixedOffsetHeight === null || Math.abs(this._weekStandardFixedOffsetHeight - measuredOffset) > 1; const containerTopChanged = this._weekStandardContainerTopInViewport === null || Math.abs(this._weekStandardContainerTopInViewport - measuredContainerTop) > 1; if (offsetChanged || containerTopChanged) { this._weekStandardFixedOffsetHeight = measuredOffset; this._weekStandardContainerTopInViewport = measuredContainerTop; this.render(); } } cancelMonthCompactMeasurement() { if (this._monthMeasureRaf !== null) { window.cancelAnimationFrame(this._monthMeasureRaf); this._monthMeasureRaf = null; } if (this._monthMeasureRenderRaf !== null) { window.cancelAnimationFrame(this._monthMeasureRenderRaf); this._monthMeasureRenderRaf = null; } } scheduleMonthCompactTopMeasurement(force = false) { if (this._viewMode !== 'month' || !this._config.compact_height || this.shouldShowAllEventsInMonth()) return; if (this.isEventManagementDialogOpen()) return; if (!force && !this._monthCompactMeasurementDirty && this._monthContainerTopInViewport !== null) return; if (this._monthMeasureRaf !== null) return; this._monthMeasureRaf = window.requestAnimationFrame(() => { this._monthMeasureRaf = null; this.updateMonthContainerTopInViewportFromDom(); this._monthCompactMeasurementDirty = false; }); } observeHostAndParentResize() { if (typeof window.ResizeObserver !== 'function') return; const parent = this.parentElement || null; if (this._hostResizeObserver && this._observedResizeParent === parent) return; if (this._hostResizeObserver) { this._hostResizeObserver.disconnect(); this._hostResizeObserver = null; } this._observedResizeParent = parent; this._lastObservedHostSize = this.measureHostAndParentSize(); this._hostResizeObserver = new window.ResizeObserver(() => { this.scheduleHostAndParentResizeHandling(); }); this._hostResizeObserver.observe(this); if (parent) { this._hostResizeObserver.observe(parent); } } measureHostAndParentSize() { const hostSize = this.getElementSizeForAllocation(this); const parentSize = this.getElementSizeForAllocation(this.parentElement); return { hostWidth: Math.round(hostSize.width), hostHeight: Math.round(hostSize.height), parentWidth: Math.round(parentSize.width), parentHeight: Math.round(parentSize.height) }; } hasObservedHostSizeChanged(nextSize) { const previousSize = this._lastObservedHostSize; if (!previousSize) return true; return Object.keys(nextSize).some((key) => Math.abs(nextSize[key] - previousSize[key]) > 1); } scheduleHostAndParentResizeHandling() { if (this._hostResizeRaf !== null) return; this._hostResizeRaf = window.requestAnimationFrame(() => { this._hostResizeRaf = null; const nextSize = this.measureHostAndParentSize(); if (!this.hasObservedHostSizeChanged(nextSize)) return; this._lastObservedHostSize = nextSize; if (this._config.compact_height && this._viewMode === 'month' && !this.shouldShowAllEventsInMonth()) { this._monthCompactMeasurementDirty = true; } this.render(); }); } observeHeaderResize() { if (!this._root || typeof window.ResizeObserver !== 'function') return; if (this._headerResizeObserver) { this._headerResizeObserver.disconnect(); this._headerResizeObserver = null; } const headerSelector = this._config.compact_header ? '.header-compact' : '.header'; const header = this._root.querySelector(headerSelector); if (!header) return; this._headerResizeObserver = new window.ResizeObserver(() => { this.updateCompactHeaderWrapState(); }); this._headerResizeObserver.observe(header); } observeMonthGridResize() { if (!this._root || typeof window.ResizeObserver !== 'function') return; if (this._monthGridResizeObserver) { this._monthGridResizeObserver.disconnect(); this._monthGridResizeObserver = null; } if (this._viewMode !== 'month' || !this._config.compact_height || this.shouldShowAllEventsInMonth()) return; const container = this._root.querySelector('.calendar-container'); if (!container) return; this._monthGridResizeObserver = new window.ResizeObserver(() => { this._monthCompactMeasurementDirty = true; this.scheduleMonthCompactTopMeasurement(); }); this._monthGridResizeObserver.observe(container); } updateMonthContainerTopInViewportFromDom() { if (this._viewMode !== 'month' || !this._config.compact_height || this.shouldShowAllEventsInMonth() || !this._root) return; if (this.isEventManagementDialogOpen()) return; const container = this._root.querySelector('.calendar-grid'); if (!container) return; const measuredContainerTop = Math.max(container.getBoundingClientRect().top, 0); const viewportHeight = window.visualViewport?.height || window.innerHeight; if (!Number.isFinite(measuredContainerTop)) return; if (!Number.isFinite(viewportHeight)) return; const containerTopChanged = this._monthContainerTopInViewport === null || Math.abs(this._monthContainerTopInViewport - measuredContainerTop) > 1; const viewportHeightChanged = this._lastCompactMonthViewportHeight === null || Math.abs(this._lastCompactMonthViewportHeight - viewportHeight) > 1; if (containerTopChanged) { this._monthContainerTopInViewport = measuredContainerTop; } if (viewportHeightChanged) { this._lastCompactMonthViewportHeight = viewportHeight; } if (containerTopChanged || viewportHeightChanged) { if (this._monthMeasureRenderRaf === null) { this._monthMeasureRenderRaf = window.requestAnimationFrame(() => { this._monthMeasureRenderRaf = null; this.render(); }); } } } updateAgendaContainerTopInViewportFromDom() { if (this._viewMode !== 'agenda' || !this._config.compact_height || !this._root) return; if (this.isEventManagementDialogOpen()) return; const container = this._root.querySelector('.agenda-container'); if (!container) return; const measuredContainerTop = Math.max(container.getBoundingClientRect().top, 0); if (!Number.isFinite(measuredContainerTop)) return; const containerTopChanged = this._agendaContainerTopInViewport === null || Math.abs(this._agendaContainerTopInViewport - measuredContainerTop) > 1; if (containerTopChanged) { this._agendaContainerTopInViewport = measuredContainerTop; this.render(); } } getLanguage() { return resolveLanguage(this._config.language || this._hass?.language || this._hass?.locale?.language); } getLocale() { if (this._config.locale) return this._config.locale; const configuredLanguage = this._config.language ? resolveLanguage(this._config.language) : null; if (configuredLanguage) return TRANSLATIONS[configuredLanguage]?.locale || this._config.language; const hassLocale = this._hass?.locale?.language; if (hassLocale) return hassLocale; const hassLanguage = this._hass?.language; if (hassLanguage) { const resolvedHassLanguage = resolveLanguage(hassLanguage); return TRANSLATIONS[resolvedHassLanguage]?.locale || hassLanguage; } return globalThis.navigator?.languages?.[0] || globalThis.navigator?.language || TRANSLATIONS[DEFAULT_LANGUAGE]?.locale || 'en-US'; } t(key, params = {}) { return translate(this.getLanguage(), key, params); } getWeekdayNameFormat() { return this._config?.display_full_weekday_names ? 'long' : 'short'; } getWeekdayNames(format = this.getWeekdayNameFormat()) { const formatter = new Intl.DateTimeFormat(this.getLocale(), { weekday: format }); const baseDate = new Date(2021, 5, 6); const names = []; for (let i = 0; i < 7; i++) { const date = new Date(baseDate); date.setDate(baseDate.getDate() + i); names.push(formatter.format(date)); } return names; } setWeekStart() { const date = new Date(this._currentDate); const day = date.getDay(); const diff = (day - this._config.firstDayOfWeek + 7) % 7; date.setDate(date.getDate() - diff); date.setHours(0, 0, 0, 0); this._weekStart = date; } resetAgendaWindowToToday() { const today = new Date(); today.setHours(0, 0, 0, 0); this._currentDate = new Date(today); this._agendaStartDate = new Date(today); const endDate = new Date(today); endDate.setDate(endDate.getDate() + this.getAgendaPeriodDaySpan()); endDate.setHours(23, 59, 59, 999); this._agendaEndDate = endDate; this._agendaVisibleStartDate = new Date(today); const visibleEndDate = new Date(endDate); visibleEndDate.setHours(23, 59, 59, 999); this._agendaVisibleEndDate = visibleEndDate; } ensureAgendaWindowInitialized() { if (this._agendaStartDate && this._agendaEndDate) return; this.resetAgendaWindowToToday(); } getAgendaDays() { this.ensureAgendaWindowInitialized(); const days = []; const cursor = new Date(this._agendaStartDate); cursor.setHours(0, 0, 0, 0); const end = new Date(this._agendaEndDate); end.setHours(0, 0, 0, 0); while (cursor <= end) { days.push(new Date(cursor)); cursor.setDate(cursor.getDate() + 1); } return days; } getAgendaVisibleDateRangeFromDom() { if (!this._root || this._viewMode !== 'agenda') return null; const container = this.getRootElementById('agenda-container'); if (!container) return null; const containerRect = container.getBoundingClientRect(); const dayRows = Array.from(container.querySelectorAll('.agenda-day-row')); if (dayRows.length === 0) return null; const visibleDates = dayRows .map((row) => { const rect = row.getBoundingClientRect(); const isVisible = rect.bottom > containerRect.top && rect.top < containerRect.bottom; if (!isVisible) return null; const rawDate = row.getAttribute('data-date'); if (!rawDate) return null; return new Date(rawDate); }) .filter((date) => date instanceof Date && !Number.isNaN(date.getTime())); if (visibleDates.length === 0) return null; const startDate = new Date(visibleDates[0]); startDate.setHours(0, 0, 0, 0); const endDate = new Date(visibleDates[visibleDates.length - 1]); endDate.setHours(23, 59, 59, 999); return { startDate, endDate }; } updateAgendaVisibleDateRangeFromDom() { const visibleRange = this.getAgendaVisibleDateRangeFromDom(); if (!visibleRange) { this._agendaVisibleStartDate = null; this._agendaVisibleEndDate = null; this.updateAgendaPeriodLabelInDom(); return; } this._agendaVisibleStartDate = visibleRange.startDate; this._agendaVisibleEndDate = visibleRange.endDate; this.updateAgendaPeriodLabelInDom(); } isAgendaRangeWithinCurrentWindow(range) { if (!range?.startDate || !range?.endDate || !this._agendaStartDate || !this._agendaEndDate) { return false; } const rangeStart = new Date(range.startDate); rangeStart.setHours(0, 0, 0, 0); const rangeEnd = new Date(range.endDate); rangeEnd.setHours(23, 59, 59, 999); const windowStart = new Date(this._agendaStartDate); windowStart.setHours(0, 0, 0, 0); const windowEnd = new Date(this._agendaEndDate); windowEnd.setHours(23, 59, 59, 999); return rangeStart >= windowStart && rangeEnd <= windowEnd; } updateAgendaPeriodLabelInDom() { if (!this._root || this._viewMode !== 'agenda') return; const label = this.getPeriodLabel(); this._root.querySelectorAll('.month-year').forEach((labelEl) => { labelEl.textContent = label; }); } getAgendaViewportDayCapacity() { if (!this._root || this._viewMode !== 'agenda') { return this._agendaDaysPerScrollLoad; } const container = this.getRootElementById('agenda-container'); if (!container) { return this._agendaDaysPerScrollLoad; } const rows = Array.from(container.querySelectorAll('.agenda-day-row')); if (rows.length === 0) { return this._agendaDaysPerScrollLoad; } const maxHeight = container.clientHeight; if (!Number.isFinite(maxHeight) || maxHeight <= 0) { return this._agendaDaysPerScrollLoad; } let consumedHeight = 0; let dayCount = 0; for (const row of rows) { const rowHeight = row.getBoundingClientRect().height; if (!Number.isFinite(rowHeight) || rowHeight <= 0) continue; if ((consumedHeight + rowHeight) > maxHeight && dayCount > 0) { break; } consumedHeight += rowHeight; dayCount += 1; if (consumedHeight >= maxHeight) { break; } } return Math.max(1, dayCount || this._agendaDaysPerScrollLoad); } getAgendaRollingDays() { if (this._config?.rolling_days_agenda !== null && this._config?.rolling_days_agenda !== undefined) { return this._config.rolling_days_agenda; } return null; } getAgendaPeriodDaySpan() { const rollingDays = this.getAgendaRollingDays(); return rollingDays !== null ? rollingDays : 14; } getRollingDaysForView(viewMode = this._viewMode) { if (viewMode === 'week-compact' && this._config.rolling_days_week_compact !== null) { return this._config.rolling_days_week_compact; } if (viewMode === 'week-standard' && this._config.rolling_days_schedule !== null) { return this._config.rolling_days_schedule; } return null; } getWeekDays(viewMode = this._viewMode) { const rollingDays = this.getRollingDaysForView(viewMode); // If rolling days are set, show current date + N days if (rollingDays !== null) { const days = []; const startDate = new Date(this._currentDate); startDate.setHours(0, 0, 0, 0); for (let i = 0; i <= rollingDays; i++) { const date = new Date(startDate); date.setDate(startDate.getDate() + i); days.push(date); } return days; } // Otherwise use the week-based approach const days = []; for (let i = 0; i < 7; i++) { const date = new Date(this._weekStart); date.setDate(this._weekStart.getDate() + i); if (this._config.week_days.includes(date.getDay())) { days.push(date); } } return days; } getStyles() { return ` daylight-calendar-card, skylight-calendar-card { display: block; width: 100%; height: 100%; min-height: 0; } daylight-calendar-card.event-modal-open, skylight-calendar-card.event-modal-open { position: relative; z-index: 2147483000; overflow: visible; } .calendar-container { position: relative; border-radius: var(--ha-card-border-radius, 12px); border: var(--ha-card-border-width, 0) solid var(--ha-card-border-color, transparent); overflow: hidden; box-shadow: var(--ha-card-box-shadow, 0 2px 8px rgba(0,0,0,0.1)); width: 100%; height: 100%; min-height: 100%; display: flex; flex-direction: column; color-scheme: light; font-family: var(--ha-font-family-body, var(--paper-font-body1_-_font-family, inherit)); --schedule-hour-line-color: #d1d5db; } .calendar-container::after { content: ''; position: absolute; inset: 0; z-index: 0; background-image: var(--calendar-background-image, none); background-size: var(--calendar-background-size, cover); background-position: var(--calendar-background-position, center); background-repeat: var(--calendar-background-repeat, no-repeat); opacity: var(--calendar-background-image-opacity, 0); pointer-events: none; } .calendar-container > * { position: relative; z-index: 1; } .calendar-container, .calendar-container input, .calendar-container select, .calendar-container textarea, .calendar-container button { color-scheme: light; } .calendar-container input, .calendar-container select, .calendar-container textarea, .calendar-container button { font-family: inherit; } .header, .header-compact { position: relative; flex: 0 0 auto; background: transparent; color: var(--header-text-color, white); } .header::before, .header-compact::before { content: ''; position: absolute; inset: 0; z-index: 0; background: var(--header-background-base, var(--header-background, var(--primary-color))); opacity: var(--header-background-alpha, 1); pointer-events: none; } .header > *, .header-compact > * { position: relative; z-index: 1; } .header { padding: 20px 24px; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 12px; } .header-compact { padding: 16px 24px; } .calendar-body { position: relative; z-index: 1; flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; } .calendar-body::before { content: ''; position: absolute; inset: 0; z-index: 0; background: var(--calendar-background, var(--theme-card-background, var(--ha-card-background, var(--card-background-color, #ffffff)))); opacity: var(--calendar-background-opacity, 1); pointer-events: none; } .calendar-body > * { position: relative; z-index: 1; } .header-left { display: flex; align-items: center; gap: 16px; } .compact-header-left { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; } .header-compact.is-wrapped .compact-header-left, .header-compact.is-wrapped .compact-header-controls { width: 100%; justify-content: center; align-items: center; } .header-compact.is-wrapped { align-items: center; justify-content: center; } .header-compact.is-wrapped .compact-header-left { justify-content: center; text-align: center; flex-wrap: wrap; } .header-compact.is-wrapped .header-title-wrap { justify-content: center; text-align: center; } .header-compact.is-wrapped .compact-header-controls { justify-content: center; flex-wrap: wrap; row-gap: 12px; column-gap: 12px; } .header-compact.is-wrapped .compact-period-controls { margin-left: 0; justify-content: center; flex-wrap: wrap; width: auto; } .header-compact.is-wrapped .today-button, .header-compact.is-wrapped .compact-add-event-button, .header-compact.is-wrapped .view-mode-select, .header-compact.is-wrapped .nav-button { padding-left: 12px; padding-right: 12px; } .header.is-wrapped, .header-compact.is-wrapped { background: var(--header-wrapped-background, transparent); } .header.is-wrapped .header-left, .header.is-wrapped .header-controls { justify-content: center; } .header-controls.is-wrapped .period-controls { margin-left: 0; } .calendar-badges-inline { display: flex; gap: 8px; flex-wrap: wrap; } .calendar-badges-inline.is-wrapped { justify-content: center; } .calendar-badge-inline { padding: 6px 12px !important; font-size: 12px !important; } .calendar-badge-inline .calendar-badge-icon { width: 20px !important; height: 20px !important; font-size: 10px !important; } .calendar-badge-inline .calendar-badge-name { font-size: 12px; } .calendar-badge-inline .calendar-badge-person-state { font-size: 10px; } .calendar-badge.hide-calendar-name { justify-content: center; width: 40px; height: 40px; padding: 0 !important; } .calendar-badge-inline.hide-calendar-name { width: 32px; height: 32px; } .calendar-badge.hide-calendar-name .calendar-badge-icon { width: 100% !important; height: 100% !important; border-radius: inherit; font-size: 16px !important; } .calendar-badge-inline.hide-calendar-name .calendar-badge-icon { font-size: 14px !important; } .calendar-badge.hide-calendar-name .calendar-badge-icon ha-icon { --mdc-icon-size: 60%; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; line-height: 1; } .header-title { font-size: 24px; font-weight: 600; margin: 0; } .header-title-wrap { display: inline-flex; align-items: baseline; gap: 10px; flex-wrap: wrap; } .header-time { font-size: 28px; font-weight: 500; opacity: 0.95; line-height: 1; white-space: nowrap; } .header-weather { display: inline-flex; align-items: center; gap: 4px; font-size: 28px; font-weight: 500; opacity: 0.95; line-height: 1; white-space: nowrap; } .header-weather ha-icon { --mdc-icon-size: 28px; } .add-event-button { background: var(--header-control-bg, rgba(255, 255, 255, 0.2)); border: none; color: inherit; padding: 8px 16px; border-radius: 8px; cursor: pointer; font-size: 13px; font-weight: 500; display: inline-flex; align-items: center; gap: 8px; transition: background 0.2s; } .add-event-button:hover { background: var(--header-control-bg-hover, rgba(255, 255, 255, 0.3)); border-color: var(--header-control-border-hover, rgba(255, 255, 255, 0.6)); transform: none; } .add-event-button .icon { font-size: 14px; } .header-controls { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; } .compact-header-controls { justify-content: flex-end; } .period-controls, .compact-period-controls { display: flex; align-items: center; gap: 12px; flex: 0 1 auto; margin-left: auto; } .header-controls.is-wrapped { justify-content: center; } .compact-header-controls.is-wrapped { justify-content: center; } .compact-header-controls.is-wrapped .compact-period-controls { margin-left: 0; } .view-mode-buttons { display: inline-flex; align-items: center; background: var(--header-control-bg, rgba(255, 255, 255, 0.2)); border-radius: 8px; padding: 0 10px; margin-left: 8px; position: relative; } .view-mode-buttons::after { content: "⌄"; font-size: 13px; pointer-events: none; margin-left: 8px; opacity: 0.8; } .view-mode-select { appearance: none; -webkit-appearance: none; -moz-appearance: none; background: transparent; border: none; color: inherit; padding: 8px 0; padding-right: 2px; cursor: pointer; font-size: 13px; font-weight: 500; line-height: 1; min-width: 78px; } .view-mode-select:focus { outline: none; } .view-mode-select option { color: #111827; background: #ffffff; } .nav-button { background: var(--header-control-bg, rgba(255, 255, 255, 0.2)); border: none; color: inherit; width: 36px; height: 36px; border-radius: 8px; cursor: pointer; font-size: 18px; display: flex; align-items: center; justify-content: center; transition: background 0.2s; } .dashboard-nav-button { background: var(--header-control-bg, rgba(255, 255, 255, 0.2)); border: 1px solid var(--header-control-border, rgba(255, 255, 255, 0.4)); color: inherit; width: 36px; height: 36px; border-radius: 8px; cursor: pointer; font-size: 18px; display: inline-flex; align-items: center; justify-content: center; transition: background 0.2s, border-color 0.2s; line-height: 1; } .dashboard-nav-button:hover { background: var(--header-control-bg-hover, rgba(255, 255, 255, 0.3)); border-color: var(--header-control-border-hover, rgba(255, 255, 255, 0.6)); } .nav-button:hover { background: var(--header-control-bg-hover, rgba(255, 255, 255, 0.3)); } .nav-button:disabled { opacity: 0.45; cursor: not-allowed; } .nav-button:disabled:hover { background: var(--header-control-bg, rgba(255, 255, 255, 0.2)); } .today-button { background: var(--header-control-bg, rgba(255, 255, 255, 0.2)); border: none; color: inherit; padding: 8px 16px; border-radius: 8px; cursor: pointer; font-size: 14px; font-weight: 500; transition: background 0.2s; } .today-button:hover { background: var(--header-control-bg-hover, rgba(255, 255, 255, 0.3)); } .theme-toggle { width: 30px; height: 30px; border-radius: 8px; border: 1px solid var(--header-control-border, rgba(255, 255, 255, 0.4)); background: var(--header-control-bg, rgba(255, 255, 255, 0.2)); color: inherit; display: inline-flex; align-items: center; justify-content: center; cursor: pointer; font-size: 14px; line-height: 1; transition: all 0.2s; } .theme-toggle:hover, .compact-add-event-button:hover { background: var(--header-control-bg-hover, rgba(255, 255, 255, 0.3)); border-color: var(--header-control-border-hover, rgba(255, 255, 255, 0.6)); } .compact-add-event-button { width: 30px; height: 30px; border-radius: 8px; border: 1px solid var(--header-control-border, rgba(255, 255, 255, 0.4)); background: var(--header-control-bg, rgba(255, 255, 255, 0.2)); color: inherit; display: inline-flex; align-items: center; justify-content: center; cursor: pointer; font-size: 18px; line-height: 1; transition: all 0.2s; padding: 0; } .month-year { font-size: 18px; font-weight: 500; color: inherit; min-width: 210px; text-align: center; } .calendar-container.hide-year .month-year { min-width: 145px; } .calendar-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 1px; background: #e5e7eb; border-top: 1px solid #e5e7eb; flex: 1 1 auto; min-height: 0; overflow: auto; } .calendar-grid.month-week-numbers { grid-template-columns: 28px repeat(7, 1fr); } .month-week-number-header { background: #f9fafb; } .month-week-number-cell { background: #f9fafb; color: #6b7280; display: flex; align-items: center; justify-content: center; padding: 4px 0; } .month-week-number-text { color: inherit; font-size: 11px; font-weight: 600; line-height: 1; transform: rotate(-90deg); white-space: nowrap; } .calendar-grid.compact-month { align-items: stretch; } .calendar-grid.compact-month .day-cell { min-height: 0; overflow: hidden; } .day-header { background: #f9fafb; padding: 12px 8px; text-align: center; font-weight: 600; font-size: 12px; text-transform: uppercase; color: #6b7280; letter-spacing: 0.5px; } .day-cell { background: white; min-height: 100px; min-width: 0; padding: 8px; position: relative; cursor: pointer; transition: background 0.2s; } .day-cell:hover { background: #f9fafb; } .day-cell.other-month { background: #fafafa; opacity: 0.5; } .day-cell.today { background: #eff6ff; } .day-cell.day-style-has-background, .week-day-column.day-style-has-background, .week-standard-day-column.day-style-has-background, .agenda-day-row.day-style-has-background { background: var(--day-conditional-background) !important; } .day-cell.day-style-has-background:hover { background: var(--day-conditional-background) !important; } .week-day-column.day-style-has-background .week-day-header, .week-standard-day-column.day-style-has-background .week-standard-day-header, .week-standard-day-column.day-style-has-background .all-day-events, .week-standard-day-column.day-style-has-background .day-time-slot, .agenda-day-row.day-style-has-background .agenda-day-label { background: transparent !important; } .day-cell.day-style-has-border, .week-day-column.day-style-has-border, .week-standard-day-column.day-style-has-border, .agenda-day-row.day-style-has-border { position: relative; } .day-cell.day-style-has-border::after, .week-day-column.day-style-has-border::after, .week-standard-day-column.day-style-has-border::after, .agenda-day-row.day-style-has-border::after { content: ''; position: absolute; inset: 0; border: var(--day-style-border-width, 2px) solid var(--day-style-border-color, var(--divider-color, #d1d5db)); border-radius: inherit; box-sizing: border-box; pointer-events: none; z-index: 2; } .day-number { font-size: 14px; font-weight: 600; color: #374151; margin-bottom: 4px; } .day-badges { display: inline-flex; align-items: center; gap: 4px; margin-left: auto; max-width: 100%; overflow: hidden; } .day-badge { width: var(--dcc-day-badge-size, 30px); height: var(--dcc-day-badge-size, 30px); min-width: var(--dcc-day-badge-size, 30px); max-width: 100%; border-radius: 999px; display: inline-flex; align-items: center; justify-content: center; font-size: var(--dcc-day-badge-font-size, 12px); font-weight: 700; line-height: 1; background: var(--dcc-day-badge-background, var(--primary-color)); color: var(--dcc-day-badge-color, var(--text-primary-color, #fff)); overflow: hidden; box-sizing: border-box; } .day-badge.has-text { width: auto; gap: 4px; padding: 0 8px; } .day-badge ha-icon { --mdc-icon-size: calc(var(--dcc-day-badge-size, 30px) * 0.53); color: inherit; flex: 0 0 auto; } .day-badge-text { display: block; min-width: 0; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .day-header-row { display: flex; align-items: flex-start; justify-content: space-between; gap: 6px; margin-bottom: 4px; min-height: 42px; } .day-header-row .day-number { margin-bottom: 0; } .month-day-forecast { display: inline-flex; align-items: center; gap: 4px; } .month-day-forecast .forecast-condition { font-size: 14px; } .month-day-forecast .forecast-condition ha-icon { --mdc-icon-size: 14px; } .month-day-forecast .forecast-temperatures { font-size: 12px; gap: 2px; } .day-cell.today .day-number { background: #3b82f6; color: white; width: 28px; height: 28px; border-radius: 50%; display: flex; align-items: center; justify-content: center; margin-bottom: 4px; } .event { background: #3b82f6; color: var(--event-bubble-text-color, white); display: block; width: 100%; max-width: 100%; padding: 4px 6px 4px calc(6px + var(--combine-left-offset, 0px)); border-radius: 4px; font-size: var(--event-bubble-font-size, 11px); line-height: 1.2; margin-bottom: 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; box-sizing: border-box; cursor: pointer; transition: transform 0.2s, box-shadow 0.2s; font-weight: 500; position: relative; padding-bottom: calc(4px + (var(--combined-corner-bubbles, 0) * 14px)); } .event:hover { transform: translateY(-1px); box-shadow: 0 2px 4px rgba(0,0,0,0.2); } .event-time { font-size: var(--event-time-font-size, 9px); opacity: 0.9; margin-right: 4px; } .more-events { font-size: 10px; color: #6b7280; margin-top: 2px; font-weight: 500; cursor: pointer; width: fit-content; } .more-events:hover { text-decoration: underline; } .week-compact-container.single-day-modal { grid-template-columns: 1fr; border-top: none; background: transparent; } /* Week Compact View Styles */ .week-compact-container { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 1px; background: #e5e7eb; border-top: 1px solid #e5e7eb; flex: 1 1 auto; min-height: 0; overflow: auto; } .week-day-column { background: white; padding: 16px 12px; min-height: 200px; } .week-day-header { text-align: center; margin-bottom: 12px; padding-bottom: 12px; border-bottom: 2px solid #e5e7eb; display: flex; flex-direction: column; align-items: center; } .week-day-header-main { display: flex; flex-direction: column; align-items: center; width: 100%; } .week-day-meta-row { display: inline-flex; align-items: center; justify-content: center; gap: 10px; margin-top: 2px; min-height: 32px; } .week-day-forecast, .week-standard-day-forecast, .agenda-day-forecast { display: inline-flex; align-items: center; justify-content: center; gap: 6px; margin-top: 0; line-height: 1; } .forecast-condition { display: inline-flex; font-size: 16px; } .forecast-condition ha-icon { --mdc-icon-size: 20px; } .forecast-temperatures { display: inline-flex; flex-direction: column; align-items: flex-start; gap: 3px; font-size: 14px; color: #374151; } .forecast-temp-low { opacity: 0.75; } .week-day-name { font-size: 12px; font-weight: 600; text-transform: uppercase; color: #6b7280; letter-spacing: 0.5px; } .week-day-date { font-size: 24px; font-weight: 700; color: #111827; margin-top: 0; line-height: 1; } .week-day-column.today .week-day-header { border-bottom-color: #3b82f6; } .week-day-column.today .week-day-date { color: #3b82f6; } .week-compact-event { background: #3b82f6; color: var(--event-bubble-text-color, white); font-size: var(--event-bubble-font-size, 11px); padding: 8px 10px 8px calc(10px + var(--combine-left-offset, 0px)); border-radius: 6px; margin-bottom: 8px; cursor: pointer; transition: transform 0.2s, box-shadow 0.2s; position: relative; padding-bottom: calc(8px + (var(--combined-corner-bubbles, 0) * 14px)); } .week-compact-event:hover { transform: translateX(2px); box-shadow: 0 2px 8px rgba(0,0,0,0.15); } .week-compact-event-time { font-size: var(--event-time-font-size, 9px); font-weight: 600; opacity: 0.9; margin-bottom: 4px; } .week-compact-event-title { font-size: 1em; font-weight: 500; line-height: 1.3; } .event-title-with-prefix { display: inline-flex; align-items: center; gap: clamp(4px, calc(var(--event-bubble-font-size, 11px) * 0.3), 7px); min-width: 0; } .event-style-icon { color: inherit; flex: 0 0 auto; } .event-style-icon-before-title { --mdc-icon-size: var(--event-style-icon-size, 1em); width: var(--mdc-icon-size); height: var(--mdc-icon-size); font-size: var(--mdc-icon-size); line-height: 1; } .event-style-icon-corner { --mdc-icon-size: var(--event-style-icon-size, var(--event-bubble-font-size, 14px)); position: absolute; right: 6px; bottom: 4px; width: var(--mdc-icon-size); height: var(--mdc-icon-size); font-size: var(--mdc-icon-size); line-height: 1; pointer-events: none; z-index: 2; } .event-title-prefix-friendly-name { font-size: 0.9em; font-weight: 600; opacity: 0.95; white-space: nowrap; } .event-title-prefix-badge { --event-title-prefix-size: clamp(9px, calc(var(--event-bubble-font-size, 11px) * 0.8), 15px); display: inline-flex; align-items: center; justify-content: center; width: var(--event-title-prefix-size); height: var(--event-title-prefix-size); border-radius: 50%; overflow: hidden; flex: 0 0 auto; line-height: 1; } .event-title-prefix-badges { display: inline-flex; align-items: center; gap: clamp(2px, calc(var(--event-bubble-font-size, 11px) * 0.15), 4px); flex: 0 0 auto; } .event-title-prefix-badge ha-icon { --mdc-icon-size: calc(var(--event-title-prefix-size) * 0.78); font-size: var(--mdc-icon-size); width: var(--mdc-icon-size); height: var(--mdc-icon-size); line-height: 1; display: inline-flex; align-items: center; justify-content: center; color: inherit; } .event-title-prefix-badge span { font-size: calc(var(--event-title-prefix-size) * 0.62); font-weight: 600; line-height: 1; } .event-title-prefix-badge img { width: 100%; height: 100%; object-fit: cover; } .week-compact-event-location { font-size: var(--event-location-font-size, 9px); opacity: 0.9; margin-top: 4px; line-height: 1.3; white-space: normal; overflow-wrap: anywhere; word-break: break-word; } .agenda-container { display: flex; flex-direction: column; gap: 8px; overflow-y: auto; padding-right: 4px; flex: 1 1 auto; min-height: 0; } .agenda-day-row { display: grid; grid-template-columns: 88px 1fr; gap: 12px; border-top: 1px solid #e5e7eb; padding-top: 8px; } .agenda-month-banner { width: 100%; border-top: 2px solid #d1d5db; border-bottom: 1px solid #d1d5db; color: #4b5563; font-size: 24px; font-weight: 700; letter-spacing: 0.08em; text-align: center; display: flex; align-items: center; justify-content: center; min-height: 48px; padding: 16px 0; margin-top: 6px; } .agenda-day-label { text-align: center; margin-bottom: 12px; padding-bottom: 12px; border-bottom: 2px solid #e5e7eb; } .agenda-day-weekday { font-size: 12px; font-weight: 600; text-transform: uppercase; color: #6b7280; letter-spacing: 0.5px; } .agenda-day-date { font-size: 24px; font-weight: 700; color: #111827; margin-top: 4px; } .agenda-day-forecast { margin-top: 6px; } .agenda-day-forecast .forecast-condition ha-icon { --mdc-icon-size: 18px; } .agenda-day-forecast .forecast-temperatures { font-size: 11px; } .agenda-day-row.today .agenda-day-label { border-bottom-color: #3b82f6; } .agenda-day-events { display: flex; flex-direction: column; gap: 8px; } .agenda-event { border-radius: 8px; padding: 10px 64px 10px calc(12px + var(--combine-left-offset, 0px)); cursor: pointer; overflow: hidden; color: var(--event-bubble-text-color, white); position: relative; height: var(--agenda-event-min-height, 68px); box-sizing: border-box; padding-bottom: calc(10px + (var(--combined-corner-bubbles, 0) * 16px)); } .agenda-event-time { font-size: var(--event-time-font-size, 10px); font-weight: 600; margin-bottom: 4px; min-height: 1.2em; } .agenda-event-title { font-size: var(--event-bubble-font-size, 16px); font-weight: 700; min-height: 1.2em; line-height: 1.25; } .agenda-event-location { font-size: var(--event-location-font-size, 9px); opacity: 0.95; margin-top: 4px; line-height: 1.3; white-space: normal; overflow-wrap: anywhere; word-break: break-word; min-height: 1.2em; } .agenda-event .week-standard-event-icons { position: absolute; top: 10px; right: 10px; margin-top: 0; } .calendar-container.agenda-compact-events .agenda-event { display: flex; flex-direction: row; flex-wrap: wrap; align-items: baseline; gap: 0 8px; height: auto; padding: 8px 64px 8px calc(12px + var(--combine-left-offset, 0px)); padding-bottom: calc(8px + (var(--combined-corner-bubbles, 0) * 16px)); } .calendar-container.agenda-compact-events .agenda-event-title { flex: 1 1 auto; min-height: unset; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .calendar-container.agenda-compact-events .agenda-event-time { flex: 0 0 auto; min-height: unset; margin-bottom: 0; opacity: 0.9; white-space: nowrap; } .calendar-container.agenda-compact-events .agenda-event-location { flex: 0 0 100%; min-height: unset; } .calendar-container.agenda-compact-events .agenda-day-date { font-size: 16px; } .calendar-container.agenda-compact-events .agenda-day-label { margin-bottom: 2px; padding-bottom: 2px; } .calendar-container.agenda-compact-events .agenda-month-banner { font-size: 18px; min-height: 36px; padding: 8px 0; } .agenda-empty-day { color: #9ca3af; font-size: 12px; padding: 8px 0; } /* Week Standard View Styles */ .calendar-badges-container { position: relative; } .calendar-badges { padding: 16px 24px; display: flex; gap: 12px; flex-wrap: nowrap; overflow-x: auto; overflow-y: hidden; -webkit-overflow-scrolling: touch; scrollbar-width: thin; background: white; border-bottom: 1px solid #e5e7eb; } .calendar-badges-container.has-overflow::after, .calendar-badges-container.has-overflow::before { position: absolute; top: 0; bottom: 1px; width: 44px; pointer-events: none; display: flex; align-items: center; justify-content: center; font-size: 24px; font-weight: 800; text-shadow: 0 0 8px rgba(255, 255, 255, 0.85); opacity: 0; transition: opacity 0.2s ease; } .calendar-badges-container.has-overflow::after { content: '»'; right: 0; color: rgba(17, 24, 39, 0.85); background: linear-gradient(to left, rgba(255, 255, 255, 1), rgba(255, 255, 255, 0)); } .calendar-badges-container.has-overflow::before { content: '«'; left: 0; color: rgba(17, 24, 39, 0.75); background: linear-gradient(to right, rgba(255, 255, 255, 1), rgba(255, 255, 255, 0)); } .calendar-badges-container.show-right-indicator::after, .calendar-badges-container.show-left-indicator::before { opacity: 1; } .calendar-badges-container.show-right-indicator::after { animation: badges-overflow-nudge-right 1.2s ease-in-out infinite; } .calendar-badges-container.show-left-indicator::before { animation: badges-overflow-nudge-left 1.2s ease-in-out infinite; } @keyframes badges-overflow-nudge-right { 0%, 100% { transform: translateX(0); } 50% { transform: translateX(3px); } } @keyframes badges-overflow-nudge-left { 0%, 100% { transform: translateX(0); } 50% { transform: translateX(-3px); } } .calendar-badge { display: inline-flex; align-items: center; flex: 0 0 auto; gap: 8px; padding: 8px 16px; border-radius: 20px; border: 2px solid; font-size: 13px; font-weight: 500; cursor: pointer; transition: all 0.2s; user-select: none; } .calendar-badge:hover { transform: translateY(-1px); box-shadow: 0 2px 8px rgba(0,0,0,0.1); } .calendar-badge-hidden { opacity: 0.5; } .calendar-badge-icon { width: 24px; height: 24px; border-radius: 50%; display: flex; align-items: center; justify-content: center; color: var(--skylight-calendar-filter-icon-color, white); font-size: 11px; font-weight: 600; overflow: hidden; } .calendar-badge:not(.hide-calendar-name) .calendar-badge-person-icon { width: 32px; height: 32px; flex: 0 0 32px; font-size: 13px; } .calendar-badge-inline:not(.hide-calendar-name) .calendar-badge-person-icon { width: 28px !important; height: 28px !important; flex-basis: 28px; font-size: 12px !important; } .calendar-badge-icon ha-icon { --mdc-icon-size: 14px; color: inherit; } .calendar-badge:not(.hide-calendar-name) .calendar-badge-person-icon ha-icon { --mdc-icon-size: 18px; } .calendar-badge-photo img { width: 100%; height: 100%; object-fit: cover; display: block; } .calendar-badge-label { display: flex; flex-direction: column; min-width: 0; line-height: 1.15; } .calendar-badge-name { color: inherit; } .calendar-badge-person-state { font-size: 11px; font-weight: 400; opacity: 0.82; margin-top: 2px; white-space: nowrap; } .week-standard-container { --week-standard-column-gap: 12px; --week-standard-bridge-overlap: 2px; display: flex; align-items: flex-start; background: #f9fafb; overflow: auto; padding: 16px; gap: var(--week-standard-column-gap); width: 100%; flex: 1 1 auto; min-height: 0; box-sizing: border-box; } .time-column { min-width: 60px; flex-shrink: 0; position: relative; background: transparent; } .time-column-header-spacer { height: 60px; background: transparent; flex-shrink: 0; } .time-column-allday-spacer { padding: 8px; background: transparent; border-bottom: 2px solid transparent; flex-shrink: 0; box-sizing: border-box; } .time-column-extra-spacer { height: 35px; background: transparent; flex-shrink: 0; } .time-slot { height: 120px; font-size: 11px; color: #9ca3af; text-align: right; font-weight: 500; position: relative; padding-right: 8px; display: flex; align-items: flex-start; padding-top: 0; box-sizing: border-box; border-top: 1px solid transparent; } .time-slot-label { position: absolute; top: -6px; right: 8px; line-height: 1; } .week-standard-day-column { flex: 1; min-width: 140px; background: white; border-radius: 8px; overflow: visible; box-shadow: 0 1px 3px rgba(0,0,0,0.1); } .week-standard-container.compact-width .week-standard-day-column { min-width: 0; } .week-standard-day-header { padding: 16px; text-align: center; border-bottom: 1px solid #e5e7eb; background: white; display: flex; flex-direction: column; align-items: center; } .week-standard-day-name { font-size: 12px; font-weight: 600; text-transform: uppercase; color: #6b7280; letter-spacing: 0.5px; } .week-standard-day-date { font-size: 24px; font-weight: 700; color: #111827; margin-top: 0; width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; flex: 0 0 auto; line-height: 1; position: relative; z-index: 1; } .week-standard-day-column.today .week-standard-day-date { background: #3b82f6; color: white; border-radius: 50%; } .day-time-slots { position: relative; min-height: 600px; } .all-day-events { padding: 8px; background: #f9fafb; border-bottom: 2px solid #e5e7eb; display: flex; flex-direction: column; gap: 4px; box-sizing: border-box; overflow: visible; } .all-day-event { padding: 4px 8px 4px calc(8px + var(--combine-left-offset, 0px)); color: var(--event-bubble-text-color, white); border-radius: 6px; cursor: pointer; transition: transform 0.2s, box-shadow 0.2s; font-size: var(--event-bubble-font-size, 11px); flex-shrink: 0; height: 24px; display: flex; align-items: center; box-sizing: border-box; overflow: visible; position: relative; } .all-day-event.continues-prev { border-top-left-radius: 0; border-bottom-left-radius: 0; } .all-day-event.continues-next { border-top-right-radius: 0; border-bottom-right-radius: 0; } .all-day-event.bridge-prev { margin-left: calc(-1 * (var(--week-standard-column-gap) + var(--week-standard-bridge-overlap))); padding-left: calc(8px + var(--week-standard-column-gap) + var(--week-standard-bridge-overlap) + var(--combine-left-offset, 0px)); } .all-day-event.bridge-next { margin-right: calc(-1 * (var(--week-standard-column-gap) + var(--week-standard-bridge-overlap))); padding-right: calc(8px + var(--week-standard-column-gap) + var(--week-standard-bridge-overlap)); } .all-day-event-spacer { height: 24px; flex-shrink: 0; } .all-day-event:hover { transform: translateY(-1px); box-shadow: 0 2px 8px rgba(0,0,0,0.15); } .all-day-event.leading-span-title { z-index: 2; } .all-day-event-title { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: inherit; display: block; min-width: 0; max-width: 100%; flex: 1 1 auto; } .all-day-event-title.spans-multiple-days { position: absolute; top: 50%; left: calc(8px + var(--combine-left-offset, 0px)); transform: translateY(-50%); width: calc(((100% * var(--all-day-title-span-days, 1)) + ((var(--week-standard-column-gap) + var(--week-standard-bridge-overlap)) * var(--all-day-title-gap-count, 0))) - (24px + var(--combine-left-offset, 0px))); max-width: calc(((100% * var(--all-day-title-span-days, 1)) + ((var(--week-standard-column-gap) + var(--week-standard-bridge-overlap)) * var(--all-day-title-gap-count, 0))) - (24px + var(--combine-left-offset, 0px))); overflow: hidden; text-overflow: ellipsis; z-index: 1; pointer-events: none; } .day-time-slot { height: 120px; border-top: 1px solid var(--schedule-hour-line-color, #e5e7eb); position: relative; box-sizing: border-box; cursor: pointer; transition: background 0.2s; } .day-time-slot:hover { background: rgba(59, 130, 246, 0.05); } .week-standard-event { position: absolute; left: 8px; right: 8px; color: var(--event-bubble-text-color, white); padding: 4px 8px 4px calc(8px + var(--combine-left-offset, 0px)); border-radius: 8px; font-size: var(--event-bubble-font-size, 11px); overflow: hidden; cursor: pointer; transition: transform 0.2s, box-shadow 0.2s; z-index: 1; box-shadow: 0 1px 3px rgba(0,0,0,0.1); box-sizing: border-box; padding-bottom: calc(4px + (var(--combined-corner-bubbles, 0) * 14px)); } .week-standard-event:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.15); z-index: 10; } .week-standard-event-title { font-weight: 600; margin-bottom: 4px; line-height: 1.3; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } .week-standard-event-time { font-size: var(--event-time-font-size, 9px); font-weight: 500; opacity: 0.85; } .week-standard-event-location { font-size: var(--event-location-font-size, 9px); opacity: 0.9; margin-top: 4px; line-height: 1.3; white-space: normal; overflow-wrap: anywhere; word-break: break-word; } .week-standard-event-calendar-name { font-size: 10px; font-weight: 600; opacity: 0.9; } .week-standard-event-icons { display: flex; justify-content: flex-end; gap: 4px; margin-top: 4px; } .week-standard-event-icon { width: 20px; height: 20px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; font-size: 10px; flex-shrink: 0; } .combined-corner-bubbles { position: absolute; right: 6px; bottom: 4px; display: inline-flex; align-items: center; justify-content: flex-end; gap: 2px; pointer-events: none; } .combined-corner-bubble { width: 14px; height: 14px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; font-size: 8px; font-weight: 700; line-height: 1; box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.7); } .current-time-line { position: absolute; left: 0; right: 0; height: 2px; background: #ef4444; z-index: 5; pointer-events: none; } .current-time-line::before { content: ''; position: absolute; width: 8px; height: 8px; border-radius: 50%; background: #ef4444; left: -4px; top: -3px; } daylight-calendar-card.event-modal-open .calendar-container, daylight-calendar-card.event-modal-open .calendar-body, skylight-calendar-card.event-modal-open .calendar-container, skylight-calendar-card.event-modal-open .calendar-body { overflow: visible; } .event-modal { display: none; position: fixed; inset: 0; background: rgba(0, 0, 0, 0.5); z-index: 2147483647; align-items: center; justify-content: center; box-sizing: border-box; padding: 16px; } .event-modal.show { display: flex; } .modal-content { background: white; border-radius: 12px; padding: 24px; max-width: 500px; width: 90%; max-height: 80vh; max-height: min(80vh, calc(100dvh - 32px)); overflow-y: auto; box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1); } .modal-content.modal-size-narrow { box-sizing: border-box; max-width: 380px; width: min(90%, 380px); } .modal-content.modal-size-medium { max-width: 500px; width: 90%; } .modal-content.modal-size-wide { box-sizing: border-box; max-width: 760px; width: min(94%, 760px); } .modal-content.modal-size-full { box-sizing: border-box; max-width: none; width: calc(100vw - 32px); max-height: calc(100dvh - 32px); } .modal-content.modal-size-narrow > .confirm-dialog, .modal-content.modal-size-wide > .confirm-dialog, .modal-content.modal-size-full > .confirm-dialog { box-sizing: border-box; max-width: none; width: 100%; } @media (max-width: 480px) { .modal-content, .modal-content.modal-size-narrow, .modal-content.modal-size-medium, .modal-content.modal-size-wide, .modal-content.modal-size-full { width: calc(100vw - 24px); max-width: calc(100vw - 24px); max-height: calc(100dvh - 24px); padding: 16px; box-sizing: border-box; } } .modal-header { display: flex; justify-content: space-between; align-items: start; margin-bottom: 16px; } .modal-title { font-size: 20px; font-weight: 600; color: #111827; margin: 0; } .modal-close { background: none; border: none; font-size: 24px; cursor: pointer; color: #6b7280; padding: 0; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; border-radius: 6px; transition: background 0.2s; } .modal-close:hover { background: #f3f4f6; } .modal-body { color: #374151; } .modal-row { display: flex; gap: 12px; margin-bottom: 12px; font-size: 14px; } .modal-label { font-weight: 600; min-width: 80px; color: #6b7280; } .modal-value { flex: 1; } .modal-row-description { align-items: flex-start; } .event-description-content { line-height: 1.5; overflow-wrap: anywhere; } .event-description-content > :first-child { margin-top: 0; } .event-description-content > :last-child { margin-bottom: 0; } .event-description-content p, .event-description-content ul, .event-description-content ol, .event-description-content blockquote, .event-description-content pre { margin: 0 0 10px; } .event-description-content ul, .event-description-content ol { padding-left: 20px; } .event-description-content li + li { margin-top: 4px; } .event-description-content h1, .event-description-content h2, .event-description-content h3, .event-description-content h4, .event-description-content h5, .event-description-content h6 { margin: 0 0 8px; font-weight: 700; line-height: 1.25; color: #111827; } .event-description-content h1 { font-size: 20px; } .event-description-content h2 { font-size: 18px; } .event-description-content h3 { font-size: 16px; } .event-description-content h4, .event-description-content h5, .event-description-content h6 { font-size: 14px; } .event-description-content blockquote { border-left: 3px solid #d1d5db; color: #4b5563; padding-left: 10px; } .event-description-content code { background: #f3f4f6; border-radius: 4px; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 0.9em; padding: 1px 4px; } .event-description-content pre { background: #f3f4f6; border-radius: 8px; overflow-x: auto; padding: 10px; white-space: pre-wrap; } .event-description-content pre code { background: transparent; padding: 0; } .event-description-content a { color: #2563eb; text-decoration: underline; } #create-event-form, #edit-event-form { display: flex; flex-direction: column; gap: 18px; } .form-group, .form-group-inline { margin-bottom: 0; } .form-label { display: block; font-size: 14px; font-weight: 600; color: #374151; margin-bottom: 8px; } .form-group-inline .form-label { margin-bottom: 0; } .form-inline-row { display: grid; grid-template-columns: 120px minmax(0, 1fr); gap: 12px; align-items: center; } .form-inline-row.form-inline-row-top { align-items: start; } .form-required { color: #ef4444; margin-left: 4px; } .form-input { width: 100%; padding: 10px 12px; border: 2px solid #e5e7eb; border-radius: 8px; font-size: 14px; font-family: inherit; transition: border-color 0.2s; box-sizing: border-box; } .form-input:focus { outline: none; border-color: #3b82f6; } .form-input.error { border-color: #ef4444; } .form-select { width: 100%; padding: 10px 12px; border: 2px solid #e5e7eb; border-radius: 8px; font-size: 14px; font-family: inherit; background: white; cursor: pointer; transition: border-color 0.2s; box-sizing: border-box; } .form-select:focus { outline: none; border-color: #3b82f6; } .form-textarea { width: 100%; padding: 10px 12px; border: 2px solid #e5e7eb; border-radius: 8px; font-size: 14px; font-family: inherit; min-height: 80px; resize: vertical; transition: border-color 0.2s; box-sizing: border-box; } .form-textarea:focus { outline: none; border-color: #3b82f6; } .form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } .form-checkbox-group { display: flex; align-items: center; gap: 8px; } .form-checkbox-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 10px 14px; } .form-checkbox-row { display: flex; flex-wrap: wrap; gap: 18px; margin-bottom: 0; } .form-checkbox-row .form-group { margin-bottom: 0; } .form-checkbox { width: 20px; height: 20px; cursor: pointer; } .form-checkbox-label { font-size: 14px; color: #374151; cursor: pointer; user-select: none; } .recurrence-ends-label { margin-bottom: 10px; } .recurrence-end-row { display: grid; grid-template-columns: 110px minmax(0, 1fr); gap: 12px; align-items: center; margin-bottom: 10px; } .recurrence-end-option { display: inline-flex; align-items: center; gap: 8px; font-size: 14px; color: #374151; cursor: pointer; } .recurrence-end-option input[type="radio"] { margin: 0; } .recurrence-end-row .form-input { margin: 0; } .recurrence-after-input { display: flex; align-items: center; gap: 10px; } .recurrence-after-input .form-input { max-width: 80px; } .form-error { color: #ef4444; font-size: 13px; margin-top: 4px; } .form-actions { display: flex; gap: 12px; justify-content: flex-end; margin-top: 6px; } .btn { padding: 10px 20px; border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.2s; border: none; font-family: inherit; } .btn-primary { background: #3b82f6; color: white; } .btn-primary:hover { background: #2563eb; transform: translateY(-1px); } .btn-primary:disabled { background: #9ca3af; cursor: not-allowed; transform: none; } .btn-secondary { background: #f3f4f6; color: #374151; } .btn-secondary:hover { background: #e5e7eb; } .btn-danger { background: #ef4444; color: white; } .btn-danger:hover { background: #dc2626; transform: translateY(-1px); } .btn-danger:disabled { background: #fca5a5; cursor: not-allowed; transform: none; } .modal-actions { display: flex; gap: 12px; justify-content: space-between; margin-top: 24px; align-items: center; } .modal-actions-left { display: flex; gap: 12px; } .modal-actions-right { display: flex; gap: 12px; } .confirm-dialog { background: white; border-radius: 12px; padding: 24px; max-width: 400px; width: 90%; box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1); } .confirm-title { font-size: 18px; font-weight: 600; color: #111827; margin: 0 0 12px 0; } .confirm-message { font-size: 14px; color: #6b7280; margin-bottom: 20px; line-height: 1.5; } .confirm-actions { display: flex; gap: 12px; justify-content: flex-end; } .recurring-options { background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 8px; padding: 16px; margin-bottom: 16px; } .recurring-option { display: flex; align-items: center; gap: 12px; padding: 12px; cursor: pointer; border-radius: 6px; transition: background 0.2s; margin-bottom: 8px; } .recurring-option:hover { background: #f3f4f6; } .recurring-option.disabled-option { cursor: not-allowed; opacity: 0.72; } .recurring-option.disabled-option:hover { background: transparent; } .recurring-option:last-child { margin-bottom: 0; } .recurring-option input[type="radio"] { width: 18px; height: 18px; cursor: pointer; } .recurring-option-label { flex: 1; } .recurring-option-title { font-weight: 600; color: #111827; font-size: 14px; margin-bottom: 2px; } .recurring-option-description { font-size: 13px; color: #6b7280; } .error-message { background: #fef2f2; border: 1px solid #fecaca; color: #991b1b; padding: 12px 16px; border-radius: 8px; margin-bottom: 16px; font-size: 14px; } .success-message { background: #f0fdf4; border: 1px solid #bbf7d0; color: #166534; padding: 12px 16px; border-radius: 8px; margin-bottom: 16px; font-size: 14px; } .empty-state { padding: 40px 24px; text-align: center; color: #6b7280; } .empty-state-icon { font-size: 48px; margin-bottom: 16px; opacity: 0.5; } .empty-state-text { font-size: 16px; font-weight: 500; margin-bottom: 8px; } .empty-state-subtext { font-size: 14px; opacity: 0.8; } .day-modal-event { margin-bottom: 16px; padding: 12px; border-radius: 4px; cursor: pointer; color: var(--event-bubble-text-color, white); position: relative; padding-bottom: calc(12px + (var(--combined-corner-bubbles, 0) * 16px)); } .day-modal-event-title { font-weight: 600; margin-bottom: 4px; } .day-modal-event-meta { font-size: 13px; color: inherit; opacity: 0.9; } .day-modal-event-location { font-size: 13px; color: inherit; opacity: 0.9; margin-top: 4px; } .info-banner { border-radius: 8px; padding: 12px; margin-top: 16px; font-size: 13px; } .info-banner.warning { background: #fef3c7; border: 1px solid #fbbf24; color: #92400e; } .calendar-container.dark-mode { color: #e8ecf1; color-scheme: dark; --schedule-hour-line-color: #556070; } .calendar-container.dark-mode, .calendar-container.dark-mode input, .calendar-container.dark-mode select, .calendar-container.dark-mode textarea, .calendar-container.dark-mode button { color-scheme: dark; } .calendar-container.dark-mode .week-standard-container, .calendar-container.dark-mode .calendar-badges { background: #30363f; border-color: #4b5563; } .calendar-container.dark-mode .calendar-badges-container.has-overflow::after { color: rgba(248, 250, 252, 0.95); text-shadow: 0 0 10px rgba(17, 24, 39, 0.75); background: linear-gradient(to left, rgba(48, 54, 63, 1), rgba(48, 54, 63, 0)); } .calendar-container.dark-mode .calendar-badges-container.has-overflow::before { color: rgba(248, 250, 252, 0.9); text-shadow: 0 0 10px rgba(17, 24, 39, 0.75); background: linear-gradient(to right, rgba(48, 54, 63, 1), rgba(48, 54, 63, 0)); } .calendar-container.dark-mode .day-cell, .calendar-container.dark-mode .week-day-column, .calendar-container.dark-mode .week-day-header, .calendar-container.dark-mode .week-standard-day-column, .calendar-container.dark-mode .week-standard-day-header, .calendar-container.dark-mode .all-day-events, .calendar-container.dark-mode .day-time-slot, .calendar-container.dark-mode .time-slot, .calendar-container.dark-mode .time-slot-label, .calendar-container.dark-mode .empty-state { background: #353c45; color: #dde3ea; border-color: #556070; } .calendar-container.dark-mode .time-slot { background: inherit; color: #dde3ea; border-top-color: transparent; } .calendar-container.dark-mode .week-standard-day-header, .calendar-container.dark-mode .all-day-events { border-bottom-color: transparent; } .calendar-container.dark-mode .day-header, .calendar-container.dark-mode .month-week-number-header, .calendar-container.dark-mode .month-week-number-cell { background: #353b42; color: #dde3ea; border-color: #556070; } .calendar-container.dark-mode .week-day-column.today .week-day-header { border-bottom-color: #3b82f6; } .calendar-container.dark-mode .week-standard-day-name, .calendar-container.dark-mode .week-standard-day-date, .calendar-container.dark-mode .week-day-name, .calendar-container.dark-mode .week-day-date { background: #3b434d; color: #dde3ea; border-color: #556070; } .calendar-container.dark-mode .agenda-day-weekday, .calendar-container.dark-mode .agenda-day-date { background: transparent; color: #dde3ea; } .calendar-container.dark-mode .week-day-column.today .week-day-date { color: #3b82f6; } .calendar-container.dark-mode .week-standard-day-column.today .week-standard-day-date { background: #3b82f6; color: white; border-radius: 50%; } .calendar-container.dark-mode .week-standard-day-column { border: 1px solid #556070; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25); } .calendar-container.dark-mode .day-cell:hover, .calendar-container.dark-mode .day-time-slot:hover, .calendar-container.dark-mode .recurring-option:hover, .calendar-container.dark-mode .modal-close:hover, .calendar-container.dark-mode .btn-secondary:hover { background: #3f4752; } .calendar-container.dark-mode .recurring-option.disabled-option:hover { background: transparent; } .calendar-container.dark-mode .day-cell.other-month { background: #2f353e; } .calendar-container.dark-mode .day-number, .calendar-container.dark-mode .forecast-temperatures, .calendar-container.dark-mode .month-year, .calendar-container.dark-mode .modal-title, .calendar-container.dark-mode .confirm-title, .calendar-container.dark-mode .recurring-option-title { color: #f4f7fb; } .calendar-container.dark-mode .more-events, .calendar-container.dark-mode .modal-label, .calendar-container.dark-mode .confirm-message, .calendar-container.dark-mode .recurring-option-description, .calendar-container.dark-mode .day-modal-event-meta, .calendar-container.dark-mode .day-modal-event-location, .calendar-container.dark-mode .empty-state { color: #c7d0db; } .calendar-container.dark-mode .week-standard-day-column, .calendar-container.dark-mode .week-day-column, .calendar-container.dark-mode .modal-content, .calendar-container.dark-mode .confirm-dialog, .calendar-container.dark-mode .form-input, .calendar-container.dark-mode .form-select, .calendar-container.dark-mode .form-textarea, .calendar-container.dark-mode .recurring-options, .calendar-container.dark-mode .btn-secondary, .calendar-container.dark-mode .day-modal-event { background: #3b434d; color: #e2e8f0; border-color: #606b7b; box-shadow: none; } .calendar-container.dark-mode .modal-header, .calendar-container.dark-mode .modal-row { border-color: #5b6676; } .calendar-container.dark-mode .form-checkbox-label, .calendar-container.dark-mode .recurrence-end-option, .calendar-container.dark-mode .modal-value, .calendar-container.dark-mode .form-label { color: #d6dee8; } .calendar-container.dark-mode .event-description-content h1, .calendar-container.dark-mode .event-description-content h2, .calendar-container.dark-mode .event-description-content h3, .calendar-container.dark-mode .event-description-content h4, .calendar-container.dark-mode .event-description-content h5, .calendar-container.dark-mode .event-description-content h6 { color: #f8fafc; } .calendar-container.dark-mode .event-description-content blockquote { border-left-color: #64748b; color: #cbd5e1; } .calendar-container.dark-mode .event-description-content code, .calendar-container.dark-mode .event-description-content pre { background: #28313d; } .calendar-container.dark-mode .event-description-content a { color: #93c5fd; } .calendar-container.dark-mode .form-required { color: #f87171; } .calendar-container.dark-mode .form-input::placeholder, .calendar-container.dark-mode .form-textarea::placeholder { color: #9aa6b8; } .calendar-container.dark-mode input[type="date"]::-webkit-calendar-picker-indicator, .calendar-container.dark-mode input[type="datetime-local"]::-webkit-calendar-picker-indicator, .calendar-container.dark-mode input[type="time"]::-webkit-calendar-picker-indicator { filter: invert(1) brightness(0.9); } .calendar-container.dark-mode .btn-secondary { border: 1px solid #606b7b; } .calendar-container.dark-mode .info-banner { background: #5a4a34; border-color: #8f7a56; color: #f3e5c7; } .calendar-container.dark-mode .view-mode-buttons, .calendar-container.dark-mode .add-event-button, .calendar-container.dark-mode .compact-add-event-button, .calendar-container.dark-mode .nav-button, .calendar-container.dark-mode .today-button, .calendar-container.dark-mode .theme-toggle { background: rgba(226, 232, 240, 0.14); border-color: rgba(226, 232, 240, 0.28); } .calendar-container.dark-mode .view-mode-select { color: #f8fafc; } .calendar-container.dark-mode .view-mode-select option { color: #f8fafc; background: #1f2937; } .calendar-container.dark-mode .week-day-header, .calendar-container.dark-mode .week-standard-day-header { background: #3b434d; } .calendar-container.dark-mode .agenda-day-row { border-top-color: #5b6676; } .calendar-container.dark-mode .agenda-day-label { border-bottom-color: #5b6676; } .calendar-container.dark-mode .agenda-month-banner { border-top-color: #5b6676; border-bottom-color: #5b6676; color: #c7d0db; } .calendar-container.dark-mode .agenda-day-date { color: #f4f7fb; } .calendar-container.custom-background .calendar-grid, .calendar-container.custom-background .week-compact-container, .calendar-container.custom-background .calendar-badges, .calendar-container.custom-background .week-day-header, .calendar-container.custom-background .week-standard-day-header, .calendar-container.custom-background .time-slot, .calendar-container.custom-background .time-slot-label, .calendar-container.custom-background .week-day-name, .calendar-container.custom-background .week-day-date, .calendar-container.custom-background .week-standard-day-name, .calendar-container.custom-background .week-standard-day-date, .calendar-container.custom-background .empty-state { background: transparent !important; } .calendar-container.custom-background .week-day-header, .calendar-container.custom-background .week-standard-day-header, .calendar-container.custom-background .calendar-grid, .calendar-container.custom-background .week-compact-container, .calendar-container.custom-background .calendar-badges { border-color: rgba(255, 255, 255, 0.35) !important; } .calendar-container.custom-background .week-standard-container { background: rgba(var(--custom-surface-calendar-rgb, 249, 250, 251), var(--custom-surface-alpha, 0.55)) !important; } .calendar-container.custom-background .calendar-grid { background: rgba(var(--custom-surface-calendar-rgb, 249, 250, 251), var(--custom-surface-alpha, 0.55)) !important; border-top-color: rgba(var(--custom-surface-column-rgb, 255, 255, 255), var(--custom-surface-alpha, 0.55)) !important; } .calendar-container.custom-background .day-header, .calendar-container.custom-background .month-week-number-header { background: rgba(var(--custom-surface-all-day-rgb, 249, 250, 251), var(--custom-surface-alpha, 0.55)) !important; } .calendar-container.custom-background .day-cell, .calendar-container.custom-background .month-week-number-cell { background: rgba(var(--custom-surface-column-rgb, 255, 255, 255), var(--custom-surface-alpha, 0.55)) !important; } .calendar-container.custom-background .week-compact-container { background: rgba(var(--custom-surface-calendar-rgb, 249, 250, 251), var(--custom-surface-alpha, 0.55)) !important; border-top-color: rgba(var(--custom-surface-column-rgb, 255, 255, 255), var(--custom-surface-alpha, 0.55)) !important; } .calendar-container.custom-background .week-day-column { background: rgba(var(--custom-surface-column-rgb, 255, 255, 255), var(--custom-surface-alpha, 0.55)) !important; } .calendar-container.custom-background .week-standard-day-column { background: rgba(var(--custom-surface-column-rgb, 255, 255, 255), var(--custom-surface-alpha, 0.55)) !important; } .calendar-container.custom-background .all-day-events { background: rgba(var(--custom-surface-all-day-rgb, 249, 250, 251), var(--custom-surface-alpha, 0.55)) !important; } .calendar-container.custom-background .day-time-slot { background: rgba(var(--custom-surface-slot-rgb, 255, 255, 255), var(--custom-surface-alpha, 0.55)) !important; } .calendar-container.custom-background .agenda-container, .calendar-container.custom-background .agenda-month-banner, .calendar-container.custom-background .agenda-day-row { background: rgba(var(--custom-surface-calendar-rgb, 249, 250, 251), var(--custom-surface-alpha, 0.55)) !important; } .calendar-container.custom-background .agenda-day-label { background: rgba(var(--custom-surface-all-day-rgb, 249, 250, 251), var(--custom-surface-alpha, 0.55)) !important; } .calendar-container.dark-mode.custom-background .week-standard-day-header, .calendar-container.dark-mode.custom-background .all-day-events { border-bottom-color: transparent !important; } .calendar-container.dark-mode.custom-background .week-standard-day-column { border-color: transparent !important; box-shadow: none !important; } .calendar-container.custom-background .week-standard-day-column.today .week-standard-day-date { background: #3b82f6 !important; color: #ffffff !important; border-radius: 50%; } .calendar-container.custom-background .calendar-badges-container.has-overflow::after, .calendar-container.custom-background .calendar-badges-container.has-overflow::before { display: none; } .calendar-container.custom-background .day-cell.other-month { background: rgba(255, 255, 255, 0.12) !important; } .calendar-container.dark-mode.custom-background .day-cell.other-month { background: rgba(0, 0, 0, 0.2) !important; } @media (max-width: 768px) { .header { flex-direction: column; align-items: stretch; } .header-controls { justify-content: space-between; } .compact-header-controls { justify-content: flex-start; } .period-controls, .compact-period-controls { width: 100%; justify-content: space-between; gap: 8px; margin-left: 0; } .period-controls .month-year, .compact-period-controls .month-year { flex: 1; text-align: center; } .week-standard-container { font-size: 10px; } .week-standard-day-date { font-size: 14px; } .form-row { grid-template-columns: 1fr; } .form-inline-row { grid-template-columns: 88px minmax(0, 1fr); gap: 8px; align-items: center; } .form-group-inline .form-label { margin-bottom: 0; } .header-time { font-size: 22px; } } `; } render() { const shouldRestoreAgendaScrollPosition = this._viewMode === 'agenda' && Number.isFinite(this._agendaPendingScrollTop); const agendaScrollTopToRestore = shouldRestoreAgendaScrollPosition ? this._agendaPendingScrollTop : null; const today = new Date(); const year = this._currentDate.getFullYear(); const month = this._currentDate.getMonth(); const themeCardBackground = this._isDarkMode ? '#2a2f36' : '#ffffff'; const calendarBaseBackground = `var(--calendar-background, var(--theme-card-background, var(--ha-card-background, var(--card-background-color, ${themeCardBackground}))))`; const normalizedBackgroundOpacity = this.normalizeBackgroundOpacity(this._config.background_opacity, this._config.background_transparent ? 100 : 0); const rawHeaderBackgroundColor = this.normalizeSingleColor(this._config.header_color); const resolvedHeaderBackgroundBase = typeof rawHeaderBackgroundColor === 'string' && rawHeaderBackgroundColor.trim().toLowerCase() === 'match-card-background' ? calendarBaseBackground : (rawHeaderBackgroundColor || 'var(--primary-color)'); const normalizedHeaderBackgroundOpacity = this.normalizeBackgroundOpacity( this._config.header_background_opacity, this._config.header_background_transparent ? 100 : 0 ); const normalizedHeaderReveal = Math.max(0, Math.min(1, normalizedHeaderBackgroundOpacity / 100)); const headerAlpha = Math.max(0.2, 1 - (normalizedHeaderReveal * 0.75)); const configuredHeaderTextColor = this.normalizeSingleColor(this._config.header_text_color); let resolvedHeaderTextColor = configuredHeaderTextColor; if (!resolvedHeaderTextColor) { const headerBaseForContrast = typeof rawHeaderBackgroundColor === 'string' && rawHeaderBackgroundColor.trim().toLowerCase() === 'match-card-background' ? themeCardBackground : resolvedHeaderBackgroundBase; const headerBaseRgb = this.colorToRgb(headerBaseForContrast); const themeCardBackgroundRgb = this.colorToRgb(themeCardBackground); if (headerBaseRgb && themeCardBackgroundRgb && headerAlpha < 1) { const blendedHeaderRgb = { r: Math.round((headerBaseRgb.r * headerAlpha) + (themeCardBackgroundRgb.r * (1 - headerAlpha))), g: Math.round((headerBaseRgb.g * headerAlpha) + (themeCardBackgroundRgb.g * (1 - headerAlpha))), b: Math.round((headerBaseRgb.b * headerAlpha) + (themeCardBackgroundRgb.b * (1 - headerAlpha))) }; resolvedHeaderTextColor = this.getContractColor(`rgb(${blendedHeaderRgb.r}, ${blendedHeaderRgb.g}, ${blendedHeaderRgb.b})`); } else { resolvedHeaderTextColor = this.getContractColor(headerBaseForContrast); } } const headerControlBackground = this.colorWithAlpha(resolvedHeaderTextColor, 0.16); const headerControlHoverBackground = this.colorWithAlpha(resolvedHeaderTextColor, 0.24); const headerControlActiveBackground = this.colorWithAlpha(resolvedHeaderTextColor, 0.32); const headerControlBorder = this.colorWithAlpha(resolvedHeaderTextColor, 0.4); const headerControlBorderHover = this.colorWithAlpha(resolvedHeaderTextColor, 0.6); const wrappedHeaderBackground = normalizedHeaderBackgroundOpacity <= 0 ? resolvedHeaderBackgroundBase : 'transparent'; const headerStyle = `--header-background-base: ${resolvedHeaderBackgroundBase}; --header-background-alpha: ${headerAlpha}; --header-wrapped-background: ${wrappedHeaderBackground}; --header-text-color: ${resolvedHeaderTextColor}; --header-control-bg: ${headerControlBackground}; --header-control-bg-hover: ${headerControlHoverBackground}; --header-control-bg-active: ${headerControlActiveBackground}; --header-control-border: ${headerControlBorder}; --header-control-border-hover: ${headerControlBorderHover};`; const normalizedBackgroundImageUrl = this.normalizeBackgroundImageUrl(this._config.background_image_url); const safeBackgroundImageUrl = normalizedBackgroundImageUrl ? String(normalizedBackgroundImageUrl).replace(/[\'\\]/g, '\\$&') : null; const hasCustomBackground = normalizedBackgroundOpacity > 0; const backgroundImageStyle = safeBackgroundImageUrl ? `--calendar-background-image: url('${safeBackgroundImageUrl}'); --calendar-background-size: ${this._config.background_image_size}; --calendar-background-position: ${this._config.background_image_position}; --calendar-background-repeat: ${this._config.background_image_repeat};` : ''; const backgroundAlpha = (100 - normalizedBackgroundOpacity) / 100; const normalizedReveal = Math.max(0, Math.min(1, normalizedBackgroundOpacity / 100)); const scaledBackgroundImageAlpha = Math.max(0, Math.min(1, normalizedReveal * 0.75)); const backgroundImageAlpha = safeBackgroundImageUrl ? scaledBackgroundImageAlpha : 0; const customSurfaceAlpha = Math.max(0.2, 1 - (normalizedReveal * 0.75)); const customSurfacePalette = this._isDarkMode ? { calendar: '48, 54, 63', column: '59, 67, 77', allDay: '53, 60, 69', slot: '53, 60, 69' } : { calendar: '249, 250, 251', column: '255, 255, 255', allDay: '249, 250, 251', slot: '255, 255, 255' }; const backgroundStyle = `--theme-card-background: ${themeCardBackground}; --calendar-background-opacity: ${backgroundAlpha}; --calendar-background-image-opacity: ${backgroundImageAlpha}; --custom-surface-alpha: ${customSurfaceAlpha}; --custom-surface-calendar-rgb: ${customSurfacePalette.calendar}; --custom-surface-column-rgb: ${customSurfacePalette.column}; --custom-surface-all-day-rgb: ${customSurfacePalette.allDay}; --custom-surface-slot-rgb: ${customSurfacePalette.slot};`; const containerStyle = `${headerStyle} ${backgroundStyle} ${backgroundImageStyle}`.trim(); this._root.innerHTML = ` ${this._config.uix?.style ? ` ` : ''}
${paragraphLines.map(line => this.renderMarkdownInline(line)).join('
')}
${quoteLines.map(line => this.renderMarkdownInline(line)).join('`); quoteLines = []; }; const flushAll = () => { flushParagraph(); flushList(); flushQuote(); }; lines.forEach((line) => { if (!line.trim()) { flushAll(); return; } const headingMatch = line.match(/^(#{1,6})\s+(.+)$/); if (headingMatch) { flushAll(); const level = headingMatch[1].length; htmlBlocks.push(`
')}
${code}`);
return token;
});
html = html.replace(/\[([^\]]+)]\(([^)\s]+)\)/g, (_match, label, url) => {
const safeUrl = this.getSafeDescriptionUrl(this.decodeHtmlEntities(url));
if (!safeUrl) return label;
return `${label}`;
});
html = html
.replace(/\*\*([^*]+)\*\*/g, '$1')
.replace(/__([^_]+)__/g, '$1')
.replace(/~~([^~]+)~~/g, '