├── .github ├── FUNDING.yml ├── img │ ├── header.png │ ├── header.afphoto │ ├── example_editor.png │ ├── example_weather.png │ ├── example_1_basic_ios.png │ ├── example_5_complete.png │ ├── example_1_basic_native.png │ ├── example_3_custom_styling.png │ ├── example_4_week_numbers.png │ ├── example_today_indicator.png │ ├── example_2_advanced_compact.png │ └── example_2_advanced_expanded.png ├── workflows │ ├── hacs-validate.yml │ ├── ci.yml │ └── release.yml ├── release.yml ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.yml │ └── bug_report.yml └── PULL_REQUEST_TEMPLATE.md ├── .prettierignore ├── hacs.json ├── .prettierrc ├── src ├── translations │ ├── languages │ │ ├── he.json │ │ ├── zh-CN.json │ │ ├── zh-TW.json │ │ ├── vi.json │ │ ├── sl.json │ │ ├── cs.json │ │ ├── hr.json │ │ ├── fr.json │ │ ├── el.json │ │ ├── nn.json │ │ ├── uk.json │ │ ├── es.json │ │ ├── hu.json │ │ ├── da.json │ │ ├── ro.json │ │ ├── it.json │ │ ├── nl.json │ │ ├── ca.json │ │ ├── ru.json │ │ ├── is.json │ │ ├── pl.json │ │ ├── pt.json │ │ ├── th.json │ │ ├── bg.json │ │ ├── fi.json │ │ ├── sv.json │ │ ├── en.json │ │ ├── nb.json │ │ ├── sk.json │ │ └── de.json │ ├── dayjs.ts │ └── localize.ts ├── interaction │ ├── feedback.ts │ └── actions.ts ├── config │ ├── constants.ts │ ├── types.ts │ └── config.ts ├── rendering │ └── editor.styles.ts └── utils │ ├── logger.ts │ ├── weather.ts │ └── helpers.ts ├── .gitignore ├── tsconfig.json ├── package.json ├── rollup.config.mjs ├── LICENSE ├── eslint.config.mjs ├── CONTRIBUTING.md └── docs └── architecture.md /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [alexpfau] 2 | buy_me_a_coffee: alexpfau 3 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | /dist 2 | /.vscode 3 | package-lock.json 4 | package.json -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Calendar Card Pro", 3 | "filename": "calendar-card-pro.js" 4 | } 5 | -------------------------------------------------------------------------------- /.github/img/header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baruchiro/calendar-card-pro/main/.github/img/header.png -------------------------------------------------------------------------------- /.github/img/header.afphoto: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baruchiro/calendar-card-pro/main/.github/img/header.afphoto -------------------------------------------------------------------------------- /.github/img/example_editor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baruchiro/calendar-card-pro/main/.github/img/example_editor.png -------------------------------------------------------------------------------- /.github/img/example_weather.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baruchiro/calendar-card-pro/main/.github/img/example_weather.png -------------------------------------------------------------------------------- /.github/img/example_1_basic_ios.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baruchiro/calendar-card-pro/main/.github/img/example_1_basic_ios.png -------------------------------------------------------------------------------- /.github/img/example_5_complete.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baruchiro/calendar-card-pro/main/.github/img/example_5_complete.png -------------------------------------------------------------------------------- /.github/img/example_1_basic_native.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baruchiro/calendar-card-pro/main/.github/img/example_1_basic_native.png -------------------------------------------------------------------------------- /.github/img/example_3_custom_styling.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baruchiro/calendar-card-pro/main/.github/img/example_3_custom_styling.png -------------------------------------------------------------------------------- /.github/img/example_4_week_numbers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baruchiro/calendar-card-pro/main/.github/img/example_4_week_numbers.png -------------------------------------------------------------------------------- /.github/img/example_today_indicator.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baruchiro/calendar-card-pro/main/.github/img/example_today_indicator.png -------------------------------------------------------------------------------- /.github/img/example_2_advanced_compact.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baruchiro/calendar-card-pro/main/.github/img/example_2_advanced_compact.png -------------------------------------------------------------------------------- /.github/img/example_2_advanced_expanded.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baruchiro/calendar-card-pro/main/.github/img/example_2_advanced_expanded.png -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true, 4 | "trailingComma": "all", 5 | "printWidth": 100, 6 | "tabWidth": 2, 7 | "useTabs": false, 8 | "bracketSpacing": true, 9 | "arrowParens": "always" 10 | } 11 | -------------------------------------------------------------------------------- /.github/workflows/hacs-validate.yml: -------------------------------------------------------------------------------- 1 | name: HACS Validation 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | schedule: 8 | - cron: '0 0 * * *' # Run daily at midnight 9 | workflow_dispatch: 10 | 11 | jobs: 12 | validate-hacs: 13 | if: github.repository == 'alexpfau/calendar-card-pro' 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v3 17 | - name: HACS validation 18 | uses: hacs/action@main 19 | with: 20 | category: 'plugin' 21 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | exclude: 3 | labels: 4 | - ignore-for-release 5 | authors: 6 | - octocat 7 | categories: 8 | - title: Breaking Changes 🛠 9 | labels: 10 | - breaking-change 11 | - title: New Features 🎉 12 | labels: 13 | - enhancement 14 | - title: Fixes 🐛 15 | labels: 16 | - bug 17 | - title: Translations 🌍 18 | labels: 19 | - translations 20 | - title: Other Changes 21 | labels: 22 | - '*' 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: 💬 Questions & Support 4 | url: https://community.home-assistant.io/t/calendar-card-pro-a-beautiful-high-performance-calendar-card-for-home-assistant/863711 5 | about: For questions, help, and discussions about Calendar Card Pro, please use our dedicated Home Assistant community forum thread. 6 | - name: 📚 Documentation 7 | url: https://github.com/alexpfau/calendar-card-pro#readme 8 | about: Check the README for detailed configuration options and examples. 9 | -------------------------------------------------------------------------------- /src/translations/languages/he.json: -------------------------------------------------------------------------------- 1 | { 2 | "daysOfWeek": ["א'", "ב'", "ג'", "ד'", "ה'", "ו'", "ש'"], 3 | "fullDaysOfWeek": ["ראשון", "שני", "שלישי", "רביעי", "חמישי", "שישי", "שבת"], 4 | "months": ["ינו", "פבר", "מרץ", "אפר", "מאי", "יונ", "יול", "אוג", "ספט", "אוק", "נוב", "דצמ"], 5 | "allDay": "כל-היום", 6 | "multiDay": "עד", 7 | "endsToday": "מסתיים היום", 8 | "endsTomorrow": "מסתיים מחר", 9 | "at": "בשעה", 10 | "noEvents": "אין אירועים קרובים", 11 | "loading": "טוען אירועי לוח שנה...", 12 | "error": "שגיאה: ישות לוח השנה לא נמצאה או לא מוגדרת כראוי" 13 | } 14 | -------------------------------------------------------------------------------- /src/translations/languages/zh-CN.json: -------------------------------------------------------------------------------- 1 | { 2 | "daysOfWeek": ["日", "一", "二", "三", "四", "五", "六"], 3 | "fullDaysOfWeek": ["星期日", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六"], 4 | "months": [ 5 | "一月", 6 | "二月", 7 | "三月", 8 | "四月", 9 | "五月", 10 | "六月", 11 | "七月", 12 | "八月", 13 | "九月", 14 | "十月", 15 | "十一月", 16 | "十二月" 17 | ], 18 | "allDay": "整天", 19 | "multiDay": "直到", 20 | "at": "在", 21 | "endsToday": "今天结束", 22 | "endsTomorrow": "明天结束", 23 | "noEvents": "没有即将到来的活动", 24 | "loading": "正在加载日历事件...", 25 | "error": "错误:找不到日历实体或配置不正确" 26 | } 27 | -------------------------------------------------------------------------------- /src/translations/languages/zh-TW.json: -------------------------------------------------------------------------------- 1 | { 2 | "daysOfWeek": ["日", "一", "二", "三", "四", "五", "六"], 3 | "fullDaysOfWeek": ["星期日", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六"], 4 | "months": [ 5 | "一月", 6 | "二月", 7 | "三月", 8 | "四月", 9 | "五月", 10 | "六月", 11 | "七月", 12 | "八月", 13 | "九月", 14 | "十月", 15 | "十一月", 16 | "十二月" 17 | ], 18 | "allDay": "整天", 19 | "multiDay": "直到", 20 | "at": "在", 21 | "endsToday": "今天結束", 22 | "endsTomorrow": "明天結束", 23 | "noEvents": "沒有即將到來的活動", 24 | "loading": "正在加載日曆事件...", 25 | "error": "錯誤:找不到日曆實體或配置不正確" 26 | } 27 | -------------------------------------------------------------------------------- /src/translations/languages/vi.json: -------------------------------------------------------------------------------- 1 | { 2 | "daysOfWeek": ["CN", "T.2", "T.3", "T.4", "T.5", "T.6", "T.7"], 3 | "fullDaysOfWeek": ["Chủ Nhật", "Thứ Hai", "Thứ Ba", "Thứ Tư", "Thứ Năm", "Thứ Sáu", "Thứ Bảy"], 4 | "months": ["Th1", "Th2", "Th3", "Th4", "Th5", "Th6", "Th7", "Th8", "Th9", "Th10", "Th11", "Th12"], 5 | "allDay": "cả ngày", 6 | "multiDay": "đến", 7 | "at": "lúc", 8 | "endsToday": "kết thúc hôm nay", 9 | "endsTomorrow": "kết thúc ngày mai", 10 | "noEvents": "Không có sự kiện sắp tới", 11 | "loading": "Đang tải sự kiện...", 12 | "error": "Lỗi: Không tìm thấy lịch hoặc cấu hình không đúng" 13 | } 14 | -------------------------------------------------------------------------------- /src/translations/languages/sl.json: -------------------------------------------------------------------------------- 1 | { 2 | "daysOfWeek": ["ned", "pon", "tor", "sre", "čet", "pet", "sob"], 3 | "fullDaysOfWeek": ["nedelja", "ponedeljek", "torek", "sreda", "četrtek", "petek", "sobota"], 4 | "months": ["jan", "feb", "mar", "apr", "maj", "jun", "jul", "avg", "sep", "okt", "nov", "dec"], 5 | "allDay": "cel dan", 6 | "multiDay": "do", 7 | "at": "ob", 8 | "endsToday": "konča se danes", 9 | "endsTomorrow": "konča se jutri", 10 | "noEvents": "Ni planiranih dogodkov", 11 | "loading": "Nalagam dogodke...", 12 | "error": "Napaka: Entiteta ni bila najdena ali pa je nepravilno konfigurirana." 13 | } 14 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Build & Lint Checks 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | - dev 8 | 9 | jobs: 10 | lint-build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v4 15 | 16 | - name: Setup Node.js 17 | uses: actions/setup-node@v4 18 | with: 19 | node-version: 20 20 | cache: npm 21 | 22 | - name: Install dependencies 23 | run: npm ci 24 | 25 | - name: Lint 26 | run: npm run lint 27 | 28 | - name: Build 29 | run: npm run build 30 | -------------------------------------------------------------------------------- /src/translations/languages/cs.json: -------------------------------------------------------------------------------- 1 | { 2 | "daysOfWeek": ["Ne", "Po", "Út", "St", "Čt", "Pá", "So"], 3 | "fullDaysOfWeek": ["Neděle", "Pondělí", "Úterý", "Středa", "Čtvrtek", "Pátek", "Sobota"], 4 | "months": ["Led", "Úno", "Bře", "Dub", "Kvě", "Čvn", "Čvc", "Srp", "Zář", "Říj", "Lis", "Pro"], 5 | "allDay": "celý den", 6 | "multiDay": "do", 7 | "at": "v", 8 | "endsToday": "končí dnes", 9 | "endsTomorrow": "končí zítra", 10 | "noEvents": "Žádné nadcházející události", 11 | "loading": "Načítání událostí z kalendáře...", 12 | "error": "Chyba: Entita kalendáře nebyla nalezena nebo je nesprávně nakonfigurována" 13 | } 14 | -------------------------------------------------------------------------------- /src/translations/languages/hr.json: -------------------------------------------------------------------------------- 1 | { 2 | "daysOfWeek": ["Ned", "Pon", "Uto", "Sri", "Čet", "Pet", "Sub"], 3 | "fullDaysOfWeek": ["Nedjelja", "Ponedjeljak", "Utorak", "Srijeda", "Četvrtak", "Petak", "Subota"], 4 | "months": ["Sij", "Velj", "Ožu", "Tra", "Svi", "Lip", "Srp", "Kol", "Ruj", "Lis", "Stu", "Pro"], 5 | "allDay": "cijeli dan", 6 | "multiDay": "do", 7 | "at": "u", 8 | "endsToday": "završava danas", 9 | "endsTomorrow": "završava sutra", 10 | "noEvents": "Nema nadolazećih događaja", 11 | "loading": "Učitavanje događaja...", 12 | "error": "Greška: Kalendar entitet nije pronađen ili je neispravno postavljen" 13 | } 14 | -------------------------------------------------------------------------------- /src/translations/languages/fr.json: -------------------------------------------------------------------------------- 1 | { 2 | "daysOfWeek": ["Dim", "Lun", "Mar", "Mer", "Jeu", "Ven", "Sam"], 3 | "fullDaysOfWeek": ["Dimanche", "Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi"], 4 | "months": ["Jan", "Fév", "Mar", "Avr", "Mai", "Juin", "Juil", "Août", "Sep", "Oct", "Nov", "Déc"], 5 | "allDay": "toute la journée", 6 | "multiDay": "jusqu'au", 7 | "at": "à", 8 | "endsToday": "finit aujourd'hui", 9 | "endsTomorrow": "finit demain", 10 | "noEvents": "Aucun événement à venir", 11 | "loading": "Chargement des événements...", 12 | "error": "Erreur: Entité de calendrier introuvable ou mal configurée" 13 | } 14 | -------------------------------------------------------------------------------- /src/translations/languages/el.json: -------------------------------------------------------------------------------- 1 | { 2 | "daysOfWeek": ["Κυρ", "Δευ", "Τρι", "Τετ", "Πεμ", "Παρ", "Σαβ"], 3 | "fullDaysOfWeek": ["Κυριακή", "Δευτέρα", "Τρίτη", "Τετάρτη", "Πέμπτη", "Παρασκευή", "Σάββατο"], 4 | "months": ["Ιαν", "Φεβ", "Μαρ", "Απρ", "Μαϊ", "Ιουν", "Ιουλ", "Αυγ", "Σεπ", "Οκτ", "Νοε", "Δεκ"], 5 | "allDay": "Ολοήμερο", 6 | "multiDay": "έως", 7 | "at": "στις", 8 | "endsToday": "λήγει σήμερα", 9 | "endsTomorrow": "λήγει αύριο", 10 | "noEvents": "Δεν υπάρχουν προγραμματισμένα γεγονότα", 11 | "loading": "Φόρτωση ημερολογίου...", 12 | "error": "Σφάλμα: Η οντότητα ημερολογίου δεν βρέθηκε ή δεν έχει ρυθμιστεί σωστά" 13 | } 14 | -------------------------------------------------------------------------------- /src/translations/languages/nn.json: -------------------------------------------------------------------------------- 1 | { 2 | "daysOfWeek": ["Søn", "Mån", "Tys", "Ons", "Tor", "Fre", "Lau"], 3 | "fullDaysOfWeek": ["Søndag", "Måndag", "Tysdag", "Onsdag", "Torsdag", "Fredag", "Laurdag"], 4 | "months": ["Jan", "Feb", "Mar", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Des"], 5 | "allDay": "heile dagen", 6 | "multiDay": "inntil", 7 | "at": "kl. ", 8 | "endsToday": "sluttar i dag", 9 | "endsTomorrow": "sluttar i morgon", 10 | "noEvents": "Ingen kommande hendingar", 11 | "loading": "Lastar kalenderhendingar...", 12 | "error": "Feil: Kalendereininga vart ikkje funnen eller er ikkje konfigurert riktig" 13 | } 14 | -------------------------------------------------------------------------------- /src/translations/languages/uk.json: -------------------------------------------------------------------------------- 1 | { 2 | "daysOfWeek": ["Нд", "Пн", "Вт", "Ср", "Чт", "Пт", "Сб"], 3 | "fullDaysOfWeek": ["неділі", "понеділка", "вівторка", "середи", "четверга", "п'ятниці", "суботи"], 4 | "months": ["січ", "лют", "бер", "кві", "тра", "чер", "лип", "сер", "вер", "жов", "лис", "гру"], 5 | "allDay": "весь день", 6 | "multiDay": "до", 7 | "at": "об", 8 | "endsToday": "закінчується сьогодні", 9 | "endsTomorrow": "закінчується завтра", 10 | "noEvents": "Немає майбутніх подій", 11 | "loading": "Завантаження подій календаря...", 12 | "error": "Помилка: Cутність календаря не знайдено або налаштовано неправильно" 13 | } 14 | -------------------------------------------------------------------------------- /src/translations/languages/es.json: -------------------------------------------------------------------------------- 1 | { 2 | "daysOfWeek": ["Dom", "Lun", "Mar", "Mié", "Jue", "Vie", "Sáb"], 3 | "fullDaysOfWeek": ["Domingo", "Lunes", "Martes", "Miércoles", "Jueves", "Viernes", "Sábado"], 4 | "months": ["Ene", "Feb", "Mar", "Abr", "May", "Jun", "Jul", "Ago", "Sep", "Oct", "Nov", "Dic"], 5 | "allDay": "todo el día", 6 | "multiDay": "hasta", 7 | "at": "a las", 8 | "endsToday": "termina hoy", 9 | "endsTomorrow": "termina mañana", 10 | "noEvents": "No hay eventos próximos", 11 | "loading": "Cargando eventos del calendario...", 12 | "error": "Error: La entidad del calendario no se encontró o está mal configurada" 13 | } 14 | -------------------------------------------------------------------------------- /src/translations/languages/hu.json: -------------------------------------------------------------------------------- 1 | { 2 | "daysOfWeek": ["Vas", "Hét", "Kedd", "Sze", "Csüt", "Pén", "Szo"], 3 | "fullDaysOfWeek": ["Vasárnap", "Hétfő", "Kedd", "Szerda", "Csütörtök", "Péntek", "Szombat"], 4 | "months": ["Jan", "Feb", "Már", "Ápr", "Máj", "Jún", "Júl", "Aug", "Szep", "Okt", "Nov", "Dec"], 5 | "allDay": "egész napos", 6 | "multiDay": "eddig:", 7 | "endsToday": "ma este ér véget", 8 | "endsTomorrow": "holnap ér véget", 9 | "at": "itt:", 10 | "noEvents": "Mára nincs több esemény", 11 | "loading": "Naptárbejegyzések betöltése...", 12 | "error": "Hiba: Naptár entitás nem található vagy nem megfelelően konfigutált" 13 | } 14 | -------------------------------------------------------------------------------- /src/translations/languages/da.json: -------------------------------------------------------------------------------- 1 | { 2 | "daysOfWeek": ["Søn", "Man", "Tir", "Ons", "Tor", "Fre", "Lør"], 3 | "fullDaysOfWeek": ["Søndag", "Mandag", "Tirsdag", "Onsdag", "Torsdag", "Fredag", "Lørdag"], 4 | "months": ["Jan", "Feb", "Mar", "Apr", "Maj", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dec"], 5 | "allDay": "hele dagen", 6 | "multiDay": "indtil", 7 | "at": "kl.", 8 | "endsToday": "slutter i dag", 9 | "endsTomorrow": "slutter i morgen", 10 | "noEvents": "Ingen kommende begivenheder", 11 | "loading": "Indlæser kalenderbegivenheder...", 12 | "error": "Fejl: Kalenderenheden blev ikke fundet eller er ikke konfigureret korrekt" 13 | } 14 | -------------------------------------------------------------------------------- /src/translations/languages/ro.json: -------------------------------------------------------------------------------- 1 | { 2 | "daysOfWeek": ["Du", "Lu", "Ma", "Mi", "Jo", "Vi", "Sa"], 3 | "fullDaysOfWeek": ["Duminica", "Luni", "Marti", "Miercuri", "Joi", "Vineri", "Sambata"], 4 | "months": ["Ian", "Feb", "Mart", "Apr", "Mai", "Iun", "Iul", "Aug", "Sept", "Oct", "Nov", "Dec"], 5 | "allDay": "toata ziua", 6 | "multiDay": "pana la", 7 | "at": "la", 8 | "endsToday": "se incheie astazi", 9 | "endsTomorrow": "se incheie maine", 10 | "noEvents": "Nu sunt evenimente viitoare", 11 | "loading": "Incarcare evenimente de calendar...", 12 | "error": "Eroare: Entitatea de calendar nu a fost gasita sau este configurata incorect" 13 | } 14 | -------------------------------------------------------------------------------- /src/translations/languages/it.json: -------------------------------------------------------------------------------- 1 | { 2 | "daysOfWeek": ["Dom", "Lun", "Mar", "Mer", "Gio", "Ven", "Sab"], 3 | "fullDaysOfWeek": ["Domenica", "Lunedì", "Martedì", "Mercoledì", "Giovedì", "Venerdì", "Sabato"], 4 | "months": ["Gen", "Feb", "Mar", "Apr", "Mag", "Giu", "Lug", "Ago", "Set", "Ott", "Nov", "Dic"], 5 | "allDay": "tutto-il-giorno", 6 | "multiDay": "fino a", 7 | "at": "a", 8 | "endsToday": "termina oggi", 9 | "endsTomorrow": "termina domani", 10 | "noEvents": "Nessun evento programmato", 11 | "loading": "Sto caricando il calendario degli eventi...", 12 | "error": "Errore: Entità Calendario non trovata o non configurata correttamente" 13 | } 14 | -------------------------------------------------------------------------------- /src/translations/languages/nl.json: -------------------------------------------------------------------------------- 1 | { 2 | "daysOfWeek": ["Zo", "Ma", "Di", "Wo", "Do", "Vr", "Za"], 3 | "fullDaysOfWeek": [ 4 | "zondag", 5 | "maandag", 6 | "dinsdag", 7 | "woensdag", 8 | "donderdag", 9 | "vrijdag", 10 | "zaterdag" 11 | ], 12 | "months": ["Jan", "Feb", "Mrt", "Apr", "Mei", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dec"], 13 | "allDay": "hele dag", 14 | "multiDay": "tot", 15 | "at": "om", 16 | "endsToday": "eindigt vandaag", 17 | "endsTomorrow": "eindigt morgen", 18 | "noEvents": "Geen afspraken gepland", 19 | "loading": "Kalender afspraken laden...", 20 | "error": "Fout: Kalender niet gevonden of verkeerd geconfigureerd" 21 | } 22 | -------------------------------------------------------------------------------- /src/translations/languages/ca.json: -------------------------------------------------------------------------------- 1 | { 2 | "daysOfWeek": ["Dg", "Dl", "Dm", "Dc", "Dj", "Dv", "Ds"], 3 | "fullDaysOfWeek": [ 4 | "Diumenge", 5 | "Dilluns", 6 | "Dimarts", 7 | "Dimecres", 8 | "Dijous", 9 | "Divendres", 10 | "Dissabte" 11 | ], 12 | "months": ["Gen", "Febr", "Març", "Abr", "Maig", "Juny", "Jul", "Ag", "Set", "Oct", "Nov", "Des"], 13 | "allDay": "tot el dia", 14 | "multiDay": "fins a", 15 | "at": "a les", 16 | "endsToday": "acaba avui", 17 | "endsTomorrow": "acaba damà", 18 | "noEvents": "Cap event proper", 19 | "loading": "Carregant events...", 20 | "error": "Error: No s'ha trobat l'entitat del calendari o aquesta està mal configurada" 21 | } 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore dist/ and docs/ folder 2 | dist/* 3 | 4 | # Ignore system-specific and editor-related files 5 | .DS_Store 6 | .vscode/* 7 | 8 | # Ignore logs 9 | logs/ 10 | *.log 11 | npm-debug.log* 12 | 13 | # Ignore dependencies 14 | node_modules/ 15 | 16 | # Ignore runtime/process data 17 | pids/ 18 | *.pid 19 | *.seed 20 | *.pid.lock 21 | 22 | # Ignore build-related files 23 | build/ 24 | build/Release/ 25 | 26 | # Ignore cache and temporary files 27 | .cache/ 28 | tmp/ 29 | .undefined/ 30 | *.tgz 31 | 32 | # Ignore environment variables 33 | .env 34 | .env.test 35 | 36 | # Ignore project-specific caches and artifacts 37 | .eslintcache 38 | 39 | # Ignore documentation build artifacts 40 | docs/_build/ -------------------------------------------------------------------------------- /src/translations/languages/ru.json: -------------------------------------------------------------------------------- 1 | { 2 | "daysOfWeek": ["Вс", "Пн", "Вт", "Ср", "Чт", "Пт", "Сб"], 3 | "fullDaysOfWeek": [ 4 | "воскресенья", 5 | "понедельника", 6 | "вторника", 7 | "среды", 8 | "четверга", 9 | "пятницы", 10 | "субботы" 11 | ], 12 | "months": ["янв", "фев", "мар", "апр", "май", "июн", "июл", "авг", "сен", "окт", "ноя", "дек"], 13 | "allDay": "весь день", 14 | "multiDay": "до", 15 | "at": "в", 16 | "endsToday": "заканчивается сегодня", 17 | "endsTomorrow": "заканчивается завтра", 18 | "noEvents": "Нет предстоящих событий", 19 | "loading": "Загрузка событий календаря...", 20 | "error": "Ошибка: Объект календарь, не найден или неправильно настроен" 21 | } 22 | -------------------------------------------------------------------------------- /src/translations/languages/is.json: -------------------------------------------------------------------------------- 1 | { 2 | "daysOfWeek": ["Sun", "Mán", "Þri", "Mið", "Fim", "Fös", "Lau"], 3 | "fullDaysOfWeek": [ 4 | "Sunnudagur", 5 | "Mánudagur", 6 | "Þriðjudagur", 7 | "Miðvikudagur", 8 | "Fimmtudagur", 9 | "Föstudagur", 10 | "Laugardagur" 11 | ], 12 | "months": ["Jan", "Feb", "Mar", "Apr", "Maí", "Jún", "Júl", "Ágú", "Sep", "Okt", "Nóv", "Des"], 13 | "allDay": "Allur dagurinn", 14 | "multiDay": "þar til", 15 | "at": "kl", 16 | "endsToday": "lýkur í dag", 17 | "endsTomorrow": "lýkur á morgun", 18 | "noEvents": "Engir viðburðir á næstunni", 19 | "loading": "Hleður inn dagatal...", 20 | "error": "Villa: Dagatalseining finnst ekki eða er vanstillt" 21 | } 22 | -------------------------------------------------------------------------------- /src/translations/languages/pl.json: -------------------------------------------------------------------------------- 1 | { 2 | "daysOfWeek": ["Nd", "Pn", "Wt", "Śr", "Cz", "Pt", "Sb"], 3 | "fullDaysOfWeek": [ 4 | "niedzieli", 5 | "poniedziałku", 6 | "wtorku", 7 | "środy", 8 | "czwartku", 9 | "piątku", 10 | "soboty" 11 | ], 12 | "months": ["sty", "lut", "mar", "kwi", "maj", "cze", "lip", "sie", "wrz", "paź", "lis", "gru"], 13 | "allDay": "cały dzień", 14 | "multiDay": "do", 15 | "at": "o", 16 | "endsToday": "kończy się dziś", 17 | "endsTomorrow": "kończy się jutro", 18 | "noEvents": "Brak nadchodzących wydarzeń", 19 | "loading": "Ładowanie wydarzeń z kalendarza...", 20 | "error": "Błąd: encja kalendarza nie została znaleziona lub jest niepoprawnie skonfigurowana" 21 | } 22 | -------------------------------------------------------------------------------- /src/translations/languages/pt.json: -------------------------------------------------------------------------------- 1 | { 2 | "daysOfWeek": ["Dom", "Seg", "Ter", "Qua", "Qui", "Sex", "Sáb"], 3 | "fullDaysOfWeek": [ 4 | "Domingo", 5 | "Segunda-feira", 6 | "Terça-feira", 7 | "Quarta-feira", 8 | "Quinta-feira", 9 | "Sexta-feira", 10 | "Sábado" 11 | ], 12 | "months": ["Jan", "Fev", "Mar", "Abr", "Mai", "Jun", "Jul", "Ago", "Set", "Out", "Nov", "Dez"], 13 | "allDay": "o dia todo", 14 | "multiDay": "até", 15 | "at": "às", 16 | "endsToday": "termina hoje", 17 | "endsTomorrow": "termina amanhã", 18 | "noEvents": "Nenhum evento próximo", 19 | "loading": "Carregando eventos do calendário...", 20 | "error": "Erro: A entidade do calendário não foi encontrada ou está configurada incorretamente" 21 | } 22 | -------------------------------------------------------------------------------- /src/translations/languages/th.json: -------------------------------------------------------------------------------- 1 | { 2 | "daysOfWeek": ["อา.", "จ.", "อ.", "พ.", "พฤ.", "ศ.", "ส."], 3 | "fullDaysOfWeek": ["อาทิตย์", "จันทร์", "อังคาร", "พุธ", "พฤหัสบดี", "ศุกร์", "เสาร์"], 4 | "months": [ 5 | "ม.ค.", 6 | "ก.พ.", 7 | "มี.ค.", 8 | "เม.ย.", 9 | "พ.ค.", 10 | "มิ.ย.", 11 | "ก.ค.", 12 | "ส.ค.", 13 | "ก.ย.", 14 | "ต.ค.", 15 | "พ.ย.", 16 | "ธ.ค." 17 | ], 18 | "allDay": "ตลอดวัน", 19 | "multiDay": "ถึง", 20 | "at": "เวลา", 21 | "endsToday": "สิ้นสุดวันนี้", 22 | "endsTomorrow": "สิ้นสุดพรุ่งนี้", 23 | "noEvents": "ไม่มีเหตุการณ์ที่กำลังจะเกิดขึ้น", 24 | "loading": "กำลังโหลดเหตุการณ์ปฏิทิน...", 25 | "error": "ข้อผิดพลาด: ไม่พบเอนทิตีปฏิทินหรือมีการตั้งค่าที่ไม่ถูกต้อง" 26 | } 27 | -------------------------------------------------------------------------------- /src/translations/languages/bg.json: -------------------------------------------------------------------------------- 1 | { 2 | "daysOfWeek": ["нед.", "пон.", "вт.", "ср.", "четв.", "пет.", "съб."], 3 | "fullDaysOfWeek": ["неделя", "понеделник", "вторник", "сряда", "четвъртък", "петък", "събота"], 4 | "months": [ 5 | "ян.", 6 | "фев.", 7 | "мар", 8 | "апр.", 9 | "май", 10 | "юни", 11 | "юли", 12 | "авг.", 13 | "септ.", 14 | "окт.", 15 | "ноем.", 16 | "дек." 17 | ], 18 | "allDay": "цял ден", 19 | "multiDay": "до", 20 | "at": "в", 21 | "endsToday": "приключва днес", 22 | "endsTomorrow": "приключва утре", 23 | "noEvents": "Няма планирани събития", 24 | "loading": "Зареждане на календара със събития...", 25 | "error": "Грешка: календарът не е намерен или не е конфигуриран правилно" 26 | } 27 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Build & Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | release: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v4 14 | 15 | - name: Setup Node.js 16 | uses: actions/setup-node@v4 17 | with: 18 | node-version: 20 19 | cache: npm 20 | 21 | - name: Install dependencies 22 | run: npm ci 23 | 24 | - name: Lint 25 | run: npm run lint 26 | 27 | - name: Build 28 | run: npm run build 29 | 30 | - name: Release 31 | uses: softprops/action-gh-release@v2 32 | with: 33 | draft: true 34 | generate_release_notes: true 35 | files: dist/calendar-card-pro.js 36 | -------------------------------------------------------------------------------- /src/translations/languages/fi.json: -------------------------------------------------------------------------------- 1 | { 2 | "daysOfWeek": ["Su", "Ma", "Ti", "Ke", "To", "Pe", "La"], 3 | "fullDaysOfWeek": [ 4 | "Sunnuntai", 5 | "Maanantai", 6 | "Tiistai", 7 | "Keskiviikko", 8 | "Torstai", 9 | "Perjantai", 10 | "Lauantai" 11 | ], 12 | "months": [ 13 | "Tammi", 14 | "Helmi", 15 | "Maalis", 16 | "Huhti", 17 | "Touko", 18 | "Kesä", 19 | "Heinä", 20 | "Elo", 21 | "Syys", 22 | "Loka", 23 | "Marras", 24 | "Joulu" 25 | ], 26 | "allDay": "koko päivä", 27 | "multiDay": "asti", 28 | "at": "klo", 29 | "endsToday": "päättyy tänään", 30 | "endsTomorrow": "päättyy huomenna", 31 | "noEvents": "Ei tulevia tapahtumia", 32 | "loading": "Ladataan kalenteritapahtumia...", 33 | "error": "Virhe: Kalenteriyksikköä ei löydy tai se on väärin määritetty" 34 | } 35 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "lib": ["ES2017", "DOM", "DOM.Iterable"], 7 | "strict": true, 8 | "noImplicitAny": false, 9 | "noUnusedParameters": true, 10 | "noImplicitReturns": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "skipLibCheck": true, 13 | "resolveJsonModule": true, 14 | "experimentalDecorators": true, 15 | "sourceMap": true, 16 | "allowSyntheticDefaultImports": true, 17 | "esModuleInterop": true, 18 | "baseUrl": "src", 19 | "paths": { 20 | "@config/*": ["config/*"], 21 | "@translations/*": ["translations/*"], 22 | "@utils/*": ["utils/*"], 23 | "@rendering/*": ["rendering/*"] 24 | }, 25 | "noEmit": true 26 | }, 27 | "include": ["src/**/*"] 28 | } 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "calendar-card-pro-dev", 3 | "version": "3.1.0", 4 | "description": "Custom Home Assistant calendar card", 5 | "main": "dist/calendar-card-pro.js", 6 | "scripts": { 7 | "dev": "rollup -c --watch", 8 | "build": "cross-env NODE_ENV=prod rollup -c", 9 | "lint": "eslint 'src/**/*.ts' --fix --format stylish", 10 | "format": "prettier --write 'src/**/*.ts'" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/alexpfau/calendar-card-pro-dev.git" 15 | }, 16 | "keywords": [ 17 | "calendar", 18 | "card", 19 | "home-assistant", 20 | "homeassistant", 21 | "hacs", 22 | "lovelace", 23 | "custom-card" 24 | ], 25 | "author": "Alex Pfau", 26 | "license": "MIT", 27 | "bugs": { 28 | "url": "https://github.com/alexpfau/calendar-card-pro-dev/issues" 29 | }, 30 | "homepage": "https://github.com/alexpfau/calendar-card-pro-dev#readme", 31 | "devDependencies": { 32 | "@eslint/eslintrc": "^3.3.0", 33 | "@rollup/plugin-commonjs": "^28.0.3", 34 | "@rollup/plugin-json": "^6.1.0", 35 | "@rollup/plugin-node-resolve": "^16.0.1", 36 | "@rollup/plugin-replace": "^6.0.2", 37 | "@rollup/plugin-terser": "^0.4.4", 38 | "@rollup/plugin-typescript": "^12.1.2", 39 | "@types/node": "^22.13.5", 40 | "@typescript-eslint/eslint-plugin": "^8.25.0", 41 | "@typescript-eslint/parser": "^8.25.0", 42 | "cross-env": "^7.0.3", 43 | "esbuild": "^0.25.2", 44 | "eslint": "^9.21.0", 45 | "eslint-config-prettier": "^10.0.2", 46 | "eslint-plugin-import": "^2.31.0", 47 | "eslint-plugin-prettier": "^5.2.3", 48 | "prettier": "^3.5.2", 49 | "rollup": "^4.34.8", 50 | "rollup-plugin-esbuild": "^6.2.1", 51 | "typescript": "^5.7.3" 52 | }, 53 | "dependencies": { 54 | "@material/web": "^2.2.0", 55 | "@mdi/js": "^7.4.47", 56 | "dayjs": "^1.11.13" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /rollup.config.mjs: -------------------------------------------------------------------------------- 1 | import replace from '@rollup/plugin-replace'; 2 | import resolve from '@rollup/plugin-node-resolve'; 3 | import commonjs from '@rollup/plugin-commonjs'; 4 | import terser from '@rollup/plugin-terser'; 5 | import esbuild from 'rollup-plugin-esbuild'; 6 | import json from '@rollup/plugin-json'; 7 | import { readFileSync } from 'fs'; 8 | 9 | // Use the existing NODE_ENV variable for both purposes 10 | const isProd = process.env.NODE_ENV === 'prod'; 11 | // Use NODE_ENV to determine filename as well 12 | const outputFilename = isProd ? 'calendar-card-pro.js' : 'calendar-card-pro-dev.js'; 13 | 14 | // Get version from package.json reliably 15 | const packageJson = JSON.parse(readFileSync('./package.json', 'utf-8')); 16 | const version = packageJson.version; 17 | 18 | export default { 19 | input: 'src/calendar-card-pro.ts', 20 | output: { 21 | dir: 'dist', 22 | format: 'es', 23 | // Use the dynamic filename based on NODE_ENV 24 | entryFileNames: outputFilename, 25 | sourcemap: true, 26 | }, 27 | plugins: [ 28 | replace({ 29 | preventAssignment: true, 30 | delimiters: ['', ''], 31 | // Replace version placeholders in main header and constants.ts 32 | '@version vPLACEHOLDER': `@version ${version}`, 33 | "CURRENT: 'vPLACEHOLDER'": `CURRENT: '${version}'`, 34 | // Change log level in constants.ts to 0 in production 35 | 'CURRENT_LOG_LEVEL: 1': `CURRENT_LOG_LEVEL: ${isProd ? 0 : 1}`, 36 | 'CURRENT_LOG_LEVEL: 2': `CURRENT_LOG_LEVEL: ${isProd ? 0 : 2}`, 37 | 'CURRENT_LOG_LEVEL: 3': `CURRENT_LOG_LEVEL: ${isProd ? 0 : 3}`, 38 | // Remove -dev suffix from component name in production 39 | 'calendar-card-pro-dev': isProd ? 'calendar-card-pro' : 'calendar-card-pro-dev', 40 | }), 41 | json(), 42 | esbuild({ 43 | tsconfig: 'tsconfig.json', 44 | target: 'es2017', 45 | sourceMap: true, 46 | }), 47 | resolve(), 48 | commonjs(), 49 | terser(), 50 | ], 51 | }; 52 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: 🚀 Feature Request 2 | description: Suggest an idea for Calendar Card Pro 3 | title: '[Feature]: ' 4 | labels: ['enhancement'] 5 | body: 6 | - type: checkboxes 7 | attributes: 8 | label: Requirements 9 | options: 10 | - label: I've checked that I'm using the latest version of Calendar Card Pro 11 | required: true 12 | - label: I've verified this feature isn't already available in the configuration options 13 | required: true 14 | - label: I've searched existing issues to verify this hasn't already been requested 15 | required: true 16 | - type: textarea 17 | attributes: 18 | label: Is your feature request related to a problem? 19 | description: > 20 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 21 | validations: 22 | required: true 23 | - type: textarea 24 | attributes: 25 | label: Describe the solution you'd like 26 | description: > 27 | A clear and concise description of what you want to happen. 28 | validations: 29 | required: true 30 | - type: textarea 31 | attributes: 32 | label: Describe alternatives you've considered 33 | description: > 34 | A clear and concise description of any alternative solutions or features you've considered. 35 | validations: 36 | required: false 37 | - type: textarea 38 | attributes: 39 | label: Example use case 40 | description: > 41 | Describe how you would use this feature in your setup. 42 | validations: 43 | required: true 44 | - type: textarea 45 | attributes: 46 | label: Additional context 47 | description: > 48 | Add any other context, mockups, or screenshots about the feature request. 49 | validations: 50 | required: false 51 | - type: markdown 52 | attributes: 53 | value: > 54 | Thanks for contributing to Calendar Card Pro! 🎉 55 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Alex Pfau 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | --- 24 | 25 | ## THIRD-PARTY LICENSES AND ATTRIBUTIONS 26 | 27 | Calendar Card Pro incorporates code and design elements from the following projects: 28 | 29 | 1. lit / LitElement 30 | Copyright (c) 2017 Google LLC 31 | BSD-3-Clause License 32 | https://github.com/lit/lit/ 33 | 34 | 2. Home Assistant Frontend 35 | Copyright (c) 2013-present Home Assistant contributors 36 | Apache License 2.0 37 | https://github.com/home-assistant/frontend 38 | 39 | Calendar Card Pro uses components from and is designed to work with 40 | Home Assistant. Interaction patterns were inspired by Home Assistant's 41 | Tile Card component. 42 | 43 | 3. Design Inspiration 44 | Calendar design elements were inspired by Home Assistant community member 45 | @kdw2060's button-card calendar design. 46 | https://community.home-assistant.io/t/calendar-add-on-some-calendar-designs/385790 47 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { FlatCompat } from '@eslint/eslintrc'; 2 | import tsPlugin from '@typescript-eslint/eslint-plugin'; 3 | import tsParser from '@typescript-eslint/parser'; 4 | import prettierPlugin from 'eslint-plugin-prettier'; 5 | import importPlugin from 'eslint-plugin-import'; 6 | import prettierConfig from 'eslint-config-prettier'; 7 | 8 | const compat = new FlatCompat(); 9 | 10 | export default [ 11 | { 12 | files: ['src/**/*.ts'], 13 | languageOptions: { 14 | parser: tsParser, 15 | parserOptions: { 16 | ecmaVersion: 2021, 17 | sourceType: 'module', 18 | project: './tsconfig.json', 19 | tsconfigRootDir: import.meta.dirname, 20 | }, 21 | }, 22 | plugins: { 23 | '@typescript-eslint': tsPlugin, 24 | prettier: prettierPlugin, 25 | import: importPlugin, 26 | }, 27 | rules: { 28 | ...tsPlugin.configs.recommended.rules, 29 | ...prettierConfig.rules, 30 | '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], 31 | '@typescript-eslint/no-explicit-any': 'error', 32 | '@typescript-eslint/explicit-function-return-type': 'off', 33 | 'import/order': [ 34 | 'warn', 35 | { 36 | groups: ['builtin', 'external', 'internal', ['sibling', 'parent'], 'index', 'unknown'], 37 | 'newlines-between': 'always', 38 | alphabetize: { order: 'asc', caseInsensitive: true }, 39 | }, 40 | ], 41 | 'prettier/prettier': ['error'], 42 | 'sort-imports': [ 43 | 'error', 44 | { 45 | ignoreCase: false, 46 | ignoreDeclarationSort: true, 47 | ignoreMemberSort: false, 48 | memberSyntaxSortOrder: ['none', 'all', 'multiple', 'single'], 49 | allowSeparatedGroups: true, 50 | }, 51 | ], 52 | }, 53 | settings: { 54 | 'import/resolver': { 55 | typescript: { 56 | project: './tsconfig.json', 57 | alwaysTryTypes: true, 58 | }, 59 | }, 60 | }, 61 | }, 62 | ]; 63 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | 4 | 5 | ## Related Issue 6 | 7 | 8 | 9 | 10 | 11 | 12 | This PR fixes or closes issue: fixes # 13 | 14 | ## Motivation and Context 15 | 16 | 17 | 18 | ## How Has This Been Tested 19 | 20 | 21 | 22 | 23 | 24 | ## Types of changes 25 | 26 | 27 | 28 | - [ ] 🐛 Bug fix (non-breaking change which fixes an issue) 29 | - [ ] 🚀 New feature (non-breaking change which adds functionality) 30 | - [ ] 🌎 Translation (addition or update for a language) 31 | - [ ] ⚙️ Tech (code style improvement, performance improvement or dependencies update) 32 | - [ ] 📚 Documentation (fix or addition to documentation) 33 | - [ ] ⚠️ Breaking change (fix or feature that would cause existing functionality to change) 34 | 35 | ## Checklist 36 | 37 | 38 | 39 | 40 | - [ ] My code follows the code style of this project. 41 | - [ ] I have linted and formatted my code (`npm run lint` and `npm run format`) 42 | - [ ] My change requires a change to the documentation. 43 | - [ ] I have updated the documentation accordingly. 44 | - [ ] I have tested the change in my local Home Assistant instance. 45 | - [ ] I have followed [the translation guidelines](https://github.com/alexpfau/calendar-card-pro#adding-translations) if I'm adding or updating a translation. 46 | -------------------------------------------------------------------------------- /src/translations/dayjs.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/order */ 2 | /** 3 | * dayjs configuration and utilities for Calendar Card Pro 4 | * 5 | * Handles loading language-specific configurations for relative time formatting. 6 | */ 7 | 8 | import dayjs from 'dayjs'; 9 | import relativeTime from 'dayjs/plugin/relativeTime'; 10 | 11 | // Configure dayjs with the relativeTime plugin before importing locales 12 | dayjs.extend(relativeTime); 13 | 14 | // Explicitly import all locales supported by our card 15 | import 'dayjs/locale/bg'; 16 | import 'dayjs/locale/ca'; 17 | import 'dayjs/locale/cs'; 18 | import 'dayjs/locale/da'; 19 | import 'dayjs/locale/de'; 20 | import 'dayjs/locale/el'; 21 | import 'dayjs/locale/en'; 22 | import 'dayjs/locale/es'; 23 | import 'dayjs/locale/fi'; 24 | import 'dayjs/locale/fr'; 25 | import 'dayjs/locale/he'; 26 | import 'dayjs/locale/hr'; 27 | import 'dayjs/locale/hu'; 28 | import 'dayjs/locale/is'; 29 | import 'dayjs/locale/it'; 30 | import 'dayjs/locale/nb'; 31 | import 'dayjs/locale/nl'; 32 | import 'dayjs/locale/nn'; 33 | import 'dayjs/locale/pl'; 34 | import 'dayjs/locale/pt'; 35 | import 'dayjs/locale/ro'; 36 | import 'dayjs/locale/ru'; 37 | import 'dayjs/locale/sk'; 38 | import 'dayjs/locale/sl'; 39 | import 'dayjs/locale/sv'; 40 | import 'dayjs/locale/th'; 41 | import 'dayjs/locale/uk'; 42 | import 'dayjs/locale/vi'; 43 | import 'dayjs/locale/zh-cn'; 44 | import 'dayjs/locale/zh-tw'; 45 | 46 | /** 47 | * Get relative time string (e.g., "in 2 days") 48 | * 49 | * @param date Target date 50 | * @param locale Language code 51 | * @returns Formatted relative time string 52 | */ 53 | export function getRelativeTimeString(date: Date, locale: string): string { 54 | const mappedLocale = mapLocale(locale); 55 | return dayjs(date).locale(mappedLocale).fromNow(); 56 | } 57 | 58 | /** 59 | * Map Home Assistant/Card locale to dayjs locale if needed 60 | */ 61 | function mapLocale(locale: string): string { 62 | // Handle special cases first (like zh-CN, zh-TW) 63 | const lowerLocale = locale.toLowerCase(); 64 | if (lowerLocale === 'zh-cn' || lowerLocale === 'zh-tw') { 65 | return lowerLocale; 66 | } 67 | 68 | // For other locales, extract the base language code 69 | const baseLocale = lowerLocale.split('-')[0]; 70 | 71 | // Complete list of supported locales matching our translations 72 | const supportedLocales = [ 73 | 'bg', 74 | 'cs', 75 | 'da', 76 | 'de', 77 | 'el', 78 | 'en', 79 | 'es', 80 | 'fi', 81 | 'fr', 82 | 'he', 83 | 'hr', 84 | 'hu', 85 | 'is', 86 | 'it', 87 | 'nb', 88 | 'nl', 89 | 'nn', 90 | 'pl', 91 | 'pt', 92 | 'ru', 93 | 'sk', 94 | 'sl', 95 | 'sv', 96 | 'th', 97 | 'uk', 98 | 'vi', 99 | 'zh-cn', 100 | 'zh-tw', 101 | ]; 102 | 103 | // Default to English if locale isn't supported 104 | return supportedLocales.includes(baseLocale) ? baseLocale : 'en'; 105 | } 106 | -------------------------------------------------------------------------------- /src/interaction/feedback.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/order */ 2 | /** 3 | * Visual feedback for Calendar Card Pro interactions 4 | * 5 | * This module provides visual indicators and feedback for user interactions 6 | * including hold indicators and related visual effects. 7 | */ 8 | 9 | import * as Types from '../config/types'; 10 | import * as Logger from '../utils/logger'; 11 | import * as Constants from '../config/constants'; 12 | 13 | //----------------------------------------------------------------------------- 14 | // VISUAL INDICATORS 15 | //----------------------------------------------------------------------------- 16 | 17 | /** 18 | * Create a visual hold indicator at pointer position 19 | * 20 | * @param event - Pointer event that triggered the hold 21 | * @param config - Card configuration to use for styling 22 | * @returns The created hold indicator element 23 | */ 24 | export function createHoldIndicator(event: PointerEvent, config: Types.Config): HTMLElement { 25 | // Create hold indicator 26 | const holdIndicator = document.createElement('div'); 27 | 28 | // Configure the visual appearance 29 | holdIndicator.style.position = 'absolute'; 30 | holdIndicator.style.pointerEvents = 'none'; 31 | holdIndicator.style.borderRadius = '50%'; 32 | holdIndicator.style.backgroundColor = config.accent_color; 33 | holdIndicator.style.opacity = `${Constants.UI.HOLD_INDICATOR_OPACITY}`; 34 | holdIndicator.style.transform = 'translate(-50%, -50%) scale(0)'; 35 | holdIndicator.style.transition = `transform ${Constants.TIMING.HOLD_INDICATOR_TRANSITION}ms ease-out`; 36 | 37 | // Set position based on pointer event 38 | holdIndicator.style.left = event.pageX + 'px'; 39 | holdIndicator.style.top = event.pageY + 'px'; 40 | 41 | // Choose size based on interaction type (touch vs mouse) 42 | const isTouchEvent = event.pointerType === 'touch'; 43 | const size = isTouchEvent 44 | ? Constants.UI.HOLD_INDICATOR.TOUCH_SIZE 45 | : Constants.UI.HOLD_INDICATOR.POINTER_SIZE; 46 | 47 | holdIndicator.style.width = `${size}px`; 48 | holdIndicator.style.height = `${size}px`; 49 | 50 | // Add to body 51 | document.body.appendChild(holdIndicator); 52 | 53 | // Trigger animation 54 | setTimeout(() => { 55 | holdIndicator.style.transform = 'translate(-50%, -50%) scale(1)'; 56 | }, 10); 57 | 58 | Logger.debug('Created hold indicator'); 59 | return holdIndicator; 60 | } 61 | 62 | /** 63 | * Remove a hold indicator with animation 64 | * 65 | * @param indicator - Hold indicator element to remove 66 | */ 67 | export function removeHoldIndicator(indicator: HTMLElement): void { 68 | // Fade out animation 69 | indicator.style.opacity = '0'; 70 | indicator.style.transition = `opacity ${Constants.TIMING.HOLD_INDICATOR_FADEOUT}ms ease-out`; 71 | 72 | // Remove after animation completes 73 | setTimeout(() => { 74 | if (indicator.parentNode) { 75 | indicator.parentNode.removeChild(indicator); 76 | Logger.debug('Removed hold indicator'); 77 | } 78 | }, Constants.TIMING.HOLD_INDICATOR_FADEOUT); 79 | } 80 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: 🐞 Bug Report 2 | description: Report a bug in Calendar Card Pro 3 | title: '[Bug]: ' 4 | labels: ['bug'] 5 | body: 6 | - type: checkboxes 7 | attributes: 8 | label: Requirements 9 | options: 10 | - label: I've checked that I'm using the latest version of Calendar Card Pro 11 | required: true 12 | - label: I've searched existing issues to verify this isn't a duplicate 13 | required: true 14 | - label: I've tried refreshing with a cleared browser cache (Ctrl+F5 or Cmd+Shift+R) 15 | required: true 16 | - type: textarea 17 | attributes: 18 | label: Current Behavior 19 | description: A concise description of what you're experiencing. 20 | validations: 21 | required: true 22 | - type: textarea 23 | attributes: 24 | label: Expected Behavior 25 | description: A concise description of what you expected to happen. 26 | validations: 27 | required: true 28 | - type: textarea 29 | attributes: 30 | label: Steps To Reproduce 31 | description: Steps to reproduce the behavior. 32 | placeholder: | 33 | 1. Add the Calendar Card Pro to dashboard 34 | 2. Configure with these settings... 35 | 3. Click on... 36 | 4. See error... 37 | validations: 38 | required: true 39 | - type: textarea 40 | attributes: 41 | label: Card Configuration 42 | description: Your Calendar Card Pro YAML configuration 43 | render: yaml 44 | placeholder: | 45 | type: custom:calendar-card-pro 46 | entities: 47 | - calendar.example 48 | # Your configuration here 49 | validations: 50 | required: true 51 | - type: textarea 52 | attributes: 53 | label: Calendar Entity State 54 | description: | 55 | If relevant, please provide the state/attributes of your calendar entity. You can find it in the [developer tools](https://my.home-assistant.io/redirect/developer_states/). 56 | render: yaml 57 | validations: 58 | required: false 59 | - type: textarea 60 | attributes: 61 | label: Browser Console Logs 62 | description: | 63 | If you're seeing errors, please open your browser's developer console (F12) and share any relevant logs. 64 | render: shell 65 | validations: 66 | required: false 67 | - type: textarea 68 | attributes: 69 | label: Environment 70 | description: | 71 | Your environment details 72 | value: | 73 | - Browser & Version: 74 | - Home Assistant Version: 75 | - Calendar Card Pro Version: 76 | - Device Type: 77 | render: markdown 78 | validations: 79 | required: true 80 | - type: textarea 81 | attributes: 82 | label: Additional Information 83 | description: | 84 | Screenshots, additional context, or other details that might help resolve the issue. 85 | 86 | Tip: You can attach images by clicking this area and dragging files in. 87 | validations: 88 | required: false 89 | -------------------------------------------------------------------------------- /src/config/constants.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Calendar Card Pro Constants 3 | * 4 | * This module contains all constant values used throughout the application. 5 | * Centralizing constants makes them easier to adjust and ensures consistency. 6 | */ 7 | 8 | //----------------------------------------------------------------------------- 9 | // CORE APPLICATION INFORMATION 10 | //----------------------------------------------------------------------------- 11 | 12 | /** 13 | * Version information 14 | */ 15 | export const VERSION = { 16 | /** Current version of Calendar Card Pro - will be replaced during build with version defined in package.json */ 17 | CURRENT: 'vPLACEHOLDER', 18 | }; 19 | 20 | //----------------------------------------------------------------------------- 21 | // CORE CONFIGURATION 22 | //----------------------------------------------------------------------------- 23 | 24 | /** 25 | * Cache-related constants 26 | */ 27 | export const CACHE = { 28 | /** Default interval (minutes) for refreshing event data from API */ 29 | DEFAULT_DATA_REFRESH_MINUTES: 30, 30 | 31 | /** Cache duration (milliseconds) to use when manual page reload is detected */ 32 | MANUAL_RELOAD_CACHE_DURATION_SECONDS: 5, // 5 seconds 33 | 34 | /** Multiplier used with cache lifetime to calculate when entries should be purged */ 35 | CACHE_EXPIRY_MULTIPLIER: 4, 36 | 37 | /** Interval (milliseconds) between cache cleanup operations */ 38 | CACHE_CLEANUP_INTERVAL_MS: 3600000, // 1 hour 39 | 40 | /** Prefix for calendar event cache keys in localStorage */ 41 | EVENT_CACHE_KEY_PREFIX: 'cache_data_', 42 | }; 43 | 44 | /** 45 | * Logging-related constants 46 | */ 47 | export const LOGGING = { 48 | /** 49 | * Current log level 50 | * 0 = ERROR, 1 = WARN, 2 = INFO, 3 = DEBUG 51 | */ 52 | CURRENT_LOG_LEVEL: 3, 53 | 54 | /** Standard prefix for log messages */ 55 | PREFIX: '📅 Calendar Card Pro', 56 | }; 57 | 58 | //----------------------------------------------------------------------------- 59 | // UI BEHAVIOR & INTERACTIONS 60 | //----------------------------------------------------------------------------- 61 | 62 | /** 63 | * Timing-related constants 64 | */ 65 | export const TIMING = { 66 | /** Hold indicator threshold in milliseconds */ 67 | HOLD_THRESHOLD: 500, 68 | 69 | /** Hold indicator transition duration in milliseconds */ 70 | HOLD_INDICATOR_TRANSITION: 200, 71 | 72 | /** Hold indicator fadeout duration in milliseconds */ 73 | HOLD_INDICATOR_FADEOUT: 300, 74 | 75 | /** Threshold in milliseconds for refreshing data when returning to a tab */ 76 | VISIBILITY_REFRESH_THRESHOLD: 300000, // 5 minutes 77 | }; 78 | 79 | /** 80 | * DOM and UI constants 81 | */ 82 | export const UI = { 83 | /** Week/month horizontal separator spacing multipliers */ 84 | SEPARATOR_SPACING: { 85 | /** Multiplier for week separators (1x day_spacing) */ 86 | WEEK: 1, 87 | /** Multiplier for month separators (2x day_spacing) */ 88 | MONTH: 1.5, 89 | }, 90 | 91 | /** Opacity for hold indicators */ 92 | HOLD_INDICATOR_OPACITY: 0.2, 93 | 94 | /** Hold indicator sizes */ 95 | HOLD_INDICATOR: { 96 | /** Size for touch devices */ 97 | TOUCH_SIZE: 100, 98 | /** Size for mouse/pointer devices */ 99 | POINTER_SIZE: 50, 100 | }, 101 | }; 102 | 103 | /** 104 | * Default list of country names to remove when remove_location_country is true 105 | * These are commonly used country names across different calendars 106 | */ 107 | export const COUNTRY_NAMES: string[] = [ 108 | 'Germany', 109 | 'Deutschland', 110 | 'United States', 111 | 'USA', 112 | 'United States of America', 113 | 'United Kingdom', 114 | 'Great Britain', 115 | 'France', 116 | 'Italy', 117 | 'Italia', 118 | 'Spain', 119 | 'España', 120 | 'Netherlands', 121 | 'Nederland', 122 | 'Austria', 123 | 'Österreich', 124 | 'Switzerland', 125 | 'Schweiz', 126 | ]; 127 | -------------------------------------------------------------------------------- /src/rendering/editor.styles.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/order */ 2 | /** 3 | * Styling for the Calendar Card Pro editor 4 | */ 5 | 6 | import { css } from 'lit'; 7 | 8 | export default css` 9 | ha-textfield, 10 | ha-select, 11 | ha-formfield, 12 | ha-entity-picker, 13 | ha-icon-picker { 14 | display: block; 15 | margin: 8px 0; 16 | } 17 | 18 | .card-config { 19 | display: flex; 20 | flex-direction: column; 21 | padding: 4px 0; 22 | } 23 | 24 | .helper-text { 25 | color: var(--secondary-text-color); 26 | font-size: 10px; 27 | line-height: 1.1; 28 | margin-top: -4px; 29 | margin-bottom: 8px; 30 | } 31 | 32 | h3 { 33 | margin: 24px 0 6px 0; 34 | font-size: 14px; 35 | } 36 | 37 | h3:first-of-type { 38 | margin-top: 8px; 39 | } 40 | 41 | h4 { 42 | margin: 24px 0 6px 0; 43 | } 44 | 45 | h5 { 46 | margin: 2px 0 0 0; 47 | } 48 | 49 | .panel-content { 50 | padding: 8px 0 12px 0; 51 | } 52 | 53 | .action-config { 54 | display: flex; 55 | flex-direction: column; 56 | } 57 | 58 | ha-expansion-panel { 59 | margin: 8px 0; 60 | } 61 | 62 | ha-button { 63 | margin: 8px 0; 64 | } 65 | 66 | .indicator-field { 67 | display: flex; 68 | flex-direction: column; 69 | margin: 8px 0; 70 | } 71 | 72 | ha-formfield { 73 | display: flex; 74 | align-items: center; 75 | padding: 8px 0; 76 | } 77 | 78 | .date-input { 79 | position: relative; 80 | margin-bottom: 16px; 81 | width: 100%; 82 | } 83 | 84 | .date-input .mdc-text-field { 85 | width: 100%; 86 | height: 56px; 87 | border-radius: 4px 4px 0 0; 88 | padding: 0; 89 | background-color: var( 90 | --mdc-text-field-fill-color, 91 | var(--input-fill-color, rgba(var(--rgb-primary-text-color), 0.06)) 92 | ); 93 | border-bottom: 1px solid var(--mdc-text-field-idle-line-color, var(--secondary-text-color)); 94 | transition: 95 | background-color 15ms linear, 96 | border-bottom-color 15ms linear; 97 | box-sizing: border-box; 98 | position: relative; 99 | overflow: hidden; /* Important for containing the ripple */ 100 | } 101 | 102 | .date-input .mdc-text-field__ripple { 103 | position: absolute; 104 | top: 0; 105 | left: 0; 106 | width: 100%; 107 | height: 100%; 108 | pointer-events: none; 109 | opacity: 0; /* Hidden by default */ 110 | background-color: var(--mdc-ripple-color, var(--primary-text-color)); 111 | transition: 112 | opacity 15ms linear, 113 | background-color 15ms linear; 114 | z-index: 1; 115 | } 116 | 117 | .date-input .mdc-floating-label { 118 | position: absolute; 119 | top: 8px; 120 | left: 4px; 121 | -webkit-font-smoothing: antialiased; 122 | font-family: var( 123 | --mdc-typography-subtitle1-font-family, 124 | var(--mdc-typography-font-family, Roboto, sans-serif) 125 | ); 126 | font-size: var(--mdc-typography-subtitle1-font-size, 1rem); 127 | font-weight: var(--mdc-typography-subtitle1-font-weight, 400); 128 | letter-spacing: var(--mdc-typography-subtitle1-letter-spacing, 0.009375em); 129 | text-transform: var(--mdc-typography-subtitle1-text-transform, inherit); 130 | transform: scale(0.75); 131 | color: var(--mdc-select-label-ink-color, rgba(0, 0, 0, 0.6)); 132 | pointer-events: none; 133 | transition: color 15ms linear; 134 | z-index: 2; 135 | } 136 | 137 | .date-input .value-container { 138 | display: flex; 139 | align-items: center; 140 | height: 100%; 141 | padding: 8px 16px 8px; 142 | position: relative; 143 | z-index: 2; 144 | } 145 | 146 | .date-input .value-text { 147 | -webkit-font-smoothing: antialiased; 148 | font-family: var( 149 | --mdc-typography-subtitle1-font-family, 150 | var(--mdc-typography-font-family, Roboto, sans-serif) 151 | ); 152 | font-size: var(--mdc-typography-subtitle1-font-size, 1rem); 153 | line-height: var(--mdc-typography-subtitle1-line-height, 1.75rem); 154 | font-weight: var(--mdc-typography-subtitle1-font-weight, 400); 155 | letter-spacing: var(--mdc-typography-subtitle1-letter-spacing, 0.009375em); 156 | text-transform: var(--mdc-typography-subtitle1-text-transform, inherit); 157 | color: var(--primary-text-color); 158 | } 159 | 160 | .date-input input[type='date'] { 161 | position: absolute; 162 | top: 0; 163 | left: 0; 164 | width: 100%; 165 | height: 100%; 166 | opacity: 0; 167 | cursor: pointer; 168 | z-index: 3; 169 | } 170 | 171 | /* Handle focus and hover states with JavaScript toggling classes */ 172 | .date-input .mdc-text-field.focused { 173 | border-bottom: 2px solid var(--primary-color); 174 | } 175 | 176 | .date-input .mdc-text-field.focused .mdc-floating-label { 177 | color: var(--primary-color); 178 | } 179 | `; 180 | -------------------------------------------------------------------------------- /src/interaction/actions.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/order */ 2 | /** 3 | * Action handling for Calendar Card Pro 4 | * 5 | * This module contains functions for processing and executing 6 | * user actions (tap, hold, etc.) 7 | */ 8 | 9 | import * as Types from '../config/types'; 10 | import * as Logger from '../utils/logger'; 11 | 12 | //----------------------------------------------------------------------------- 13 | // PUBLIC API 14 | //----------------------------------------------------------------------------- 15 | 16 | /** 17 | * Extract primary entity ID from configured entities 18 | * 19 | * @param entities - Entity configuration array 20 | * @returns The primary entity ID or undefined if not available 21 | */ 22 | export function getPrimaryEntityId( 23 | entities: Array, 24 | ): string | undefined { 25 | if (!entities || !entities.length) return undefined; 26 | 27 | const firstEntity = entities[0]; 28 | return typeof firstEntity === 'string' ? firstEntity : firstEntity.entity; 29 | } 30 | 31 | /** 32 | * Handle an action based on its configuration 33 | * 34 | * @param actionConfig - Action configuration object 35 | * @param hass - Home Assistant interface 36 | * @param element - Element that triggered the action 37 | * @param entityId - Optional entity ID for the action 38 | * @param toggleCallback - Optional callback for toggle action 39 | */ 40 | export function handleAction( 41 | actionConfig: Types.ActionConfig, 42 | hass: Types.Hass | null, 43 | element: Element, 44 | entityId?: string, 45 | toggleCallback?: () => void, 46 | ): void { 47 | if (!actionConfig || !hass) return; 48 | 49 | const ctx: Types.ActionContext = { 50 | element, 51 | hass, 52 | entityId, 53 | toggleCallback, 54 | }; 55 | 56 | // Execute different types of actions based on the configuration 57 | switch (actionConfig.action) { 58 | case 'more-info': 59 | fireMoreInfo(entityId, ctx); 60 | break; 61 | 62 | case 'navigate': 63 | if (actionConfig.navigation_path) { 64 | navigate(actionConfig.navigation_path, ctx); 65 | } 66 | break; 67 | 68 | case 'url': 69 | if (actionConfig.url_path) { 70 | openUrl(actionConfig.url_path, ctx); 71 | } 72 | break; 73 | 74 | case 'toggle': 75 | if (toggleCallback) { 76 | toggleCallback(); 77 | } 78 | break; 79 | 80 | case 'expand': // Add this case to handle the expand action 81 | if (toggleCallback) { 82 | toggleCallback(); 83 | } 84 | break; 85 | 86 | case 'call-service': { 87 | if (!actionConfig.service) return; 88 | 89 | const [domain, service] = actionConfig.service.split('.', 2); 90 | if (!domain || !service) return; 91 | 92 | hass.callService(domain, service, actionConfig.service_data || {}); 93 | break; 94 | } 95 | 96 | case 'fire-dom-event': { 97 | fireDomEvent(element, ctx); 98 | break; 99 | } 100 | 101 | case 'none': 102 | default: 103 | // Do nothing for 'none' action 104 | break; 105 | } 106 | } 107 | 108 | //----------------------------------------------------------------------------- 109 | // PRIVATE ACTION HANDLERS 110 | //----------------------------------------------------------------------------- 111 | 112 | /** 113 | * Fire more-info event for an entity 114 | * 115 | * @param entityId - Entity ID to show more info for 116 | * @param ctx - Action context 117 | */ 118 | function fireMoreInfo(entityId: string | undefined, ctx: Types.ActionContext): void { 119 | if (!entityId) return; 120 | 121 | // Create and dispatch a Home Assistant more-info event 122 | const event = new CustomEvent('hass-more-info', { 123 | bubbles: true, 124 | composed: true, 125 | detail: { entityId }, 126 | }); 127 | 128 | ctx.element.dispatchEvent(event); 129 | Logger.debug(`Fired more-info event for ${entityId}`); 130 | } 131 | 132 | /** 133 | * Navigate to a path in Home Assistant 134 | * 135 | * @param path - Navigation path 136 | * @param ctx - Action context 137 | */ 138 | function navigate(path: string, ctx: Types.ActionContext): void { 139 | // Create and dispatch a location-changed event 140 | const event = new CustomEvent('location-changed', { 141 | bubbles: true, 142 | composed: true, 143 | detail: { replace: false }, 144 | }); 145 | 146 | // Use window.history for navigation 147 | if (window.history) { 148 | window.history.pushState(null, '', path); 149 | } 150 | 151 | // Dispatch the event to notify HA of the navigation 152 | ctx.element.dispatchEvent(event); 153 | Logger.debug(`Navigated to ${path}`); 154 | } 155 | 156 | /** 157 | * Open a URL in a new tab or the current window 158 | * 159 | * @param path - URL to open 160 | * @param ctx - Action context 161 | */ 162 | function openUrl(path: string, _ctx: Types.ActionContext): void { 163 | window.open(path, '_blank'); 164 | Logger.debug(`Opened URL ${path}`); 165 | } 166 | 167 | /** 168 | * Fire a DOM event for custom handlers 169 | * 170 | * @param element - Element to fire the event from 171 | * @param ctx - Action context 172 | */ 173 | function fireDomEvent(element: Element, _ctx: Types.ActionContext): void { 174 | const event = new Event('calendar-card-action', { 175 | bubbles: true, 176 | composed: true, 177 | }); 178 | 179 | element.dispatchEvent(event); 180 | Logger.debug('Fired DOM event calendar-card-action'); 181 | } 182 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Calendar Card Pro 2 | 3 | Thank you for your interest in contributing to Calendar Card Pro! This document outlines the process for contributing to the project, including code changes, translations, and bug reports. 4 | 5 | ## Understanding the Codebase 6 | 7 | Before contributing code, I strongly recommend reviewing my [architecture documentation](./docs/architecture.md), which explains: 8 | 9 | - Module organization and responsibilities 10 | - Data flow and event handling 11 | - Performance optimization techniques 12 | - Design principles and patterns 13 | 14 | ## Development Environment Setup 15 | 16 | 1. Fork the repository 17 | 2. Clone your fork: `git clone https://github.com/[your-username]/calendar-card-pro.git` 18 | 3. Install dependencies: `npm install` 19 | 4. Start development mode: `npm run dev` 20 | 5. The compiled card will be available in `dist/calendar-card-pro.js` 21 | 6. For testing in Home Assistant, follow the [testing instructions](#testing-in-home-assistant) 22 | 23 | ## Branch Structure 24 | 25 | The repository follows this branch structure: 26 | 27 | - **`main`**: Production-ready code, each release is tagged 28 | - **`dev`**: Ongoing development branch where features are integrated 29 | - **Feature branches**: Individual features/fixes (branch from `dev`, merge back to `dev` via PRs) 30 | 31 | ### Workflow 32 | 33 | 1. Create feature branches from `dev` for new features or bug fixes 34 | 2. Develop and test your changes in the feature branch 35 | 3. Submit a PR to merge your feature branch into `dev` 36 | 4. After review and testing, changes in `dev` are periodically merged into `main` for releases 37 | 38 | ## Testing in Home Assistant 39 | 40 | To test your changes in a real Home Assistant environment: 41 | 42 | 1. Copy `dist/calendar-card-pro-dev.js` to your Home Assistant's `www/community/calendar-card-pro/` folder 43 | 2. Add the resource to Home Assistant: 44 | ```yaml 45 | url: /hacsfiles/calendar-card-pro/calendar-card-pro-dev.js 46 | type: module 47 | ``` 48 | 3. Add the card to your dashboard using type: `custom:calendar-card-pro-dev` 49 | 4. Test with various calendar types and configurations 50 | 5. Verify performance with both small and large event sets 51 | 52 | > **💡 Pro Tip: Defeating Home Assistant's Aggressive Caching** 53 | > 54 | > Home Assistant aggressively caches resources, and sometimes your changes won't appear even after clearing browser cache or restarting Home Assistant. To solve this: 55 | > 56 | > 1. Add a version query parameter to your resource URL: 57 | > ```yaml 58 | > url: /hacsfiles/calendar-card-pro/calendar-card-pro-dev.js?v=1 59 | > type: module 60 | > ``` 61 | > 2. Each time you update the file and want to test new changes, increment the version number: 62 | > ```yaml 63 | > url: /hacsfiles/calendar-card-pro/calendar-card-pro-dev.js?v=2 64 | > type: module 65 | > ``` 66 | 67 | > **Note:** The build system automatically generates different filenames depending on the build mode: 68 | > 69 | > - Development build (`npm run dev`): Creates `calendar-card-pro-dev.js` 70 | > - Production build (`npm run build`): Creates `calendar-card-pro.js` 71 | > 72 | > This naming convention allows both development and production versions to coexist in the same Home Assistant directory, making it easier to test changes alongside the stable version installed via HACS. The Home Assistant card element name also includes the `-dev` suffix in development mode, ensuring there's no conflict between versions. 73 | 74 | ## Adding New Translations 75 | 76 | Calendar Card Pro supports multiple languages through JSON translation files. Here's how to add a new language: 77 | 78 | ### Method 1: Contributing a Language File to the Repository 79 | 80 | 1. **Create a new file** in `src/translations/languages/[lang-code].json` 81 | 2. **Copy the structure** from an existing language file (e.g., `en.json`) 82 | 3. **Update the localize file** in `src/translations/localize.ts` to include your new language 83 | 4. **Update the dayjs file** in `src/translations/dayjs.ts` to include your new language 84 | 5. **Translate all strings** to your language 85 | 6. **Submit a Pull Request** with your changes 86 | 87 | Example language file structure: 88 | 89 | ```json 90 | { 91 | "daysOfWeek": ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"], 92 | "fullDaysOfWeek": ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"], 93 | "months": ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"], 94 | "allDay": "all-day", 95 | "multiDay": "until", 96 | "at": "at", 97 | "endsToday": "ends today", 98 | "endsTomorrow": "ends tomorrow", 99 | "noEvents": "No upcoming events", 100 | "loading": "Loading calendar events...", 101 | "error": "Error: Calendar entity not found or improperly configured" 102 | } 103 | ``` 104 | 105 | ### Method 2: Testing Translations During Development 106 | 107 | For quickly testing a language without modifying the source code, you can use the dynamic translation registration API: 108 | 109 | 1. Create a script file in Home Assistant (e.g., `/config/www/calendar-translation-dev.js`): 110 | 111 | ```javascript 112 | // Development helper for testing new translations 113 | window.addEventListener('load', () => { 114 | setTimeout(() => { 115 | if (window.CalendarCardProLocalize) { 116 | // Register test translation 117 | window.CalendarCardProLocalize.addTranslations('test', { 118 | daysOfWeek: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'], 119 | fullDaysOfWeek: [ 120 | 'Sunday', 121 | 'Monday', 122 | 'Tuesday', 123 | 'Wednesday', 124 | 'Thursday', 125 | 'Friday', 126 | 'Saturday', 127 | ], 128 | months: [ 129 | 'Jan', 130 | 'Feb', 131 | 'Mar', 132 | 'Apr', 133 | 'May', 134 | 'Jun', 135 | 'Jul', 136 | 'Aug', 137 | 'Sep', 138 | 'Oct', 139 | 'Nov', 140 | 'Dec', 141 | ], 142 | allDay: 'TEST all-day', 143 | multiDay: 'TEST until', 144 | at: 'TEST at', 145 | endsToday: 'TEST ends today', 146 | endsTomorrow: 'TEST ends tomorrow', 147 | noEvents: 'TEST No upcoming events', 148 | loading: 'TEST Loading calendar events...', 149 | error: 'TEST Error: Calendar entity not found or improperly configured', 150 | }); 151 | console.log('Test language registered for Calendar Card Pro!'); 152 | } 153 | }, 2000); 154 | }); 155 | ``` 156 | 157 | 2. Add this script as a resource in Home Assistant 158 | 3. Set `language: 'test'` in your card configuration to test the translation 159 | 160 | > Note: This method is primarily intended for development and testing. For permanent language additions, please contribute directly to the repository via pull request. 161 | 162 | ## Code Style and Quality Standards 163 | 164 | - Follow TypeScript best practices and maintain strict typing 165 | - Use the established module structure - place new code in the appropriate module 166 | - Follow the existing patterns for similar functionality 167 | - Document all public functions with JSDoc comments 168 | - Run linting before submitting: `npm run lint --fix` 169 | - Keep bundle size in mind - avoid large dependencies 170 | 171 | ## Pull Request Process 172 | 173 | 1. Create a feature branch from your fork (`feature/my-new-feature`) 174 | 2. Make your changes following our code style guidelines 175 | 3. Ensure all linting passes (`npm run lint`) 176 | 4. Build and test your changes (`npm run build`) 177 | 5. Submit a PR against the `main` branch 178 | 6. Respond to any feedback during code review 179 | 180 | ## Bug Reports 181 | 182 | When filing a bug report, please include: 183 | 184 | 1. A clear description of the issue 185 | 2. Steps to reproduce the problem 186 | 3. Expected behavior 187 | 4. Actual behavior 188 | 5. Version of Calendar Card Pro and Home Assistant 189 | 6. Browser and OS information 190 | 7. Screenshots if applicable 191 | 192 | Thank you for contributing to Calendar Card Pro! 193 | -------------------------------------------------------------------------------- /src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/order */ 2 | /** 3 | * Logging utilities for Calendar Card Pro 4 | * Provides consistent log formatting, level-based filtering, and error handling 5 | */ 6 | 7 | import * as Constants from '../config/constants'; 8 | 9 | // Add a flag to ensure the banner only shows once per session 10 | let BANNER_SHOWN = false; 11 | 12 | // Different log levels - keeping enum in logger-utils.ts 13 | export enum LogLevel { 14 | ERROR = 0, 15 | WARN = 1, 16 | INFO = 2, 17 | DEBUG = 3, 18 | } 19 | 20 | // Use the constant from constants.ts as the default value 21 | let currentLogLevel = Constants.LOGGING.CURRENT_LOG_LEVEL; 22 | 23 | // Styling for log messages - keeping in logger-utils.ts 24 | const LOG_STYLES = { 25 | // Title pill (left side - dark grey with emoji) 26 | title: [ 27 | 'background: #424242', 28 | 'color: white', 29 | 'display: inline-block', 30 | 'line-height: 20px', 31 | 'text-align: center', 32 | 'border-radius: 20px 0 0 20px', 33 | 'font-size: 12px', 34 | 'font-weight: bold', 35 | 'padding: 4px 8px 4px 12px', 36 | 'margin: 5px 0', 37 | ].join(';'), 38 | 39 | // Version pill (right side - pale blue) 40 | version: [ 41 | 'background: #4fc3f7', 42 | 'color: white', 43 | 'display: inline-block', 44 | 'line-height: 20px', 45 | 'text-align: center', 46 | 'border-radius: 0 20px 20px 0', 47 | 'font-size: 12px', 48 | 'font-weight: bold', 49 | 'padding: 4px 12px 4px 8px', 50 | 'margin: 5px 0', 51 | ].join(';'), 52 | 53 | // Standard prefix (non-pill version for regular logs) 54 | prefix: ['color: #4fc3f7', 'font-weight: bold'].join(';'), 55 | 56 | // Error styling 57 | error: ['color: #f44336', 'font-weight: bold'].join(';'), 58 | 59 | // Warning styling 60 | warn: ['color: #ff9800', 'font-weight: bold'].join(';'), 61 | }; 62 | 63 | //----------------------------------------------------------------------------- 64 | // INITIALIZATION FUNCTIONS 65 | //----------------------------------------------------------------------------- 66 | 67 | /** 68 | * Initialize the logger with the component version 69 | * @param version Current component version 70 | */ 71 | export function initializeLogger(version: string): void { 72 | // Show version banner (always show this regardless of log level) 73 | printVersionBanner(version); 74 | } 75 | 76 | /** 77 | * Print the welcome banner with version info 78 | * @param version Component version 79 | */ 80 | export function printVersionBanner(version: string): void { 81 | // Only show banner once per browser session 82 | if (BANNER_SHOWN) return; 83 | 84 | console.groupCollapsed( 85 | `%c${Constants.LOGGING.PREFIX}%cv${version} `, 86 | LOG_STYLES.title, 87 | LOG_STYLES.version, 88 | ); 89 | console.log( 90 | '%c Description: %c A calendar card that supports multiple calendars with individual styling. ', 91 | 'font-weight: bold', 92 | 'font-weight: normal', 93 | ); 94 | console.log( 95 | '%c GitHub: %c https://github.com/alexpfau/calendar-card-pro ', 96 | 'font-weight: bold', 97 | 'font-weight: normal', 98 | ); 99 | console.groupEnd(); 100 | 101 | // Mark banner as shown 102 | BANNER_SHOWN = true; 103 | } 104 | 105 | //----------------------------------------------------------------------------- 106 | // PRIMARY PUBLIC API FUNCTIONS 107 | //----------------------------------------------------------------------------- 108 | 109 | /** 110 | * Enhanced error logging that handles different error types and contexts 111 | * Consolidates error, logError and handleApiError into a single flexible function 112 | * 113 | * @param messageOrError - Error object, message string, or other value 114 | * @param context - Optional context (string, object, or unknown) 115 | * @param data - Additional data to include in the log 116 | */ 117 | export function error( 118 | messageOrError: string | Error | unknown, 119 | context?: string | Record | unknown, 120 | ...data: unknown[] 121 | ): void { 122 | if (currentLogLevel < LogLevel.ERROR) return; 123 | 124 | // Convert unknown context to a safe format 125 | const safeContext = formatUnknownContext(context); 126 | 127 | // Process based on error type and context type 128 | if (messageOrError instanceof Error) { 129 | // Case 1: Error object 130 | const errorMessage = messageOrError.message || 'Unknown error'; 131 | const contextInfo = typeof safeContext === 'string' ? ` during ${safeContext}` : ''; 132 | const [formattedMsg, style] = formatLogMessage( 133 | `Error${contextInfo}: ${errorMessage}`, 134 | LOG_STYLES.error, 135 | ); 136 | 137 | console.error(formattedMsg, style); 138 | 139 | // Always log stack trace for Error objects 140 | if (messageOrError.stack) { 141 | console.error(messageOrError.stack); 142 | } 143 | 144 | // Add context object if provided 145 | if (safeContext && typeof safeContext === 'object') { 146 | console.error('Context:', { 147 | ...safeContext, 148 | timestamp: new Date().toISOString(), 149 | }); 150 | } 151 | 152 | // Include any additional data 153 | if (data.length > 0) { 154 | console.error('Additional data:', ...data); 155 | } 156 | } else if (typeof messageOrError === 'string') { 157 | // Case 2: String message 158 | const contextInfo = typeof safeContext === 'string' ? ` during ${safeContext}` : ''; 159 | const [formattedMsg, style] = formatLogMessage( 160 | `${messageOrError}${contextInfo}`, 161 | LOG_STYLES.error, 162 | ); 163 | 164 | if (safeContext && typeof safeContext === 'object') { 165 | // If context is an object, include it in the log 166 | console.error(formattedMsg, style, { 167 | context: { 168 | ...safeContext, 169 | timestamp: new Date().toISOString(), 170 | }, 171 | ...(data.length > 0 ? { additionalData: data } : {}), 172 | }); 173 | } else if (data.length > 0) { 174 | // Just include additional data 175 | console.error(formattedMsg, style, ...data); 176 | } else { 177 | // Simple error message 178 | console.error(formattedMsg, style); 179 | } 180 | } else { 181 | // Case 3: Unknown error type 182 | const contextInfo = typeof safeContext === 'string' ? ` during ${safeContext}` : ''; 183 | const [formattedMsg, style] = formatLogMessage( 184 | `Unknown error${contextInfo}:`, 185 | LOG_STYLES.error, 186 | ); 187 | 188 | console.error(formattedMsg, style, messageOrError); 189 | 190 | // Add context object if provided 191 | if (safeContext && typeof safeContext === 'object') { 192 | console.error('Context:', { 193 | ...safeContext, 194 | timestamp: new Date().toISOString(), 195 | }); 196 | } 197 | 198 | // Include any additional data 199 | if (data.length > 0) { 200 | console.error('Additional data:', ...data); 201 | } 202 | } 203 | } 204 | 205 | /** 206 | * Log a warning message 207 | */ 208 | export function warn(message: string, ...data: unknown[]): void { 209 | simpleLog(LogLevel.WARN, message, LOG_STYLES.warn, console.warn, ...data); 210 | } 211 | 212 | /** 213 | * Log an info message 214 | */ 215 | export function info(message: string, ...data: unknown[]): void { 216 | simpleLog(LogLevel.INFO, message, LOG_STYLES.prefix, console.log, ...data); 217 | } 218 | 219 | /** 220 | * Log a debug message 221 | */ 222 | export function debug(message: string, ...data: unknown[]): void { 223 | simpleLog(LogLevel.DEBUG, message, LOG_STYLES.prefix, console.log, ...data); 224 | } 225 | 226 | //----------------------------------------------------------------------------- 227 | // INTERNAL HELPER FUNCTIONS 228 | //----------------------------------------------------------------------------- 229 | 230 | /** 231 | * Internal helper for basic log levels (warn, info, debug) 232 | * @param level - Log level for filtering 233 | * @param message - Message to log 234 | * @param style - Style to apply to the message 235 | * @param consoleMethod - Console method to use 236 | * @param data - Additional data to log 237 | */ 238 | function simpleLog( 239 | level: LogLevel, 240 | message: string, 241 | style: string, 242 | consoleMethod: (...args: unknown[]) => void, 243 | ...data: unknown[] 244 | ): void { 245 | if (currentLogLevel < level) return; 246 | 247 | const [formattedMsg, styleArg] = formatLogMessage(message, style); 248 | if (data.length > 0) { 249 | consoleMethod(formattedMsg, styleArg, ...data); 250 | } else { 251 | consoleMethod(formattedMsg, styleArg); 252 | } 253 | } 254 | 255 | /** 256 | * Format a log message with consistent prefix and styling 257 | * @param message The message to format 258 | * @param style The style to apply 259 | * @returns Tuple of [formattedMessage, style] for console methods 260 | */ 261 | function formatLogMessage(message: string, style: string): [string, string] { 262 | return [`%c[${Constants.LOGGING.PREFIX}] ${message}`, style]; 263 | } 264 | 265 | /** 266 | * Process unknown context into a usable format for logging 267 | * @param context - Any context value that might be provided 268 | * @returns A string, object, or undefined that can be safely used in logs 269 | */ 270 | function formatUnknownContext(context: unknown): string | Record | undefined { 271 | if (context === undefined || context === null) { 272 | return undefined; 273 | } 274 | 275 | if (typeof context === 'string') { 276 | return context; 277 | } 278 | 279 | if (typeof context === 'object') { 280 | try { 281 | // Try to safely convert to Record 282 | return { ...(context as Record) }; 283 | } catch { 284 | // If conversion fails, stringify it 285 | try { 286 | return { value: JSON.stringify(context) }; 287 | } catch { 288 | return { value: String(context) }; 289 | } 290 | } 291 | } 292 | 293 | // For primitive values, just convert to string 294 | return String(context); 295 | } 296 | -------------------------------------------------------------------------------- /src/config/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Type definitions for Calendar Card Pro 3 | * 4 | * This file contains all type definitions used throughout the Calendar Card Pro application. 5 | */ 6 | 7 | // ----------------------------------------------------------------------------- 8 | // CORE CONFIGURATION 9 | // ----------------------------------------------------------------------------- 10 | 11 | /** 12 | * Main configuration interface for the card 13 | */ 14 | export interface Config { 15 | // Core settings 16 | entities: Array; 17 | start_date?: string; 18 | days_to_show: number; 19 | compact_days_to_show?: number; 20 | compact_events_to_show?: number; 21 | compact_events_complete_days?: boolean; 22 | show_empty_days: boolean; 23 | filter_duplicates: boolean; 24 | split_multiday_events: boolean; 25 | language?: string; 26 | 27 | // Header 28 | title?: string; 29 | title_font_size?: string; 30 | title_color?: string; 31 | 32 | // Layout and spacing 33 | background_color: string; 34 | accent_color: string; 35 | vertical_line_width: string; 36 | day_spacing: string; 37 | event_spacing: string; 38 | additional_card_spacing: string; 39 | max_height: string; 40 | height: string; 41 | 42 | // Week numbers and horizontal separators 43 | first_day_of_week: 'sunday' | 'monday' | 'system'; 44 | show_week_numbers: null | 'iso' | 'simple'; 45 | show_current_week_number: boolean; 46 | week_number_font_size: string; 47 | week_number_color: string; 48 | week_number_background_color: string; 49 | day_separator_width: string; 50 | day_separator_color: string; 51 | week_separator_width: string; 52 | week_separator_color: string; 53 | month_separator_width: string; 54 | month_separator_color: string; 55 | 56 | // Today indicator 57 | today_indicator: string | boolean; 58 | today_indicator_position: string; 59 | today_indicator_color: string; 60 | today_indicator_size: string; 61 | 62 | // Date column 63 | date_vertical_alignment: string; 64 | weekday_font_size: string; 65 | weekday_color: string; 66 | day_font_size: string; 67 | day_color: string; 68 | show_month: boolean; 69 | month_font_size: string; 70 | month_color: string; 71 | weekend_weekday_color?: string; 72 | weekend_day_color?: string; 73 | weekend_month_color?: string; 74 | today_weekday_color?: string; 75 | today_day_color?: string; 76 | today_month_color?: string; 77 | 78 | // Event column 79 | event_background_opacity: number; 80 | show_past_events: boolean; 81 | show_countdown: boolean; 82 | show_progress_bar: boolean; 83 | progress_bar_color: string; 84 | progress_bar_height: string; 85 | progress_bar_width: string; 86 | event_font_size: string; 87 | event_color: string; 88 | empty_day_color: string; 89 | show_time: boolean; 90 | show_single_allday_time: boolean; 91 | time_24h: boolean | 'system'; 92 | show_end_time: boolean; 93 | time_font_size: string; 94 | time_color: string; 95 | time_icon_size: string; 96 | show_location: boolean; 97 | remove_location_country: boolean | string; 98 | location_font_size: string; 99 | location_color: string; 100 | location_icon_size: string; 101 | 102 | // Weather 103 | weather?: WeatherConfig; 104 | 105 | // Actions 106 | tap_action: ActionConfig; 107 | hold_action: ActionConfig; 108 | 109 | // Cache and refresh settings 110 | refresh_interval: number; 111 | refresh_on_navigate: boolean; 112 | } 113 | 114 | /** 115 | * Calendar entity configuration 116 | */ 117 | export interface EntityConfig { 118 | entity: string; 119 | label?: string; 120 | color?: string; 121 | accent_color?: string; 122 | show_time?: boolean; 123 | show_location?: boolean; 124 | compact_events_to_show?: number; 125 | blocklist?: string; 126 | allowlist?: string; 127 | split_multiday_events?: boolean; 128 | } 129 | 130 | // Add these interfaces to src/config/types.ts 131 | 132 | /** 133 | * Weather position-specific styling configuration 134 | */ 135 | export interface WeatherPositionConfig { 136 | show_conditions?: boolean; 137 | show_high_temp?: boolean; 138 | show_low_temp?: boolean; 139 | show_temp?: boolean; 140 | icon_size?: string; 141 | font_size?: string; 142 | color?: string; 143 | } 144 | 145 | /** 146 | * Weather configuration 147 | */ 148 | export interface WeatherConfig { 149 | entity?: string; 150 | position?: 'date' | 'event' | 'both'; 151 | date?: WeatherPositionConfig; 152 | event?: WeatherPositionConfig; 153 | } 154 | 155 | /** 156 | * Raw weather forecast data from Home Assistant 157 | */ 158 | export interface WeatherForecast { 159 | datetime: string; 160 | condition: string; 161 | temperature: number; 162 | templow?: number; 163 | precipitation?: number; 164 | precipitation_probability?: number; 165 | wind_speed?: number; 166 | wind_bearing?: number; 167 | humidity?: number; 168 | } 169 | 170 | /** 171 | * Processed weather data for use in templates 172 | */ 173 | export interface WeatherData { 174 | icon: string; 175 | condition: string; 176 | temperature: string | number; 177 | templow?: string | number; 178 | datetime: string; 179 | hour?: number; 180 | precipitation?: number; 181 | precipitation_probability?: number; 182 | } 183 | 184 | /** 185 | * Weather forecasts organized by type and date/time 186 | */ 187 | export interface WeatherForecasts { 188 | daily?: Record; 189 | hourly?: Record; 190 | } 191 | 192 | // ----------------------------------------------------------------------------- 193 | // CALENDAR DATA STRUCTURES 194 | // ----------------------------------------------------------------------------- 195 | 196 | /** 197 | * Calendar event data structure 198 | */ 199 | export interface CalendarEventData { 200 | readonly start: { readonly dateTime?: string; readonly date?: string }; 201 | readonly end: { readonly dateTime?: string; readonly date?: string }; 202 | summary?: string; 203 | location?: string; 204 | _entityId?: string; 205 | _entityLabel?: string; 206 | _isEmptyDay?: boolean; 207 | _matchedConfig?: EntityConfig; 208 | time?: string; 209 | } 210 | 211 | /** 212 | * Grouped events by day 213 | */ 214 | export interface EventsByDay { 215 | weekday: string; 216 | day: number; 217 | month: string; 218 | timestamp: number; 219 | events: CalendarEventData[]; 220 | weekNumber?: number | null; // Changed from number | undefined to number | null 221 | isFirstDayOfWeek?: boolean; 222 | isFirstDayOfMonth?: boolean; 223 | monthNumber?: number; 224 | } 225 | 226 | /** 227 | * Cache entry structure 228 | */ 229 | export interface CacheEntry { 230 | events: CalendarEventData[]; 231 | timestamp: number; 232 | } 233 | 234 | // ----------------------------------------------------------------------------- 235 | // USER INTERACTION 236 | // ----------------------------------------------------------------------------- 237 | 238 | /** 239 | * Action configuration for tap and hold actions 240 | */ 241 | export interface ActionConfig { 242 | action: string; 243 | navigation_path?: string; 244 | service?: string; 245 | service_data?: object; 246 | url_path?: string; 247 | open_tab?: string; 248 | } 249 | 250 | /** 251 | * Context data for action execution 252 | */ 253 | export interface ActionContext { 254 | element: Element; 255 | hass: Hass | null; 256 | entityId?: string; 257 | toggleCallback?: () => void; 258 | } 259 | 260 | /** 261 | * Configuration for interaction module 262 | */ 263 | export interface InteractionConfig { 264 | tapAction?: ActionConfig; 265 | holdAction?: ActionConfig; 266 | context: ActionContext; 267 | } 268 | 269 | // ----------------------------------------------------------------------------- 270 | // HOME ASSISTANT INTEGRATION 271 | // ----------------------------------------------------------------------------- 272 | 273 | /** 274 | * Home Assistant interface 275 | */ 276 | export interface Hass { 277 | states: Record; 278 | callApi: (method: string, path: string, parameters?: object) => Promise; 279 | callService: (domain: string, service: string, serviceData?: object) => void; 280 | locale?: { 281 | language: string; 282 | time_format?: string; 283 | }; 284 | connection?: { 285 | subscribeEvents: (callback: (event: unknown) => void, eventType: string) => Promise<() => void>; 286 | subscribeMessage: ( 287 | callback: (message: WeatherForecastMessage) => void, 288 | options: SubscribeMessageOptions, 289 | ) => () => void; 290 | }; 291 | formatEntityState?: (stateObj: HassEntity, state: string) => string; 292 | } 293 | 294 | /** 295 | * Weather forecast message structure received from Home Assistant 296 | */ 297 | export interface WeatherForecastMessage { 298 | forecast: WeatherForecast[]; 299 | forecast_type?: string; 300 | [key: string]: unknown; 301 | } 302 | 303 | /** 304 | * Home Assistant subscribe message options 305 | */ 306 | export interface SubscribeMessageOptions { 307 | type: string; 308 | entity_id: string; 309 | forecast_type?: string; 310 | [key: string]: unknown; 311 | } 312 | 313 | /** 314 | * Home Assistant state object type 315 | */ 316 | export interface HassEntity { 317 | state: string; 318 | attributes: Record; 319 | last_changed?: string; 320 | last_updated?: string; 321 | context?: { 322 | id?: string; 323 | parent_id?: string; 324 | user_id?: string | null; 325 | }; 326 | } 327 | 328 | /** 329 | * Custom card registration interface for Home Assistant 330 | */ 331 | export interface CustomCard { 332 | type: string; 333 | name: string; 334 | preview: boolean; 335 | description: string; 336 | documentationURL?: string; 337 | } 338 | 339 | /** 340 | * Home Assistant more-info event interface 341 | */ 342 | export interface HassMoreInfoEvent extends CustomEvent { 343 | detail: { 344 | entityId: string; 345 | }; 346 | } 347 | 348 | // ----------------------------------------------------------------------------- 349 | // UI SUPPORT 350 | // ----------------------------------------------------------------------------- 351 | 352 | /** 353 | * Interface for language translations 354 | */ 355 | export interface Translations { 356 | loading: string; 357 | noEvents: string; 358 | error: string; 359 | allDay: string; 360 | multiDay: string; 361 | at: string; 362 | months: string[]; 363 | daysOfWeek: string[]; 364 | fullDaysOfWeek: string[]; 365 | endsToday: string; 366 | endsTomorrow: string; 367 | editor?: { 368 | [key: string]: string | string[]; 369 | }; 370 | } 371 | -------------------------------------------------------------------------------- /src/utils/weather.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/order */ 2 | /** 3 | * Weather utilities for Calendar Card Pro 4 | * 5 | * Processes and formats weather data from Home Assistant for use in the calendar card. 6 | */ 7 | 8 | import * as Types from '../config/types'; 9 | import * as Logger from './logger'; 10 | import * as FormatUtils from './format'; 11 | 12 | //----------------------------------------------------------------------------- 13 | // CORE WEATHER DATA PROCESSING 14 | //----------------------------------------------------------------------------- 15 | 16 | /** 17 | * Determine which forecast types (daily, hourly) are required based on configuration 18 | * 19 | * @param weatherConfig Weather configuration options 20 | * @returns Array of required forecast types 21 | */ 22 | export function getRequiredForecastTypes( 23 | weatherConfig?: Types.WeatherConfig, 24 | ): Array<'daily' | 'hourly'> { 25 | if (!weatherConfig || !weatherConfig.entity) { 26 | return []; 27 | } 28 | 29 | // Determine required forecast types based on position 30 | const position = weatherConfig.position || 'date'; 31 | 32 | // Date position only needs daily forecasts 33 | if (position === 'date') { 34 | return ['daily']; 35 | } 36 | 37 | // Event position needs both hourly (for timed events) and daily (for all-day events) 38 | if (position === 'event') { 39 | return ['daily', 'hourly']; 40 | } 41 | 42 | // Both positions need both forecast types 43 | return ['daily', 'hourly']; 44 | } 45 | 46 | /** 47 | * Process raw forecast data from Home Assistant 48 | * 49 | * @param forecast Raw forecast data from Home Assistant 50 | * @param entityId Weather entity ID 51 | * @param forecastType Type of forecast ('daily' or 'hourly') 52 | * @returns Processed forecast data indexed by date/time 53 | */ 54 | function processForecastData( 55 | forecast: Array, 56 | forecastType: 'daily' | 'hourly', 57 | ): Record { 58 | const processedForecasts: Record = {}; 59 | 60 | if (!forecast || !Array.isArray(forecast)) { 61 | return processedForecasts; 62 | } 63 | 64 | forecast.forEach((item) => { 65 | if (!item.datetime) { 66 | return; 67 | } 68 | 69 | // Process date/time based on forecast type 70 | let key: string; 71 | let hour: number | undefined; 72 | let date: Date; 73 | 74 | if (forecastType === 'hourly') { 75 | // Parse full ISO datetime for hourly forecasts 76 | date = new Date(item.datetime); 77 | hour = date.getHours(); 78 | 79 | // Use ISO format with hour as key 80 | key = `${FormatUtils.getLocalDateKey(date)}_${hour}`; 81 | } else { 82 | // For daily forecasts, just use the date as key 83 | date = new Date(item.datetime); 84 | key = FormatUtils.getLocalDateKey(date); 85 | } 86 | 87 | // Get icon based on condition 88 | const icon = getWeatherIcon(item.condition, hour); 89 | 90 | // Store processed forecast 91 | processedForecasts[key] = { 92 | icon, 93 | condition: item.condition, 94 | temperature: Math.round(item.temperature), 95 | templow: item.templow !== undefined ? Math.round(item.templow) : undefined, 96 | datetime: item.datetime, 97 | hour, 98 | precipitation: item.precipitation, 99 | precipitation_probability: item.precipitation_probability, 100 | }; 101 | }); 102 | 103 | return processedForecasts; 104 | } 105 | 106 | //----------------------------------------------------------------------------- 107 | // FORECAST MATCHING AND LOOKUP 108 | //----------------------------------------------------------------------------- 109 | 110 | /** 111 | * Find the daily forecast for a specific date 112 | * 113 | * @param date Date object to find forecast for 114 | * @param dailyForecasts Daily forecasts record 115 | * @returns Weather data for the date or undefined 116 | */ 117 | export function findDailyForecast( 118 | date: Date, 119 | dailyForecasts: Record, 120 | ): Types.WeatherData | undefined { 121 | if (!dailyForecasts) { 122 | return undefined; 123 | } 124 | 125 | // Convert date to key format (YYYY-MM-DD) 126 | const dateKey = FormatUtils.getLocalDateKey(date); 127 | 128 | // Return the forecast for this date if available 129 | return dailyForecasts[dateKey]; 130 | } 131 | 132 | /** 133 | * Find the appropriate forecast for an event 134 | * Uses hourly forecast for timed events, daily forecast for all-day events 135 | * 136 | * @param event Calendar event 137 | * @param hourlyForecasts Hourly forecasts record 138 | * @param dailyForecasts Daily forecasts record 139 | * @returns Weather data for the event or undefined 140 | */ 141 | export function findForecastForEvent( 142 | event: Types.CalendarEventData, 143 | hourlyForecasts: Record, 144 | dailyForecasts?: Record, 145 | ): Types.WeatherData | undefined { 146 | // For all-day events (with start.date but no start.dateTime) 147 | if (event.start.date && !event.start.dateTime && dailyForecasts) { 148 | // Use the daily forecast for the event date 149 | const eventDate = FormatUtils.parseAllDayDate(event.start.date); 150 | const dateKey = FormatUtils.getLocalDateKey(eventDate); 151 | return dailyForecasts[dateKey]; 152 | } 153 | 154 | // For regular events with start.dateTime 155 | if (!event.start.dateTime || !hourlyForecasts) { 156 | return undefined; 157 | } 158 | 159 | // Get the event start time 160 | const eventStart = new Date(event.start.dateTime); 161 | const eventDate = FormatUtils.getLocalDateKey(eventStart); 162 | const eventHour = eventStart.getHours(); 163 | 164 | // Try to find the exact hour 165 | const exactMatch = hourlyForecasts[`${eventDate}_${eventHour}`]; 166 | if (exactMatch) { 167 | return exactMatch; 168 | } 169 | 170 | // Find the closest hour forecast 171 | let closestHour = -1; 172 | let minDiff = 24; 173 | 174 | // Look through all hourly forecasts for this date 175 | Object.keys(hourlyForecasts).forEach((key) => { 176 | if (key.startsWith(eventDate)) { 177 | // Extract hour from the key 178 | const hourPart = key.split('_')[1]; 179 | const hour = parseInt(hourPart); 180 | 181 | if (!isNaN(hour)) { 182 | // Calculate difference, accounting for hour wrapping 183 | const diff = Math.abs(hour - eventHour); 184 | 185 | if (diff < minDiff) { 186 | minDiff = diff; 187 | closestHour = hour; 188 | } 189 | } 190 | } 191 | }); 192 | 193 | // Return the closest forecast if found 194 | if (closestHour >= 0) { 195 | return hourlyForecasts[`${eventDate}_${closestHour}`]; 196 | } 197 | 198 | return undefined; 199 | } 200 | 201 | //----------------------------------------------------------------------------- 202 | // WEATHER DATA FORMATTING 203 | //----------------------------------------------------------------------------- 204 | 205 | // Map of weather condition codes to MDI icons 206 | const CONDITION_ICON_MAP: Record = { 207 | 'clear-night': 'mdi:weather-night', 208 | cloudy: 'mdi:weather-cloudy', 209 | fog: 'mdi:weather-fog', 210 | hail: 'mdi:weather-hail', 211 | lightning: 'mdi:weather-lightning', 212 | 'lightning-rainy': 'mdi:weather-lightning-rainy', 213 | partlycloudy: 'mdi:weather-partly-cloudy', 214 | pouring: 'mdi:weather-pouring', 215 | rainy: 'mdi:weather-rainy', 216 | snowy: 'mdi:weather-snowy', 217 | 'snowy-rainy': 'mdi:weather-snowy-rainy', 218 | sunny: 'mdi:weather-sunny', 219 | windy: 'mdi:weather-windy', 220 | 'windy-variant': 'mdi:weather-windy-variant', 221 | exceptional: 'mdi:weather-cloudy-alert', 222 | }; 223 | 224 | // Night-specific icon overrides 225 | const NIGHT_ICONS: Record = { 226 | sunny: 'mdi:weather-night', 227 | partlycloudy: 'mdi:weather-night-partly-cloudy', 228 | 'lightning-rainy': 'mdi:weather-lightning', 229 | }; 230 | 231 | /** 232 | * Get MDI icon name for a weather condition 233 | * 234 | * @param condition Weather condition string 235 | * @param hour Optional hour (0-23) to determine day/night 236 | * @returns MDI icon name 237 | */ 238 | function getWeatherIcon(condition: string, hour?: number): string { 239 | // Determine if it's night (between 18:00 and 6:00) 240 | const isNight = hour !== undefined && (hour >= 18 || hour < 6); 241 | 242 | // If it's night and we have a night-specific override, use it 243 | if (isNight && NIGHT_ICONS[condition]) { 244 | return NIGHT_ICONS[condition]; 245 | } 246 | 247 | // Otherwise use standard icon or default to cloudy alert 248 | return CONDITION_ICON_MAP[condition] || 'mdi:weather-cloudy-alert'; 249 | } 250 | 251 | //----------------------------------------------------------------------------- 252 | // SUBSCRIPTION MANAGEMENT 253 | //----------------------------------------------------------------------------- 254 | 255 | /** 256 | * Subscribe to weather forecast data from Home Assistant 257 | * 258 | * @param hass Home Assistant instance 259 | * @param config Calendar card configuration 260 | * @param forecastType Type of forecast to subscribe to ('daily' or 'hourly') 261 | * @param callback Callback function to receive forecast data 262 | * @returns Unsubscribe function or undefined 263 | */ 264 | export function subscribeToWeatherForecast( 265 | hass: Types.Hass, 266 | config: Types.Config, 267 | forecastType: 'daily' | 'hourly', 268 | callback: (forecasts: Record) => void, 269 | ): (() => void) | undefined { 270 | if (!hass?.connection || !config?.weather?.entity) { 271 | return undefined; 272 | } 273 | 274 | const entityId = config.weather.entity; 275 | 276 | try { 277 | // Set up subscription to weather forecast data 278 | const unsubscribe = hass.connection.subscribeMessage( 279 | (message: { forecast: Array }) => { 280 | if (message && Array.isArray(message.forecast)) { 281 | // Process forecast data 282 | const processedForecasts = processForecastData(message.forecast, forecastType); 283 | 284 | // Call callback with processed data 285 | callback(processedForecasts); 286 | } 287 | }, 288 | { 289 | type: 'weather/subscribe_forecast', 290 | forecast_type: forecastType, 291 | entity_id: entityId, 292 | }, 293 | ); 294 | 295 | return unsubscribe; 296 | } catch (error) { 297 | Logger.error('Failed to subscribe to weather forecast', { 298 | entity: entityId, 299 | forecast_type: forecastType, 300 | error, 301 | }); 302 | 303 | return undefined; 304 | } 305 | } 306 | -------------------------------------------------------------------------------- /src/config/config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/order */ 2 | /** 3 | * Configuration module for Calendar Card Pro 4 | */ 5 | 6 | import * as Constants from './constants'; 7 | import * as Types from './types'; 8 | import * as Logger from '../utils/logger'; 9 | 10 | //----------------------------------------------------------------------------- 11 | // CORE CONFIGURATION 12 | //----------------------------------------------------------------------------- 13 | 14 | /** 15 | * Default configuration for Calendar Card Pro 16 | */ 17 | export const DEFAULT_CONFIG: Types.Config = { 18 | // Core settings 19 | entities: [], 20 | start_date: undefined, 21 | days_to_show: 3, 22 | compact_days_to_show: undefined, 23 | compact_events_to_show: undefined, 24 | compact_events_complete_days: false, 25 | show_empty_days: false, 26 | filter_duplicates: false, 27 | split_multiday_events: false, 28 | language: undefined, 29 | 30 | // Header 31 | title: undefined, 32 | title_font_size: undefined, 33 | title_color: undefined, 34 | 35 | // Layout and spacing 36 | background_color: 'var(--ha-card-background)', 37 | accent_color: '#03a9f4', 38 | vertical_line_width: '2px', 39 | day_spacing: '10px', 40 | event_spacing: '4px', 41 | additional_card_spacing: '0px', 42 | height: 'auto', 43 | max_height: 'none', 44 | 45 | // Week numbers and horizontal separators 46 | first_day_of_week: 'system', 47 | show_week_numbers: null, 48 | show_current_week_number: true, 49 | week_number_font_size: '12px', 50 | week_number_color: 'var(--primary-text-color)', 51 | week_number_background_color: '#03a9f450', 52 | day_separator_width: '0px', 53 | day_separator_color: 'var(--secondary-text-color)', 54 | week_separator_width: '0px', 55 | week_separator_color: '#03a9f450', 56 | month_separator_width: '0px', 57 | month_separator_color: 'var(--primary-text-color)', 58 | 59 | // Today indicator 60 | today_indicator: false, 61 | today_indicator_position: '15% 50%', 62 | today_indicator_color: '#03a9f4', 63 | today_indicator_size: '6px', 64 | 65 | // Date column 66 | date_vertical_alignment: 'middle', 67 | weekday_font_size: '14px', 68 | weekday_color: 'var(--primary-text-color)', 69 | day_font_size: '26px', 70 | day_color: 'var(--primary-text-color)', 71 | show_month: true, 72 | month_font_size: '12px', 73 | month_color: 'var(--primary-text-color)', 74 | weekend_weekday_color: undefined, // Inherit from weekday_color 75 | weekend_day_color: undefined, // Inherit from day_color 76 | weekend_month_color: undefined, // Inherit from month_color 77 | today_weekday_color: undefined, // Inherit from weekday_color or weekend_weekday_color 78 | today_day_color: undefined, // Inherit from day_color or weekend_day_color 79 | today_month_color: undefined, // Inherit from month_color or weekend_month_color, 80 | 81 | // Event column 82 | event_background_opacity: 0, 83 | show_past_events: false, 84 | show_countdown: false, 85 | show_progress_bar: false, 86 | progress_bar_color: 'var(--secondary-text-color)', 87 | progress_bar_height: 'calc(var(--calendar-card-font-size-time) * 0.75)', 88 | progress_bar_width: '60px', 89 | event_font_size: '14px', 90 | event_color: 'var(--primary-text-color)', 91 | empty_day_color: 'var(--primary-text-color)', 92 | show_time: true, 93 | show_single_allday_time: true, 94 | time_24h: 'system', 95 | show_end_time: true, 96 | time_font_size: '12px', 97 | time_color: 'var(--secondary-text-color)', 98 | time_icon_size: '14px', 99 | show_location: true, 100 | remove_location_country: false, 101 | location_font_size: '12px', 102 | location_color: 'var(--secondary-text-color)', 103 | location_icon_size: '14px', 104 | 105 | // Weather 106 | weather: { 107 | entity: undefined, 108 | position: 'date', 109 | date: { 110 | show_conditions: true, 111 | show_high_temp: true, 112 | show_low_temp: false, 113 | icon_size: '14px', 114 | font_size: '12px', 115 | color: 'var(--primary-text-color)', 116 | }, 117 | event: { 118 | show_conditions: true, 119 | show_temp: true, 120 | icon_size: '14px', 121 | font_size: '12px', 122 | color: 'var(--primary-text-color)', 123 | }, 124 | }, 125 | 126 | // Actions 127 | tap_action: { action: 'none' }, 128 | hold_action: { action: 'none' }, 129 | 130 | // Cache and refresh settings 131 | refresh_interval: Constants.CACHE.DEFAULT_DATA_REFRESH_MINUTES, 132 | refresh_on_navigate: true, 133 | }; 134 | 135 | //----------------------------------------------------------------------------- 136 | // CONFIGURATION UTILITIES 137 | //----------------------------------------------------------------------------- 138 | 139 | /** 140 | * Normalizes entity configuration to ensure consistent format 141 | */ 142 | export function normalizeEntities( 143 | entities: Array< 144 | | string 145 | | { 146 | entity: string; 147 | label?: string; 148 | color?: string; 149 | accent_color?: string; 150 | show_time?: boolean; 151 | show_location?: boolean; 152 | compact_events_to_show?: number; 153 | blocklist?: string; 154 | allowlist?: string; 155 | split_multiday_events?: boolean; 156 | } 157 | >, 158 | ): Array { 159 | if (!Array.isArray(entities)) { 160 | return []; 161 | } 162 | 163 | return entities 164 | .map((item) => { 165 | if (typeof item === 'string') { 166 | return { 167 | entity: item, 168 | color: 'var(--primary-text-color)', 169 | accent_color: undefined, 170 | }; 171 | } 172 | if (typeof item === 'object' && item.entity) { 173 | return { 174 | entity: item.entity, 175 | label: item.label, 176 | color: item.color || 'var(--primary-text-color)', 177 | accent_color: item.accent_color || undefined, 178 | show_time: item.show_time, 179 | show_location: item.show_location, 180 | compact_events_to_show: item.compact_events_to_show, 181 | blocklist: item.blocklist, 182 | allowlist: item.allowlist, 183 | split_multiday_events: item.split_multiday_events, 184 | }; 185 | } 186 | return null; 187 | }) 188 | .filter(Boolean) as Array; 189 | } 190 | 191 | /** 192 | * Determine if configuration changes affect data retrieval 193 | */ 194 | export function hasConfigChanged( 195 | previous: Partial | undefined, 196 | current: Types.Config, 197 | ): boolean { 198 | // Handle empty/undefined config 199 | if (!previous || Object.keys(previous).length === 0) { 200 | return true; 201 | } 202 | 203 | // Extract entity IDs without colors for comparison - entity colors are styling only 204 | // and don't require API data refresh 205 | const previousEntityIds = (previous.entities || []) 206 | .map((e) => (typeof e === 'string' ? e : e.entity)) 207 | .sort() 208 | .join(','); 209 | 210 | const currentEntityIds = (current.entities || []) 211 | .map((e) => (typeof e === 'string' ? e : e.entity)) 212 | .sort() 213 | .join(','); 214 | 215 | // Check refresh interval separately (it affects both timers and cache now) 216 | const refreshIntervalChanged = previous?.refresh_interval !== current?.refresh_interval; 217 | 218 | // Check if core data-affecting properties changed 219 | const dataChanged = 220 | previousEntityIds !== currentEntityIds || 221 | previous.days_to_show !== current.days_to_show || 222 | previous.start_date !== current.start_date || 223 | previous.show_past_events !== current.show_past_events || 224 | previous.filter_duplicates !== current.filter_duplicates; 225 | 226 | if (dataChanged || refreshIntervalChanged) { 227 | Logger.debug('Configuration change requires data refresh'); 228 | } 229 | 230 | return dataChanged || refreshIntervalChanged; 231 | } 232 | 233 | /** 234 | * Check if entity colors have changed in the configuration 235 | * This is used to determine if a re-render (but not data refresh) is needed 236 | * 237 | * @param previous - Previous configuration 238 | * @param current - New configuration 239 | * @returns True if entity colors have changed 240 | */ 241 | export function haveEntityColorsChanged( 242 | previous: Partial | undefined, 243 | current: Types.Config, 244 | ): boolean { 245 | if (!previous || !previous.entities) return false; 246 | 247 | const prevEntities = previous.entities; 248 | const currEntities = current.entities; 249 | 250 | // If entity count changed, let other functions handle it 251 | if (prevEntities.length !== currEntities.length) return false; 252 | 253 | // Create a map of entity IDs to colors for previous config 254 | const prevColorMap = new Map(); 255 | prevEntities.forEach((entity) => { 256 | if (typeof entity === 'string') { 257 | prevColorMap.set(entity, 'var(--primary-text-color)'); 258 | } else { 259 | prevColorMap.set(entity.entity, entity.color || 'var(--primary-text-color)'); 260 | } 261 | }); 262 | 263 | // Check if any entity colors changed in current config 264 | for (const entity of currEntities) { 265 | const entityId = typeof entity === 'string' ? entity : entity.entity; 266 | const color = 267 | typeof entity === 'string' 268 | ? 'var(--primary-text-color)' 269 | : entity.color || 'var(--primary-text-color)'; 270 | 271 | if (!prevColorMap.has(entityId)) { 272 | // New entity, let other functions handle it 273 | continue; 274 | } 275 | 276 | // If color changed for an existing entity, return true 277 | if (prevColorMap.get(entityId) !== color) { 278 | Logger.debug(`Entity color changed for ${entityId}, will re-render`); 279 | return true; 280 | } 281 | } 282 | 283 | return false; 284 | } 285 | 286 | //----------------------------------------------------------------------------- 287 | // INITIALIZATION HELPERS 288 | //----------------------------------------------------------------------------- 289 | 290 | /** 291 | * Find a calendar entity in Home Assistant states 292 | */ 293 | export function findCalendarEntity(hass: Record): string | null { 294 | // No valid hass object provided 295 | if (!hass || typeof hass !== 'object') { 296 | return null; 297 | } 298 | 299 | // Check for entities in the states property (standard Home Assistant structure) 300 | if ('states' in hass && typeof hass.states === 'object') { 301 | const stateKeys = Object.keys(hass.states); 302 | const calendarInStates = stateKeys.find((key) => key.startsWith('calendar.')); 303 | if (calendarInStates) { 304 | return calendarInStates; 305 | } 306 | } 307 | 308 | // Check for entities at the top level (alternative structure) 309 | return Object.keys(hass).find((entityId) => entityId.startsWith('calendar.')) || null; 310 | } 311 | 312 | /** 313 | * Generate a stub configuration for the card editor 314 | */ 315 | export function getStubConfig(hass: Record): Record { 316 | const calendarEntity = findCalendarEntity(hass); 317 | return { 318 | type: 'custom:calendar-card-pro-dev', 319 | entities: calendarEntity ? [calendarEntity] : [], 320 | days_to_show: 3, 321 | show_location: true, 322 | _description: !calendarEntity 323 | ? 'A calendar card that displays events from multiple calendars with individual styling. Add a calendar integration to Home Assistant to use this card.' 324 | : undefined, 325 | }; 326 | } 327 | -------------------------------------------------------------------------------- /src/translations/languages/sv.json: -------------------------------------------------------------------------------- 1 | { 2 | "daysOfWeek": ["Sön", "Mån", "Tis", "Ons", "Tors", "Fre", "Lör"], 3 | "fullDaysOfWeek": ["Söndag", "Måndag", "Tisdag", "Onsdag", "Torsdag", "Fredag", "Lördag"], 4 | "months": ["Jan", "Feb", "Mar", "Apr", "Maj", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dec"], 5 | "allDay": "heldag", 6 | "multiDay": "till", 7 | "at": "vid", 8 | "endsToday": "slutar idag", 9 | "endsTomorrow": "slutar imorgon", 10 | "noEvents": "Inga kommande händelser", 11 | "loading": "Laddar kalenderhändelser...", 12 | "error": "Fel: Kalenderentiteten hittades inte eller är felaktigt konfigurerad.", 13 | "editor": { 14 | "calendar_entities": "Kalenderentiteter", 15 | "calendar": "Kalender", 16 | "entity_identification": "Entitetsidentifiering", 17 | "entity": "Entitet", 18 | "add_calendar": "Lägg till kalender", 19 | "remove": "Ta bort", 20 | "convert_to_advanced": "Konvertera till avancerat", 21 | "simple": "Enkel", 22 | "display_settings": "Visningsinställningar", 23 | "label": "Etikett", 24 | "label_type": "Etikettyp", 25 | "none": "Ingen", 26 | "text_emoji": "Text/Emoji", 27 | "icon": "Ikon", 28 | "text_value": "Textvärde", 29 | "text_label_note": "Ange text eller emoji som '📅 Min Kalender'", 30 | "image": "Bild", 31 | "image_label_note": "Sökväg till bild, t.ex. /local/calendar.jpg", 32 | "label_note": "Anpassad etikett för denna kalender.", 33 | "colors": "Färger", 34 | "event_color": "Händelsefärg", 35 | "entity_color_note": "Anpassad färg för händelsetitlar från denna kalender.", 36 | "accent_color": "Accentfärg", 37 | "entity_accent_color_note": "Anpassad accentfärg för den vertikala linjen för händelser från denna kalender. Denna färg används även som bakgrundsfärg för händelsen när 'event_background_opacity' är >0.", 38 | "event_filtering": "Händelsefiltrering", 39 | "blocklist": "Blocklista", 40 | "blocklist_note": "Lista med termer (avgränsade med |) att exkludera händelser. Händelser med titlar som innehåller någon av dessa termer döljs. Exempel: 'Privat|Möte|Konferens'", 41 | "allowlist": "Tillåtlista", 42 | "allowlist_note": "Lista med termer (avgränsade med |) att inkludera händelser. Om inte tom, visas endast händelser med titlar som innehåller någon av dessa termer. Exempel: 'Födelsedag|Jubileum|Viktigt'", 43 | "entity_overrides": "Entitetsöverskridanden", 44 | "entity_overrides_note": "Dessa inställningar åsidosätter de globala kärninställningarna för denna specifika kalender.", 45 | "compact_events_to_show": "Kompakta händelser att visa", 46 | "entity_compact_events_note": "Åsidosätt antal händelser att visa i kompakt läge för denna kalender.", 47 | "show_time": "Visa tid", 48 | "entity_show_time_note": "Visa eller dölj händelsetider endast för denna kalender.", 49 | "show_location": "Visa plats", 50 | "entity_show_location_note": "Visa eller dölj plats för händelser endast för denna kalender.", 51 | "split_multiday_events": "Dela upp flerdagshändelser", 52 | "entity_split_multiday_note": "Dela upp flerdagshändelser i individuella dagsegment för denna kalender.", 53 | "core_settings": "Kärninställningar", 54 | "time_range": "Tidsintervall", 55 | "time_range_note": "Detta tidsintervall definierar det vanliga visningsläget, vilket blir det utökade läget om kompakt läge är konfigurerat.", 56 | "days_to_show": "Dagar att visa", 57 | "days_to_show_note": "Antal dagar att hämta från API och visa i kalendern", 58 | "start_date": "Startdatum", 59 | "start_date_mode": "Startdatoläge", 60 | "start_date_mode_default": "Standard (idag)", 61 | "start_date_mode_fixed": "Fast datum", 62 | "start_date_mode_offset": "Relativt avstånd", 63 | "start_date_fixed": "Fast startdatum", 64 | "start_date_offset": "Relativt avstånd från idag", 65 | "start_date_offset_note": "Ange ett positivt eller negativt tal för att justera från idag (t.ex. +1 för imorgon, -5 för fem dagar sedan).", 66 | "compact_mode": "Kompakt läge", 67 | "compact_mode_note": "Kompakt läge visar färre dagar och/eller händelser initialt. Kortet kan expandera till full vy med en tryckning eller hållning om det är konfigurerat med action: 'expand'.", 68 | "compact_days_to_show": "Kompakta dagar att visa", 69 | "compact_events_complete_days": "Kompletta dagar i kompakt läge", 70 | "compact_events_complete_days_note": "När aktiverat, om minst en händelse från en dag visas, visas alla händelser från den dagen.", 71 | "event_visibility": "Händelsevisning", 72 | "show_past_events": "Visa tidigare händelser", 73 | "show_empty_days": "Visa tomma dagar", 74 | "filter_duplicates": "Filtrera dubbletter", 75 | "language_time_formats": "Språk & tidsformat", 76 | "language": "Språk", 77 | "language_mode": "Språkläge", 78 | "language_code": "Språkkod", 79 | "language_code_note": "Ange en tvåställig språkkod (t.ex. 'sv', 'en', 'de')", 80 | "time_24h": "Tidsformat", 81 | "system": "Systemstandard", 82 | "custom": "Anpassad", 83 | "12h": "12-timmars", 84 | "24h": "24-timmars", 85 | "appearance_layout": "Utseende & Layout", 86 | "title_styling": "Titelstil", 87 | "title": "Titel", 88 | "title_font_size": "Titelfontstorlek", 89 | "title_color": "Titelfärg", 90 | "card_styling": "Kortstil", 91 | "background_color": "Bakgrundsfärg", 92 | "height_mode": "Höjdläge", 93 | "auto": "Automatisk höjd", 94 | "fixed": "Fast höjd", 95 | "maximum": "Maxhöjd", 96 | "height_value": "Höjdvärde", 97 | "fixed_height_note": "Kortet behåller alltid exakt denna höjd oavsett innehåll", 98 | "max_height_note": "Kortet växer med innehållet upp till denna maxhöjd", 99 | "event_styling": "Händelsestil", 100 | "event_background_opacity": "Händelsebakgrundens opacitet", 101 | "vertical_line_width": "Vertikal linjebredd", 102 | "spacing_alignment": "Avstånd & Justering", 103 | "day_spacing": "Dagavstånd", 104 | "event_spacing": "Händelseavstånd", 105 | "additional_card_spacing": "Ytterligare kortavstånd", 106 | "date_display": "Datumvisning", 107 | "vertical_alignment": "Vertikal justering", 108 | "date_vertical_alignment": "Datum vertikal justering", 109 | "date_formatting": "Datumformatering", 110 | "top": "Topp", 111 | "middle": "Mitten", 112 | "bottom": "Botten", 113 | "weekday_font": "Veckodagsfont", 114 | "weekday_font_size": "Veckodagsfontstorlek", 115 | "weekday_color": "Veckodagsfärg", 116 | "day_font": "Dagfont", 117 | "day_font_size": "Dagfontstorlek", 118 | "day_color": "Dagfärg", 119 | "month_font": "Månadsfont", 120 | "show_month": "Visa månad", 121 | "month_font_size": "Månadsfontstorlek", 122 | "month_color": "Månadsfärg", 123 | "weekend_highlighting": "Helgmarkering", 124 | "weekend_weekday_color": "Helg veckodagsfärg", 125 | "weekend_day_color": "Helg dagfärg", 126 | "weekend_month_color": "Helg månadsfärg", 127 | "today_highlighting": "Idag-markering", 128 | "today_weekday_color": "Idag veckodagsfärg", 129 | "today_day_color": "Idag dagfärg", 130 | "today_month_color": "Idag månadsfärg", 131 | "today_indicator": "Idag-indikator", 132 | "dot": "Punkt", 133 | "pulse": "Puls", 134 | "glow": "Glöd", 135 | "emoji": "Emoji", 136 | "emoji_value": "Emoji", 137 | "emoji_indicator_note": "Ange en enskild emoji, t.ex. 🗓️", 138 | "image_path": "Bildsökväg", 139 | "image_indicator_note": "Sökväg till bild, t.ex. /local/image.jpg", 140 | "today_indicator_position": "Idag-indikatorns position", 141 | "today_indicator_color": "Idag-indikatorns färg", 142 | "today_indicator_size": "Idag-indikatorns storlek", 143 | "week_numbers_separators": "Veckonummer & Avgränsare", 144 | "week_numbers": "Veckonummer", 145 | "first_day_of_week": "Första veckodagen", 146 | "sunday": "Söndag", 147 | "monday": "Måndag", 148 | "show_week_numbers": "Visa veckonummer", 149 | "week_number_note_iso": "ISO (Europa/Internationellt): Första veckan innehåller årets första torsdag. Ger konsekventa veckonummer mellan år (ISO 8601-standard).", 150 | "week_number_note_simple": "Enkel (Nordamerika): Veckor räknas sekventiellt från 1 januari oavsett veckodag. Första veckan kan vara partiell. Mer intuitivt men mindre standardiserat.", 151 | "show_current_week_number": "Visa aktuellt veckonummer", 152 | "week_number_font_size": "Veckonummer fontstorlek", 153 | "week_number_color": "Veckonummer färg", 154 | "week_number_background_color": "Veckonummer bakgrundsfärg", 155 | "day_separator": "Dagavgränsare", 156 | "show_day_separator": "Visa dagavgränsare", 157 | "day_separator_width": "Dagavgränsarbredd", 158 | "day_separator_color": "Dagavgränsarfärg", 159 | "week_separator": "Veckoavgränsare", 160 | "show_week_separator": "Visa veckoavgränsare", 161 | "week_separator_width": "Veckoavgränsarbredd", 162 | "week_separator_color": "Veckoavgränsarfärg", 163 | "month_separator": "Månadsavgränsare", 164 | "show_month_separator": "Visa månadsavgränsare", 165 | "month_separator_width": "Månadsavgränsarbredd", 166 | "month_separator_color": "Månadsavgränsarfärg", 167 | "event_display": "Händelsevisning", 168 | "event_title": "Händelsetitel", 169 | "event_font_size": "Händelsefontstorlek", 170 | "empty_day_color": "Tom dag-färg", 171 | "time": "Tid", 172 | "show_single_allday_time": "Visa tid för heldagshändelser", 173 | "show_end_time": "Visa sluttid", 174 | "time_font_size": "Tidfontstorlek", 175 | "time_color": "Tidfärg", 176 | "time_icon_size": "Tidsikonstorlek", 177 | "location": "Plats", 178 | "remove_location_country": "Ta bort land från plats", 179 | "location_font_size": "Platsfontstorlek", 180 | "location_color": "Platsfärg", 181 | "location_icon_size": "Platsikonstorlek", 182 | "custom_country_pattern": "Landsmönster att ta bort", 183 | "custom_country_pattern_note": "Ange länder som reguljära uttryck (t.ex. 'USA|Sverige|Norge'). Händelser med platser som slutar med dessa tas bort.", 184 | "progress_indicators": "Förloppsindikatorer", 185 | "show_countdown": "Visa nedräkning", 186 | "show_progress_bar": "Visa förloppsindikator", 187 | "progress_bar_color": "Förloppsindikator färg", 188 | "progress_bar_height": "Förloppsindikator höjd", 189 | "progress_bar_width": "Förloppsindikator bredd", 190 | "multiday_event_handling": "Flerdagshändelsehantering", 191 | "weather_integration": "Väderintegration", 192 | "weather_entity_position": "Väderentitet & position", 193 | "weather_entity": "Väderentitet", 194 | "weather_position": "Väderposition", 195 | "date": "Datum", 196 | "event": "Händelse", 197 | "both": "Båda", 198 | "date_column_weather": "Datumkolumn väder", 199 | "show_conditions": "Visa väderförhållanden", 200 | "show_high_temp": "Visa högsta temperatur", 201 | "show_low_temp": "Visa lägsta temperatur", 202 | "icon_size": "Ikonstorlek", 203 | "font_size": "Fontstorlek", 204 | "color": "Färg", 205 | "event_row_weather": "Händelserad väder", 206 | "show_temp": "Visa temperatur", 207 | "interactions": "Interaktioner", 208 | "tap_action": "Tryckåtgärd", 209 | "hold_action": "Hållåtgärd", 210 | "more_info": "Mer info", 211 | "navigate": "Navigera", 212 | "url": "URL", 213 | "call_service": "Anropa tjänst", 214 | "expand": "Växla kompakt/utökat läge", 215 | "navigation_path": "Navigeringssökväg", 216 | "url_path": "URL", 217 | "service": "Tjänst", 218 | "service_data": "Tjänstdata (JSON)", 219 | "refresh_settings": "Uppdateringsinställningar", 220 | "refresh_interval": "Uppdateringsintervall (minuter)", 221 | "refresh_on_navigate": "Uppdatera vid navigering tillbaka", 222 | "deprecated_config_detected": "Föråldrade konfigurationsalternativ upptäckta.", 223 | "deprecated_config_explanation": "Vissa alternativ i din konfiguration stöds inte längre.", 224 | "deprecated_config_update_hint": "Uppdatera för att säkerställa kompatibilitet.", 225 | "update_config": "Uppdatera konfiguration…" 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /src/translations/languages/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "daysOfWeek": ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"], 3 | "fullDaysOfWeek": ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"], 4 | "months": ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"], 5 | "allDay": "all day", 6 | "multiDay": "until", 7 | "at": "at", 8 | "endsToday": "ends today", 9 | "endsTomorrow": "ends tomorrow", 10 | "noEvents": "No upcoming events", 11 | "loading": "Loading calendar events...", 12 | "error": "Error: Calendar entity not found or improperly configured", 13 | 14 | "editor": { 15 | "calendar_entities": "Calendar Entities", 16 | "calendar": "Calendar", 17 | "entity_identification": "Entity Identification", 18 | "entity": "Entity", 19 | "add_calendar": "Add calendar", 20 | "remove": "Remove", 21 | "convert_to_advanced": "Convert to advanced", 22 | "simple": "Simple", 23 | "display_settings": "Display Settings", 24 | "label": "Label", 25 | "label_type": "Label Type", 26 | "none": "None", 27 | "text_emoji": "Text/Emoji", 28 | "icon": "Icon", 29 | "text_value": "Text Value", 30 | "text_label_note": "Enter text or emoji like '📅 My Calendar'", 31 | "image": "Image", 32 | "image_label_note": "Path to image like /local/calendar.jpg", 33 | "label_note": "Custom label for this calendar.", 34 | "colors": "Colors", 35 | "event_color": "Event color", 36 | "entity_color_note": "Custom color for event titles from this calendar.", 37 | "accent_color": "Accent color", 38 | "entity_accent_color_note": "Custom accent color for the vertical line of events from this calendar. This color will also be used as the event background color when 'event_background_opacity' is >0.", 39 | "event_filtering": "Event Filtering", 40 | "blocklist": "Blocklist", 41 | "blocklist_note": "Pipe-separated list of terms to exclude events. Events with titles containing any of these terms will be hidden. Example: 'Private|Meeting|Conference'", 42 | "allowlist": "Allowlist", 43 | "allowlist_note": "Pipe-separated list of terms to include events. If not empty, only events with titles containing any of these terms will be shown. Example: 'Birthday|Anniversary|Important'", 44 | "entity_overrides": "Entity Overrides", 45 | "entity_overrides_note": "These settings will override the global core settings for this specific calendar.", 46 | "compact_events_to_show": "Compact events to show", 47 | "entity_compact_events_note": "Override the number of events to show in compact mode for this calendar.", 48 | "show_time": "Show time", 49 | "entity_show_time_note": "Show or hide event times for this calendar only.", 50 | "show_location": "Show location", 51 | "entity_show_location_note": "Show or hide event locations for this calendar only.", 52 | "split_multiday_events": "Split multi-day events", 53 | "entity_split_multiday_note": "Split multi-day events into individual day segments for this calendar.", 54 | 55 | "core_settings": "Core Settings", 56 | "time_range": "Time Range", 57 | "time_range_note": "This time range defines the regular display mode, which becomes the expanded view if compact mode is configured.", 58 | "days_to_show": "Days to show", 59 | "days_to_show_note": "Number of days to fetch from API and display in the calendar", 60 | "start_date": "Start date", 61 | "start_date_mode": "Start date mode", 62 | "start_date_mode_default": "Default (today)", 63 | "start_date_mode_fixed": "Fixed date", 64 | "start_date_mode_offset": "Relative offset", 65 | "start_date_fixed": "Fixed start date", 66 | "start_date_offset": "Relative offset from today", 67 | "start_date_offset_note": "Enter a positive or negative number to offset from today (e.g., +1 for tomorrow, -5 for five days ago).", 68 | "compact_mode": "Compact Mode", 69 | "compact_mode_note": "Compact mode shows fewer days and/or events initially. The card can be expanded to full view using a tap or hold action if configured with action: 'expand'.", 70 | "compact_days_to_show": "Compact days to show", 71 | "compact_events_complete_days": "Complete days in compact mode", 72 | "compact_events_complete_days_note": "When enabled, if at least one event from a day is shown, all events from that day will be displayed.", 73 | "event_visibility": "Event Visibility", 74 | "show_past_events": "Show past events", 75 | "show_empty_days": "Show empty days", 76 | "filter_duplicates": "Filter duplicate events", 77 | "language_time_formats": "Language & Time Formats", 78 | "language": "Language", 79 | "language_mode": "Language Mode", 80 | "language_code": "Language Code", 81 | "language_code_note": "Enter a 2-letter language code (e.g., 'en', 'de', 'fr')", 82 | "time_24h": "Time format", 83 | "system": "System default", 84 | "custom": "Custom", 85 | "12h": "12-hour", 86 | "24h": "24-hour", 87 | 88 | "appearance_layout": "Appearance & Layout", 89 | "title_styling": "Title Styling", 90 | "title": "Title", 91 | "title_font_size": "Title font size", 92 | "title_color": "Title color", 93 | "card_styling": "Card Styling", 94 | "background_color": "Background color", 95 | "height_mode": "Height mode", 96 | "auto": "Auto height", 97 | "fixed": "Fixed height", 98 | "maximum": "Maximum height", 99 | "height_value": "Height value", 100 | "fixed_height_note": "Card always maintains exactly this height regardless of content", 101 | "max_height_note": "Card grows with content up to this maximum height", 102 | "event_styling": "Event Styling", 103 | "event_background_opacity": "Event background opacity", 104 | "vertical_line_width": "Vertical line width", 105 | "spacing_alignment": "Spacing & Alignment", 106 | "day_spacing": "Day spacing", 107 | "event_spacing": "Event spacing", 108 | "additional_card_spacing": "Additional card spacing", 109 | 110 | "date_display": "Date Display", 111 | "vertical_alignment": "Vertical Alignment", 112 | "date_vertical_alignment": "Date vertical alignment", 113 | "date_formatting": "Date Formatting", 114 | "top": "Top", 115 | "middle": "Middle", 116 | "bottom": "Bottom", 117 | "weekday_font": "Weekday font", 118 | "weekday_font_size": "Weekday font size", 119 | "weekday_color": "Weekday color", 120 | "day_font": "Day font", 121 | "day_font_size": "Day font size", 122 | "day_color": "Day color", 123 | "month_font": "Month font", 124 | "show_month": "Show month", 125 | "month_font_size": "Month font size", 126 | "month_color": "Month color", 127 | "weekend_highlighting": "Weekend Highlighting", 128 | "weekend_weekday_color": "Weekend weekday color", 129 | "weekend_day_color": "Weekend day color", 130 | "weekend_month_color": "Weekend month color", 131 | "today_highlighting": "Today Highlighting", 132 | "today_weekday_color": "Today weekday color", 133 | "today_day_color": "Today day color", 134 | "today_month_color": "Today month color", 135 | "today_indicator": "Today indicator", 136 | "dot": "Dot", 137 | "pulse": "Pulse", 138 | "glow": "Glow", 139 | "emoji": "Emoji", 140 | "emoji_value": "Emoji", 141 | "emoji_indicator_note": "Enter a single emoji character like 🗓️", 142 | "image_path": "Image path", 143 | "image_indicator_note": "Path to image like /local/image.jpg", 144 | "today_indicator_position": "Today indicator position", 145 | "today_indicator_color": "Today indicator color", 146 | "today_indicator_size": "Today indicator size", 147 | "week_numbers_separators": "Week Numbers & Separators", 148 | "week_numbers": "Week numbers", 149 | "first_day_of_week": "First day of week", 150 | "sunday": "Sunday", 151 | "monday": "Monday", 152 | "show_week_numbers": "Show week numbers", 153 | "week_number_note_iso": "ISO (Europe/International): First week contains the first Thursday of the year. Creates consistent week numbering across years (ISO 8601 standard).", 154 | "week_number_note_simple": "Simple (North America): Weeks count sequentially from January 1st regardless of weekday. First week may be partial. More intuitive but less standardized.", 155 | "show_current_week_number": "Show current week number", 156 | "week_number_font_size": "Week number font size", 157 | "week_number_color": "Week number color", 158 | "week_number_background_color": "Week number background color", 159 | "day_separator": "Day separator", 160 | "show_day_separator": "Show day separator", 161 | "day_separator_width": "Day separator width", 162 | "day_separator_color": "Day separator color", 163 | "week_separator": "Week separator", 164 | "show_week_separator": "Show week separator", 165 | "week_separator_width": "Week separator width", 166 | "week_separator_color": "Week separator color", 167 | "month_separator": "Month separator", 168 | "show_month_separator": "Show month separator", 169 | "month_separator_width": "Month separator width", 170 | "month_separator_color": "Month separator color", 171 | 172 | "event_display": "Event Display", 173 | "event_title": "Event Title", 174 | "event_font_size": "Event font size", 175 | "empty_day_color": "Empty day color", 176 | "time": "Time", 177 | "show_single_allday_time": "Show time for all-day single events", 178 | "show_end_time": "Show end time", 179 | "time_font_size": "Time font size", 180 | "time_color": "Time color", 181 | "time_icon_size": "Time icon size", 182 | "location": "Location", 183 | "remove_location_country": "Remove location country", 184 | "location_font_size": "Location font size", 185 | "location_color": "Location color", 186 | "location_icon_size": "Location icon size", 187 | "custom_country_pattern": "Country patterns to remove", 188 | "custom_country_pattern_note": "Enter country names as a regular expression pattern (e.g., 'USA|United States|Canada'). Events with locations ending with these patterns will have the country removed.", 189 | "progress_indicators": "Progress Indicators", 190 | "show_countdown": "Show countdown", 191 | "show_progress_bar": "Show progress bar", 192 | "progress_bar_color": "Progress bar color", 193 | "progress_bar_height": "Progress bar height", 194 | "progress_bar_width": "Progress bar width", 195 | "multiday_event_handling": "Multi-day Event Handling", 196 | 197 | "weather_integration": "Weather Integration", 198 | "weather_entity_position": "Weather Entity & Position", 199 | "weather_entity": "Weather entity", 200 | "weather_position": "Weather position", 201 | "date": "Date", 202 | "event": "Event", 203 | "both": "Both", 204 | "date_column_weather": "Date Column Weather", 205 | "show_conditions": "Show conditions", 206 | "show_high_temp": "Show high temperature", 207 | "show_low_temp": "Show low temperature", 208 | "icon_size": "Icon size", 209 | "font_size": "Font size", 210 | "color": "Color", 211 | "event_row_weather": "Event Row Weather", 212 | "show_temp": "Show temperature", 213 | 214 | "interactions": "Interactions", 215 | "tap_action": "Tap Action", 216 | "hold_action": "Hold Action", 217 | "more_info": "More Info", 218 | "navigate": "Navigate", 219 | "url": "URL", 220 | "call_service": "Call Service", 221 | "expand": "Toggle Compact/Expanded View", 222 | "navigation_path": "Navigation path", 223 | "url_path": "URL", 224 | "service": "Service", 225 | "service_data": "Service data (JSON)", 226 | "refresh_settings": "Refresh Settings", 227 | "refresh_interval": "Refresh interval (minutes)", 228 | "refresh_on_navigate": "Refresh when navigating back", 229 | 230 | "deprecated_config_detected": "Deprecated config options detected.", 231 | "deprecated_config_explanation": "Some options in your configuration are no longer supported.", 232 | "deprecated_config_update_hint": "Please update to ensure compatibility.", 233 | "update_config": "Update config…" 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /src/translations/languages/nb.json: -------------------------------------------------------------------------------- 1 | { 2 | "daysOfWeek": ["Søn", "Man", "Tir", "Ons", "Tor", "Fre", "Lør"], 3 | "fullDaysOfWeek": ["Søndag", "Mandag", "Tirsdag", "Onsdag", "Torsdag", "Fredag", "Lørdag"], 4 | "months": ["Jan", "Feb", "Mar", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Des"], 5 | "allDay": "hele dagen", 6 | "multiDay": "inntil", 7 | "at": "kl. ", 8 | "endsToday": "slutter i dag", 9 | "endsTomorrow": "slutter i morgen", 10 | "noEvents": "Ingen kommende hendelser", 11 | "loading": "Laster kalenderhendelser...", 12 | "error": "Feil: Kalenderenheten ble ikke funnet eller er ikke konfigurert riktig", 13 | 14 | "editor": { 15 | "calendar_entities": "kalenderhendelser", 16 | "calendar": "Kalender", 17 | "entity_identification": "Enhetsidentifikasjon", 18 | "entity": "Enhet", 19 | "add_calendar": "Legg til kalender", 20 | "remove": "Fjern", 21 | "convert_to_advanced": "Konverter til avansert", 22 | "simple": "Enkel", 23 | "display_settings": "Visningsinnstillinger", 24 | "label": "Etikett", 25 | "label_type": "Etiketttype", 26 | "none": "Ingen", 27 | "text_emoji": "Tekst/Emoji", 28 | "icon": "Ikon", 29 | "text_value": "Tekstverdi", 30 | "text_label_note": "Skriv inn tekst eller emoji som '📅 Min Kalender'", 31 | "image": "Bilde", 32 | "image_label_note": "Sti til bilde som /local/calendar.jpg", 33 | "label_note": "Egendefinert etikett for denne kalenderen.", 34 | "colors": "Farger", 35 | "event_color": "Hendelsefarge", 36 | "entity_color_note": "Egendefinert farge for hendelsestitlene fra denne kalenderen.", 37 | "accent_color": "Aksentfarge", 38 | "entity_accent_color_note": "Egendefinert aksentfarge for den vertikale linjen til hendelser fra denne kalenderen. Denne fargen vil også bli brukt som bakgrunnsfarge for hendelser når 'event_background_opacity' er >0.", 39 | "event_filtering": "Hendelsesfiltrering", 40 | "blocklist": "Blokkeringsliste", 41 | "blocklist_note": "Pipe-separert liste med uttrykk for å ekskludere hendelser. Hendelser med titler som inneholder noen av disse uttrykkene vil bli skjult. Eksempel: 'Privat|Møte|Konferanse'", 42 | "allowlist": "Tillattliste", 43 | "allowlist_note": "Pipe-separert liste med uttrykk for å inkludere hendelser. Hvis ikke tom, vil kun hendelser med titler som inneholder noen av disse uttrykkene bli vist. Eksempel: 'Fødselsdag|Jubileum|Viktig'", 44 | "entity_overrides": "Enhetsoverstyringer", 45 | "entity_overrides_note": "Disse innstillingene vil overstyre de globale kjerneinnstillingene for denne spesifikke kalenderen.", 46 | "compact_events_to_show": "Kompakte hendelser å vise", 47 | "entity_compact_events_note": "Overstyr antall hendelser som skal vises i kompakt modus for denne kalenderen.", 48 | "show_time": "Vis tid", 49 | "entity_show_time_note": "Vis eller skjul hendelsestider bare for denne kalenderen.", 50 | "show_location": "Vis sted", 51 | "entity_show_location_note": "Vis eller skjul hendelsessteder bare for denne kalenderen.", 52 | "split_multiday_events": "Del flerdagshendelser", 53 | "entity_split_multiday_note": "Del flerdagshendelser i individuelle dagssegmenter for denne kalenderen.", 54 | 55 | "core_settings": "Kjerneinnstillinger", 56 | "time_range": "Tidsområde", 57 | "time_range_note": "Dette tidsområdet definerer normal visningsmodus, som blir den utvidede visningen hvis kompakt modus er konfigurert.", 58 | "days_to_show": "Dager å vise", 59 | "days_to_show_note": "Antall dager å hente fra API og vise i kalenderen", 60 | "start_date": "Startdato", 61 | "start_date_mode": "Startdatomodus", 62 | "start_date_mode_default": "Standard (i dag)", 63 | "start_date_mode_fixed": "Fast dato", 64 | "start_date_mode_offset": "Relativt offset", 65 | "start_date_fixed": "Fast startdato", 66 | "start_date_offset": "Relativt offset fra i dag", 67 | "start_date_offset_note": "Skriv inn et positivt eller negativt tall for å forskyve fra i dag (f.eks. +1 for i morgen, -5 for fem dager siden).", 68 | "compact_mode": "Kompakt modus", 69 | "compact_mode_note": "Kompakt modus viser færre dager og/eller hendelser i utgangspunktet. Kortet kan utvides til full visning ved hjelp av et trykk eller hold-handling hvis konfigurert med handling: 'expand'.", 70 | "compact_days_to_show": "Kompakte dager å vise", 71 | "compact_events_complete_days": "Komplette dager i kompakt modus", 72 | "compact_events_complete_days_note": "Når aktivert, hvis minst én hendelse fra en dag vises, vil alle hendelser fra den dagen bli vist.", 73 | "event_visibility": "Hendelsessynlighet", 74 | "show_past_events": "Vis tidligere hendelser", 75 | "show_empty_days": "Vis tomme dager", 76 | "filter_duplicates": "Filtrer duplikate hendelser", 77 | "language_time_formats": "Språk og tidsformater", 78 | "language": "Språk", 79 | "language_mode": "Språkmodus", 80 | "language_code": "Språkkode", 81 | "language_code_note": "Skriv inn en 2-bokstavs språkkode (f.eks. 'nb', 'en', 'de')", 82 | "time_24h": "Tidsformat", 83 | "system": "Systemstandard", 84 | "custom": "Egendefinert", 85 | "12h": "12-timer", 86 | "24h": "24-timer", 87 | 88 | "appearance_layout": "Utseende og layout", 89 | "title_styling": "Tittelformatering", 90 | "title": "Tittel", 91 | "title_font_size": "Tittel skriftstørrelse", 92 | "title_color": "Tittelfarge", 93 | "card_styling": "Kortformatering", 94 | "background_color": "Bakgrunnsfarge", 95 | "height_mode": "Høydemodus", 96 | "auto": "Automatisk høyde", 97 | "fixed": "Fast høyde", 98 | "maximum": "Maksimal høyde", 99 | "height_value": "Høydeverdi", 100 | "fixed_height_note": "Kortet opprettholder alltid nøyaktig denne høyden uavhengig av innhold", 101 | "max_height_note": "Kortet vokser med innholdet opp til denne maksimale høyden", 102 | "event_styling": "Hendelsesformatering", 103 | "event_background_opacity": "Hendelse bakgrunnsopasitet", 104 | "vertical_line_width": "Vertikal linjebredde", 105 | "spacing_alignment": "Avstand og justering", 106 | "day_spacing": "Dag avstand", 107 | "event_spacing": "Hendelse avstand", 108 | "additional_card_spacing": "Ekstra kortavstand", 109 | 110 | "date_display": "Datovisning", 111 | "vertical_alignment": "Vertikal justering", 112 | "date_vertical_alignment": "Dato vertikal justering", 113 | "date_formatting": "Datoformatering", 114 | "top": "Topp", 115 | "middle": "Midten", 116 | "bottom": "Bunn", 117 | "weekday_font": "Ukedagsskrift", 118 | "weekday_font_size": "Ukedagsskriftstørrelse", 119 | "weekday_color": "Ukedagsfarge", 120 | "day_font": "Dagsskrift", 121 | "day_font_size": "Dagsskriftstørrelse", 122 | "day_color": "Dagsfarge", 123 | "month_font": "Månedsskrift", 124 | "show_month": "Vis måned", 125 | "month_font_size": "Månedsskriftstørrelse", 126 | "month_color": "Månedsfarge", 127 | "weekend_highlighting": "Helgefremheving", 128 | "weekend_weekday_color": "Helg ukedagsfarge", 129 | "weekend_day_color": "Helg dagsfarge", 130 | "weekend_month_color": "Helg månedsfarge", 131 | "today_highlighting": "I dag fremheving", 132 | "today_weekday_color": "I dag ukedagsfarge", 133 | "today_day_color": "I dag dagsfarge", 134 | "today_month_color": "I dag månedsfarge", 135 | "today_indicator": "I dag indikator", 136 | "dot": "Prikk", 137 | "pulse": "Puls", 138 | "glow": "Glød", 139 | "emoji": "Emoji", 140 | "emoji_value": "Emoji", 141 | "emoji_indicator_note": "Skriv inn et enkelt emoji-tegn som 🗓️", 142 | "image_path": "Bildesti", 143 | "image_indicator_note": "Sti til bilde som /local/image.jpg", 144 | "today_indicator_position": "I dag indikatorposisjon", 145 | "today_indicator_color": "I dag indikatorfarge", 146 | "today_indicator_size": "I dag indikatorstørrelse", 147 | "week_numbers_separators": "Ukenumre og skillelinjer", 148 | "week_numbers": "Ukenumre", 149 | "first_day_of_week": "Første dag i uken", 150 | "sunday": "Søndag", 151 | "monday": "Mandag", 152 | "show_week_numbers": "Vis ukenumre", 153 | "week_number_note_iso": "ISO (Europa/Internasjonal): Første uke inneholder første torsdag i året. Gir konsistent ukenummerering på tvers av år (ISO 8601-standard).", 154 | "week_number_note_simple": "Enkel (Nord-Amerika): Uker telles sekvensielt fra 1. januar uavhengig av ukedag. Første uke kan være delvis. Mer intuitiv, men mindre standardisert.", 155 | "show_current_week_number": "Vis gjeldende ukenummer", 156 | "week_number_font_size": "Ukenummer skriftstørrelse", 157 | "week_number_color": "Ukenummerfarge", 158 | "week_number_background_color": "Ukenummer bakgrunnsfarge", 159 | "day_separator": "Dagsskillelinje", 160 | "show_day_separator": "Vis dagsskillelinje", 161 | "day_separator_width": "Dagsskillelinje bredde", 162 | "day_separator_color": "Dagsskillelinje farge", 163 | "week_separator": "Ukeskillelinje", 164 | "show_week_separator": "Vis ukeskillelinje", 165 | "week_separator_width": "Ukeskillelinje bredde", 166 | "week_separator_color": "Ukeskillelinje farge", 167 | "month_separator": "Månedsskillelinje", 168 | "show_month_separator": "Vis månedsskillelinje", 169 | "month_separator_width": "Månedsskillelinje bredde", 170 | "month_separator_color": "Månedsskillelinje farge", 171 | 172 | "event_display": "Hendelsesvisning", 173 | "event_title": "Hendelsestitttel", 174 | "event_font_size": "Hendelsesskriftstørrelse", 175 | "empty_day_color": "Tom dagsfarge", 176 | "time": "Tid", 177 | "show_single_allday_time": "Vis tid for heldagshendelser", 178 | "show_end_time": "Vis sluttid", 179 | "time_font_size": "Tidsskriftstørrelse", 180 | "time_color": "Tidsfarge", 181 | "time_icon_size": "Tidsikonstørrelse", 182 | "location": "Sted", 183 | "remove_location_country": "Fjern stedets land", 184 | "location_font_size": "Stedsskriftstørrelse", 185 | "location_color": "Stedsfarge", 186 | "location_icon_size": "Stedsikonstørrelse", 187 | "custom_country_pattern": "Landsmønstre å fjerne", 188 | "custom_country_pattern_note": "Skriv inn landsnavn som et regulært uttrykksmønster (f.eks. 'Norge|USA|Sverige'). Hendelser med steder som slutter med disse mønstrene vil få landet fjernet.", 189 | "progress_indicators": "Fremgangsindikatorer", 190 | "show_countdown": "Vis nedtelling", 191 | "show_progress_bar": "Vis fremdriftslinje", 192 | "progress_bar_color": "Fremdriftslinjefarge", 193 | "progress_bar_height": "Fremdriftslinjehøyde", 194 | "progress_bar_width": "Fremdriftslinje bredde", 195 | "multiday_event_handling": "Flerdagshendelseshåndtering", 196 | 197 | "weather_integration": "Værintegrasjon", 198 | "weather_entity_position": "Værenhet og posisjon", 199 | "weather_entity": "Værenhet", 200 | "weather_position": "Værposisjon", 201 | "date": "Dato", 202 | "event": "Hendelse", 203 | "both": "Begge", 204 | "date_column_weather": "Datokolonne vær", 205 | "show_conditions": "Vis værforhold", 206 | "show_high_temp": "Vis høy temperatur", 207 | "show_low_temp": "Vis lav temperatur", 208 | "icon_size": "Ikonstørrelse", 209 | "font_size": "Skriftstørrelse", 210 | "color": "Farge", 211 | "event_row_weather": "Hendelsesrad vær", 212 | "show_temp": "Vis temperatur", 213 | 214 | "interactions": "Interaksjoner", 215 | "tap_action": "Trykkehandling", 216 | "hold_action": "Holdehandling", 217 | "more_info": "Mer info", 218 | "navigate": "Naviger", 219 | "url": "URL", 220 | "call_service": "Kall tjeneste", 221 | "expand": "Bytt kompakt/utvidet visning", 222 | "navigation_path": "Navigasjonssti", 223 | "url_path": "URL", 224 | "service": "Tjeneste", 225 | "service_data": "Tjenestedata (JSON)", 226 | "refresh_settings": "Oppdateringsinnstillinger", 227 | "refresh_interval": "Oppdateringsintervall (minutter)", 228 | "refresh_on_navigate": "Oppdater ved navigering tilbake", 229 | 230 | "deprecated_config_detected": "Utdaterte konfigurasjonsalternativer oppdaget.", 231 | "deprecated_config_explanation": "Noen alternativer i konfigurasjonen din støttes ikke lenger.", 232 | "deprecated_config_update_hint": "Vennligst oppdater for å sikre kompatibilitet.", 233 | "update_config": "Oppdater konfigurasjon" 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /src/translations/localize.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/order */ 2 | /** 3 | * Localization module for Calendar Card Pro 4 | * 5 | * This module handles loading and accessing translations 6 | * for different languages in the Calendar Card Pro. 7 | */ 8 | 9 | import * as Types from '../config/types'; 10 | import * as Logger from '../utils/logger'; 11 | 12 | // Import language files (sorted alphabetically by language code) 13 | import bgTranslations from './languages/bg.json'; 14 | import csTranslations from './languages/cs.json'; 15 | import caTranslations from './languages/ca.json'; 16 | import daTranslations from './languages/da.json'; 17 | import deTranslations from './languages/de.json'; 18 | import elTranslations from './languages/el.json'; 19 | import enTranslations from './languages/en.json'; 20 | import esTranslations from './languages/es.json'; 21 | import fiTranslations from './languages/fi.json'; 22 | import frTranslations from './languages/fr.json'; 23 | import heTranslations from './languages/he.json'; 24 | import hrTranslations from './languages/hr.json'; 25 | import huTranslations from './languages/hu.json'; 26 | import isTranslations from './languages/is.json'; 27 | import itTranslations from './languages/it.json'; 28 | import nbTranslations from './languages/nb.json'; 29 | import nlTranslations from './languages/nl.json'; 30 | import nnTranslations from './languages/nn.json'; 31 | import plTranslations from './languages/pl.json'; 32 | import ptTranslations from './languages/pt.json'; 33 | import roTranslations from './languages/ro.json'; 34 | import ruTranslations from './languages/ru.json'; 35 | import slTranslations from './languages/sl.json'; 36 | import skTranslations from './languages/sk.json'; 37 | import svTranslations from './languages/sv.json'; 38 | import ukTranslations from './languages/uk.json'; 39 | import viTranslations from './languages/vi.json'; 40 | import thTranslations from './languages/th.json'; 41 | import zhCNTranslations from './languages/zh-CN.json'; 42 | import zhTWTranslations from './languages/zh-TW.json'; 43 | 44 | /** 45 | * Available translations keyed by language code 46 | */ 47 | export const TRANSLATIONS: Record = { 48 | // Sorted alphabetically by language code 49 | bg: bgTranslations, 50 | cs: csTranslations, 51 | ca: caTranslations, 52 | da: daTranslations, 53 | de: deTranslations, 54 | el: elTranslations, 55 | en: enTranslations, 56 | es: esTranslations, 57 | fi: fiTranslations, 58 | fr: frTranslations, 59 | he: heTranslations, 60 | hr: hrTranslations, 61 | hu: huTranslations, 62 | is: isTranslations, 63 | it: itTranslations, 64 | nb: nbTranslations, 65 | nl: nlTranslations, 66 | nn: nnTranslations, 67 | pl: plTranslations, 68 | pt: ptTranslations, 69 | ro: roTranslations, 70 | ru: ruTranslations, 71 | sl: slTranslations, 72 | sk: skTranslations, 73 | sv: svTranslations, 74 | uk: ukTranslations, 75 | vi: viTranslations, 76 | th: thTranslations, 77 | 'zh-cn': zhCNTranslations, 78 | 'zh-tw': zhTWTranslations, 79 | }; 80 | 81 | /** 82 | * Default language to use if requested language is not available 83 | */ 84 | export const DEFAULT_LANGUAGE = 'en'; 85 | 86 | //----------------------------------------------------------------------------- 87 | // HIGH-LEVEL API FUNCTIONS 88 | //----------------------------------------------------------------------------- 89 | 90 | // Cache for already determined languages to prevent repeated calculations 91 | const languageCache = new Map(); 92 | 93 | /** 94 | * Determine the effective language based on priority order: 95 | * 1. User config language (if specified and supported) 96 | * 2. HA system language (if available and supported) 97 | * 3. Default language fallback 98 | * 99 | * @param configLanguage - Language from user configuration 100 | * @param hassLocale - Home Assistant locale information 101 | * @returns The effective language code to use 102 | */ 103 | export function getEffectiveLanguage( 104 | configLanguage?: string, 105 | hassLocale?: { language: string }, 106 | ): string { 107 | // Create cache key from inputs 108 | const cacheKey = `${configLanguage || ''}:${hassLocale?.language || ''}`; 109 | 110 | // Return cached result if available 111 | if (languageCache.has(cacheKey)) { 112 | return languageCache.get(cacheKey)!; 113 | } 114 | 115 | let effectiveLanguage: string; 116 | 117 | // Priority 1: Use config language if specified and supported 118 | if (configLanguage && configLanguage.trim() !== '') { 119 | const configLang = configLanguage.toLowerCase(); 120 | if (TRANSLATIONS[configLang]) { 121 | effectiveLanguage = configLang; 122 | languageCache.set(cacheKey, effectiveLanguage); 123 | return effectiveLanguage; 124 | } 125 | } 126 | 127 | // Priority 2: Use HA system language if available and supported 128 | if (hassLocale?.language) { 129 | const sysLang = hassLocale.language.toLowerCase(); 130 | if (TRANSLATIONS[sysLang]) { 131 | effectiveLanguage = sysLang; 132 | languageCache.set(cacheKey, effectiveLanguage); 133 | return effectiveLanguage; 134 | } 135 | 136 | // Check for language part only (e.g., "de" from "de-DE") 137 | const langPart = sysLang.split(/[-_]/)[0]; 138 | if (langPart !== sysLang && TRANSLATIONS[langPart]) { 139 | effectiveLanguage = langPart; 140 | languageCache.set(cacheKey, effectiveLanguage); 141 | return effectiveLanguage; 142 | } 143 | } 144 | 145 | // Priority 3: Use default language as fallback 146 | effectiveLanguage = DEFAULT_LANGUAGE; 147 | languageCache.set(cacheKey, effectiveLanguage); 148 | return effectiveLanguage; 149 | } 150 | 151 | /** 152 | * Get translations for the specified language 153 | * Falls back to English if the language is not available 154 | * 155 | * @param language - Language code to get translations for 156 | * @returns Translations object for the requested language 157 | */ 158 | export function getTranslations(language: string): Types.Translations { 159 | const lang = language?.toLowerCase() || DEFAULT_LANGUAGE; 160 | return TRANSLATIONS[lang] || TRANSLATIONS[DEFAULT_LANGUAGE]; 161 | } 162 | 163 | /** 164 | * Get a specific translation string from the provided language 165 | * 166 | * @param language - Language code 167 | * @param key - Translation key or path (supports 'editor.key' format) 168 | * @param fallback - Optional fallback value if translation is missing 169 | * @returns Translated string or array 170 | */ 171 | export function translate( 172 | language: string, 173 | key: keyof Types.Translations | string, 174 | fallback?: string | string[], 175 | ): string | string[] { 176 | const translations = getTranslations(language); 177 | 178 | // Handle editor translations which use dot notation (editor.some_key) 179 | if (typeof key === 'string' && key.includes('.')) { 180 | const [section, subKey] = key.split('.'); 181 | if (section === 'editor' && translations.editor && subKey in translations.editor) { 182 | const editorValue = translations.editor[subKey]; 183 | // Explicitly check and return only string or string[] values 184 | if (typeof editorValue === 'string' || Array.isArray(editorValue)) { 185 | return editorValue; 186 | } 187 | } 188 | // If nested path doesn't exist or is wrong type, use fallback or subKey 189 | return fallback !== undefined ? fallback : subKey; 190 | } 191 | 192 | // Handle direct keys in the translations object 193 | if (key in translations) { 194 | const value = translations[key as keyof Types.Translations]; 195 | // Handle the value safely to ensure return type matches 196 | if (typeof value === 'string' || Array.isArray(value)) { 197 | return value; 198 | } 199 | } 200 | 201 | // Use fallback or key name if translation is missing 202 | return fallback !== undefined ? fallback : (key as string); 203 | } 204 | 205 | /** 206 | * Check if the specified language has editor translations 207 | * 208 | * @param language - Language code to check 209 | * @returns True if the language has editor translations 210 | */ 211 | export function hasEditorTranslations(language: string): boolean { 212 | const translations = getTranslations(language); 213 | return Boolean(translations?.editor && Object.keys(translations.editor).length > 0); 214 | } 215 | 216 | //----------------------------------------------------------------------------- 217 | // TEXT FORMATTING FUNCTIONS 218 | //----------------------------------------------------------------------------- 219 | 220 | /** 221 | * Determine the date format style for a given language 222 | * 223 | * @param language - Language code 224 | * @returns Date format style identifier ('day-dot-month', 'month-day', or 'day-month') 225 | */ 226 | export function getDateFormatStyle(language: string): 'day-dot-month' | 'month-day' | 'day-month' { 227 | const lang = language?.toLowerCase() || ''; 228 | 229 | // German and Croatian use day with dot, then month (e.g., "17. Mar") 230 | if (lang === 'de' || lang === 'hr') { 231 | return 'day-dot-month'; 232 | } 233 | 234 | // English and Hungarian use month then day (e.g., "Mar 17") 235 | if (lang === 'en' || lang === 'hu') { 236 | return 'month-day'; 237 | } 238 | 239 | // Default for most other languages: day then month without dot (e.g., "17 Mar") 240 | return 'day-month'; 241 | } 242 | 243 | /** 244 | * Get day name from translations based on day index 245 | * 246 | * @param language - Language code 247 | * @param dayIndex - Day index (0 = Sunday, 6 = Saturday) 248 | * @param full - Whether to use full day names 249 | * @returns Translated day name 250 | */ 251 | export function getDayName(language: string, dayIndex: number, full = false): string { 252 | const translations = getTranslations(language); 253 | const key = full ? 'fullDaysOfWeek' : 'daysOfWeek'; 254 | 255 | if (dayIndex < 0 || dayIndex > 6) { 256 | Logger.warn(`Invalid day index ${dayIndex}. Using default.`); 257 | dayIndex = 0; // Default to Sunday if invalid 258 | } 259 | 260 | return translations[key][dayIndex]; 261 | } 262 | 263 | /** 264 | * Get month name from translations based on month index 265 | * 266 | * @param language - Language code 267 | * @param monthIndex - Month index (0 = January, 11 = December) 268 | * @returns Translated month name 269 | */ 270 | export function getMonthName(language: string, monthIndex: number): string { 271 | const translations = getTranslations(language); 272 | 273 | if (monthIndex < 0 || monthIndex > 11) { 274 | Logger.warn(`Invalid month index ${monthIndex}. Using default.`); 275 | monthIndex = 0; // Default to January if invalid 276 | } 277 | 278 | return translations.months[monthIndex]; 279 | } 280 | 281 | /** 282 | * Format a date according to the locale 283 | * Shows just the day and month name in the selected language 284 | * 285 | * @param language - Language code 286 | * @param date - Date to format 287 | * @returns Formatted date string 288 | */ 289 | export function formatDateShort(language: string, date: Date): string { 290 | const day = date.getDate(); 291 | const month = getMonthName(language, date.getMonth()); 292 | const formatStyle = getDateFormatStyle(language); 293 | 294 | switch (formatStyle) { 295 | case 'day-dot-month': 296 | return `${day}. ${month}`; 297 | case 'month-day': 298 | return `${month} ${day}`; 299 | case 'day-month': 300 | default: 301 | return `${day} ${month}`; 302 | } 303 | } 304 | 305 | //----------------------------------------------------------------------------- 306 | // LANGUAGE MANAGEMENT UTILITIES 307 | //----------------------------------------------------------------------------- 308 | 309 | /** 310 | * Get all supported languages 311 | * 312 | * @returns Array of supported language codes 313 | */ 314 | export function getSupportedLanguages(): string[] { 315 | return Object.keys(TRANSLATIONS); 316 | } 317 | 318 | /** 319 | * Check if a language is supported 320 | * 321 | * @param language - Language code to check 322 | * @returns True if language is supported, false otherwise 323 | */ 324 | export function isLanguageSupported(language: string): boolean { 325 | return language?.toLowerCase() in TRANSLATIONS; 326 | } 327 | 328 | /** 329 | * Add a new translation set for a language 330 | * This can be used for dynamic registration of new languages 331 | * 332 | * @param language - Language code 333 | * @param translations - Translations object 334 | */ 335 | export function addTranslations(language: string, translations: Types.Translations): void { 336 | if (!language) { 337 | Logger.error('Cannot add translations without a language code'); 338 | return; 339 | } 340 | 341 | TRANSLATIONS[language.toLowerCase()] = translations; 342 | } 343 | -------------------------------------------------------------------------------- /src/translations/languages/sk.json: -------------------------------------------------------------------------------- 1 | { 2 | "daysOfWeek": ["Ne", "Po", "Ut", "St", "Št", "Pi", "So"], 3 | "fullDaysOfWeek": ["Nedeľa", "Pondelok", "Utorok", "Streda", "Štvrtok", "Piatok", "Sobota"], 4 | "months": ["Jan", "Feb", "Mar", "Apr", "Máj", "Jún", "Júl", "Aug", "Sep", "Okt", "Nov", "Dec"], 5 | "allDay": "celý deň", 6 | "multiDay": "do", 7 | "at": "v", 8 | "endsToday": "končí dnes", 9 | "endsTomorrow": "končí zajtra", 10 | "noEvents": "Žiadne udalosti", 11 | "loading": "Načítavam udalosti z kalendára...", 12 | "error": "Chyba: Entita kalendára nebola nájdená alebo je nesprávne nakonfigurovaná", 13 | "editor": { 14 | "calendar_entities": "Entity kalendára", 15 | "calendar": "Kalendár", 16 | "entity_identification": "ID entity", 17 | "entity": "Entita", 18 | "add_calendar": "Pridať kalendár", 19 | "remove": "Odstrániť", 20 | "convert_to_advanced": "Previesť na pokročilé", 21 | "simple": "Jednoduché", 22 | "display_settings": "Nastavenia zobrazenia", 23 | "label": "Menovka", 24 | "label_type": "Typ menovky", 25 | "none": "Žiadna", 26 | "text_emoji": "Textová/Emodži", 27 | "icon": "Ikona", 28 | "text_value": "Textová hodnota", 29 | "text_label_note": "Zadajte text alebo emodži, napr. '📅 Môj kalendár'", 30 | "image": "Obrázok", 31 | "image_label_note": "Cesta k obrázku, napr. /local/calendar.jpg", 32 | "label_note": "Vlastná menovka tohto kalendára.", 33 | "colors": "Farby", 34 | "event_color": "Farba udalosti", 35 | "entity_color_note": "Vlastná farba pre názvy udalostí v tomto kalendári.", 36 | "accent_color": "Farba zvýraznenia", 37 | "entity_accent_color_note": "Vlastná farba zvýraznenia zvislej čiary udalostí v tomto kalendári. Táto farba zároveň použije aj ako pozadie udalosti, ak je atribút 'event_background_opacity' väčší ako nula.", 38 | "event_filtering": "Filtrovanie udalostí", 39 | "blocklist": "Zoznam zakázaných (blocklist)", 40 | "blocklist_note": "Zvislou čiarou (|) oddelený zoznam výrazov slúžiacich na výber ignorovaných udalostí. Udalosti s názvom obsahujúcim akýkoľvek z uvedených výrazov budú skryté. Príklad: 'Súkromné|Porada|Konferencia'", 41 | "allowlist": "Zoznam povolených (allowlist)", 42 | "allowlist_note": "Zvislou čiarou (|) oddelený zoznam výrazov slúžiacich na výber povolených udalostí. Ak obsahuje hodnotu, zobrazia sa iba udalosti obsahujúce jeden z uvedených výrazov. Príklad: 'Narodeniny|Výročie|Dôležité'", 43 | "entity_overrides": "Úpravy nastavení", 44 | "entity_overrides_note": "Tieto nastavenie prepíšu globálne nastavenia pre konkrétny kalendár.", 45 | "compact_events_to_show": "Zhutniť zobrazené udalosti", 46 | "entity_compact_events_note": "Zmeniť počet udalostí tohto kalendára, ktoré budú zobrazené v kompaktnom režime.", 47 | "show_time": "Zobrazovať čas", 48 | "entity_show_time_note": "Zobraziť alebo skryť čas udalostí v tomto kalendári.", 49 | "show_location": "Zobrazovať miesto", 50 | "entity_show_location_note": "Zobraziť alebo skryť miesto udalostí v tomto kalendári.", 51 | "split_multiday_events": "Rozdeľovať niekoľkodňové udalosti", 52 | "entity_split_multiday_note": "Rozdeliť niekoľkodňové udalosti v tomto kalendári do jednotlivých dní.", 53 | 54 | "core_settings": "Globálne nastavenia", 55 | "time_range": "Časový rozsah", 56 | "time_range_note": "Tento časový rozsah nastavuje režim normálneho zobrazenia, ktorý sa stane rozšíreným zobrazením, ak je nakonfigurovaný kompaktný režim.", 57 | "days_to_show": "Zobrazované dni", 58 | "days_to_show_note": "Počet dní, ktoré sa majú z API načítavať a zobrazovať v kalendári", 59 | "start_date": "Počiatočný dátum", 60 | "start_date_mode": "Režim počiatočného dátumu", 61 | "start_date_mode_default": "Predvolený (dnes)", 62 | "start_date_mode_fixed": "Pevný dátum", 63 | "start_date_mode_offset": "Relatívny posun", 64 | "start_date_fixed": "Pevný dátum začiatku", 65 | "start_date_offset": "Relatívny posun od dneška", 66 | "start_date_offset_note": "Zadajte kladné alebo záporné číslo, ktoré sa pripočíta/odpočíta od dneška (napr. +1 pre zajtrajšok, -5 pre termín pred 5 dňami).", 67 | "compact_mode": "Kompaktný režim", 68 | "compact_mode_note": "Kompaktný režim zobrazuje na začiatok menej dní a/alebo udalostí. Kartu môžete rozbaliť na plné zobrazenie akciou kliknutia alebo podržania, ak jej nastavíte akciu: 'expand'.", 69 | "compact_days_to_show": "Kompaktné dni, ktoré sa zobrazia", 70 | "compact_events_complete_days": "Úplné dni v kompaktnom režime", 71 | "compact_events_complete_days_note": "Ak je táto voľba zapnutá, pri zobrazení aspoň jednej udalosti z daného dňa sa budú zobrazovať všetky udalosti v tento deň.", 72 | "event_visibility": "Viditeľnosť udalostí", 73 | "show_past_events": "Zobrazovať udalosti, ktoré už prebehli", 74 | "show_empty_days": "Zobrazovať prázdne dni", 75 | "filter_duplicates": "Filtrovať duplicitné udalosti", 76 | "language_time_formats": "Jazyk a formát času", 77 | "language": "Jazyk", 78 | "language_mode": "Režim jazyka", 79 | "language_code": "Kód jazyka", 80 | "language_code_note": "Zadajte dvojpísmenný kód jazyka (napr. 'en', 'sk', 'cs')", 81 | "time_24h": "Formát času", 82 | "system": "Predvolený systémom", 83 | "custom": "Vlastný", 84 | "12h": "12 hod.", 85 | "24h": "24 hod.", 86 | 87 | "appearance_layout": "Vzhľad a rozloženie", 88 | "title_styling": "Štýl názvu", 89 | "title": "Názov", 90 | "title_font_size": "Veľkosť písma názvu", 91 | "title_color": "Farba názvu", 92 | "card_styling": "Štýl karty", 93 | "background_color": "Farba pozadia", 94 | "height_mode": "Režim výšky", 95 | "auto": "Automatická výška", 96 | "fixed": "Pevná výška", 97 | "maximum": "Maximálna výška", 98 | "height_value": "Hodnota výšky", 99 | "fixed_height_note": "Karta vždy používa presne túto výšku bez ohľadu na zobrazený obsah", 100 | "max_height_note": "Výška karty sa zväčšuje podľa zobrazeného obsahu až do uvedenej maximálnej hodnoty", 101 | "event_styling": "Štýl udalostí", 102 | "event_background_opacity": "Nepriehľadnosť pozadia udalostí", 103 | "vertical_line_width": "Šírka zvislej čiary", 104 | "spacing_alignment": "Medzery a zarovnanie", 105 | "day_spacing": "Medzery medzi dňami", 106 | "event_spacing": "Medzery medzi udalosťami", 107 | "additional_card_spacing": "Dodatočné medzery karty", 108 | 109 | "date_display": "Zobrazenie dátumu", 110 | "vertical_alignment": "Zvislé zarovnanie", 111 | "date_vertical_alignment": "Zvislé zarovnanie dátumu", 112 | "date_formatting": "Formátovanie dátumu", 113 | "top": "Hore", 114 | "middle": "Stred", 115 | "bottom": "Dole", 116 | "weekday_font": "Písmo pracovného dňa", 117 | "weekday_font_size": "Veľkosť písma pracovného dňa", 118 | "weekday_color": "Farba pracovného dňa", 119 | "day_font": "Písmo dňa", 120 | "day_font_size": "Veľkosť písma dňa", 121 | "day_color": "Farba dňa", 122 | "month_font": "Písmo mesiaca", 123 | "show_month": "Zobrazovať mesiac", 124 | "month_font_size": "Veľkosť písma mesiaca", 125 | "month_color": "Farba mesiaca", 126 | "weekend_highlighting": "Zvýraznenie víkendu", 127 | "weekend_weekday_color": "Farba pracovného dňa", 128 | "weekend_day_color": "Farba víkendového dňa", 129 | "weekend_month_color": "Farba víkendu v mesiaci", 130 | "today_highlighting": "Zvýraznenie dneška", 131 | "today_weekday_color": "Farba dnešného pracovného dňa", 132 | "today_day_color": "Farba dneška", 133 | "today_month_color": "Farba dneška v mesiaci", 134 | "today_indicator": "Identifikátor dneška", 135 | "dot": "Bodka", 136 | "pulse": "Pulz", 137 | "glow": "Žiara", 138 | "emoji": "Emodži", 139 | "emoji_value": "Emodži", 140 | "emoji_indicator_note": "Zadajte jeden znak emodži, napr. 🗓️", 141 | "image_path": "Cesta k obrázku", 142 | "image_indicator_note": "Cesta k obrázku, napr. /local/image.jpg", 143 | "today_indicator_position": "Umiestnenie identifikátora dneška", 144 | "today_indicator_color": "Farba identifikátora dneška", 145 | "today_indicator_size": "Veľkosť identifikátora dneška", 146 | "week_numbers_separators": "Čísla týždňov a oddeľovače", 147 | "week_numbers": "Čísla týždňov", 148 | "first_day_of_week": "Prvý deň týždňa", 149 | "sunday": "Nedeľa", 150 | "monday": "Pondelok", 151 | "show_week_numbers": "Zobrazovať čísla týždňov", 152 | "week_number_note_iso": "ISO (Európa/Medzinárodné): Prvý týždeň zahŕňa prvý štvrtok v roku. Vytvára konzistentné číslovanie týždňov medzi jednotlivými rokmi (štandard ISO 8601).", 153 | "week_number_note_simple": "Jednoduché (Severná Amerika): Týždne sú číslované od prvého januára bez ohľadu na deň týždňa. Prvý týždeň nemusí byť úplný. Intuitívnejší, ale menej štandardizovaný.", 154 | "show_current_week_number": "Zobrazovať aktuálne číslo týždňa", 155 | "week_number_font_size": "Veľkosť písma čísla týždňa", 156 | "week_number_color": "Farba čísla týždňa", 157 | "week_number_background_color": "Farba pozadia čísla týždňa", 158 | "day_separator": "Oddeľovač dní", 159 | "show_day_separator": "Zobrazovať oddeľovač dní", 160 | "day_separator_width": "Šírka oddeľovača dní", 161 | "day_separator_color": "Farba oddeľovača dní", 162 | "week_separator": "Oddeľovač týždňov", 163 | "show_week_separator": "Zobrazovať oddeľovač týždňov", 164 | "week_separator_width": "Šírka oddeľovača týždňov", 165 | "week_separator_color": "Farba oddeľovača týždňov", 166 | "month_separator": "Oddeľovač mesiacov", 167 | "show_month_separator": "Zobrazovať oddeľovač mesiacov", 168 | "month_separator_width": "Šírka oddeľovača mesiacov", 169 | "month_separator_color": "Farba oddeľovača mesiacov", 170 | 171 | "event_display": "Zobrazenie udalosti", 172 | "event_title": "Názov udalosti", 173 | "event_font_size": "Veľkosť písma udalosti", 174 | "empty_day_color": "Farba prázdneho dňa", 175 | "time": "Čas", 176 | "show_single_allday_time": "Zobrazovať čas pri celodenných udalostiach", 177 | "show_end_time": "Zobrazovať čas skončenia", 178 | "time_font_size": "Veľkosť písma času", 179 | "time_color": "Farba času", 180 | "time_icon_size": "Veľkosť ikony času", 181 | "location": "Miesto", 182 | "remove_location_country": "Odstrániť krajinu", 183 | "location_font_size": "Veľkosť písma miesta", 184 | "location_color": "Farba miesta", 185 | "location_icon_size": "Veľkosť ikony miesta", 186 | "custom_country_pattern": "Vzory krajín, ktoré budú odstránené", 187 | "custom_country_pattern_note": "Zadajte mená krajín ako vzor regulárneho výrazu (napr. 'USA|Spojené štáty|Kanada'). V udalosti, ktorých miesta sa končia na ktorýkoľvek z týchto výrazov, sa odstráni krajina.", 188 | "progress_indicators": "Ukazovateľ priebehu", 189 | "show_countdown": "Zobraziť odpočítavanie", 190 | "show_progress_bar": "Zobraziť lištu priebehu", 191 | "progress_bar_color": "Farba lišty priebehu", 192 | "progress_bar_height": "Výška lišty priebehu", 193 | "progress_bar_width": "Šírka lišty priebehu", 194 | "multiday_event_handling": "Spracovanie viacdenných udalostí", 195 | 196 | "weather_integration": "Integrácia s počasím", 197 | "weather_entity_position": "Entita počasia a umiestnenie", 198 | "weather_entity": "Entita počasia", 199 | "weather_position": "Umiestnenie počasia", 200 | "date": "Dátum", 201 | "event": "Udalosť", 202 | "both": "Obe", 203 | "date_column_weather": "Počasie v stĺpci s dátumom", 204 | "show_conditions": "Zobraziť predpoveď", 205 | "show_high_temp": "Zobraziť najvyššiu teplotu", 206 | "show_low_temp": "Zobraziť najnižšiu teplotu", 207 | "icon_size": "Veľkosť ikony", 208 | "font_size": "Veľkosť písma", 209 | "color": "Farba", 210 | "event_row_weather": "Počasie v riadku s udalosťou", 211 | "show_temp": "Zobraziť teplotu", 212 | 213 | "interactions": "Interakcie", 214 | "tap_action": "Akcia kliknutia", 215 | "hold_action": "Akcia podržania", 216 | "more_info": "Viac informácií", 217 | "navigate": "Navigovať", 218 | "url": "URL adresa", 219 | "call_service": "Zavolať službu", 220 | "expand": "Prepnúť kompaktný/rozšírený pohľad", 221 | "navigation_path": "Cesta pre navigáciu", 222 | "url_path": "URL adresa", 223 | "service": "Služba", 224 | "service_data": "Údaje pre službu (JSON)", 225 | "refresh_settings": "Nastavenie obnovenia", 226 | "refresh_interval": "Interval obnovenia (minúty)", 227 | "refresh_on_navigate": "Obnoviť pri navigovaní späť", 228 | 229 | "deprecated_config_detected": "Boli zistené zastarané možnosti konfigurácie.", 230 | "deprecated_config_explanation": "Niektoré voľby vo vašej konfigurácií už nie sú podporované.", 231 | "deprecated_config_update_hint": "Prosím, aktualizujte ich, aby ste tak zaistili kompatibilitu.", 232 | "update_config": "Aktualizovať konfiguráciu…" 233 | } 234 | } 235 | -------------------------------------------------------------------------------- /src/translations/languages/de.json: -------------------------------------------------------------------------------- 1 | { 2 | "daysOfWeek": ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"], 3 | "fullDaysOfWeek": [ 4 | "Sonntag", 5 | "Montag", 6 | "Dienstag", 7 | "Mittwoch", 8 | "Donnerstag", 9 | "Freitag", 10 | "Samstag" 11 | ], 12 | "months": ["Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"], 13 | "allDay": "ganztägig", 14 | "multiDay": "bis", 15 | "at": "um", 16 | "endsToday": "endet heute", 17 | "endsTomorrow": "endet morgen", 18 | "noEvents": "Keine anstehenden Termine", 19 | "loading": "Kalendereinträge werden geladen...", 20 | "error": "Fehler: Kalender-Entität nicht gefunden oder falsch konfiguriert", 21 | 22 | "editor": { 23 | "calendar_entities": "Kalender Entitäten", 24 | "calendar": "Kalender", 25 | "entity_identification": "Entitäts-Identifikation", 26 | "entity": "Entität", 27 | "add_calendar": "Kalender hinzufügen", 28 | "remove": "Entfernen", 29 | "convert_to_advanced": "In Erweitert umwandeln", 30 | "simple": "Einfach", 31 | "display_settings": "Ansichts-Einstellungen", 32 | "label": "Label", 33 | "label_type": "Label-Typ", 34 | "none": "Keiner", 35 | "text_emoji": "Text/Emoji", 36 | "icon": "Symbol", 37 | "text_value": "Textwert", 38 | "text_label_note": "Text oder Emoji wie '📅 Mein Kalender' eingeben", 39 | "image": "Bild", 40 | "image_label_note": "Pfad zum Bild wie /local/calendar.jpg", 41 | "label_note": "Benutzerdefiniertes Label für diesen Kalender.", 42 | "colors": "Farben", 43 | "event_color": "Ereignisfarbe", 44 | "entity_color_note": "Benutzerdefinierte Farbe für Ereignistitel aus diesem Kalender.", 45 | "accent_color": "Akzentfarbe", 46 | "entity_accent_color_note": "Benutzerdefinierte Akzentfarbe für die vertikale Linie der Ereignisse in diesem Kalender. Diese Farbe wird auch als Hintergrundfarbe für Ereignisse verwendet, wenn 'event_background_opacity' >0 ist.", 47 | "event_filtering": "Ereignisfilterung", 48 | "blocklist": "Sperrliste", 49 | "blocklist_note": "Durch Pipes getrennte Liste von Begriffen zum Ausschließen von Ereignissen. Ereignisse, deren Titel einen dieser Begriffe enthalten, werden ausgeblendet. Beispiel: 'Privat|Meeting|Konferenz'", 50 | "allowlist": "Zulassungsliste", 51 | "allowlist_note": "Durch Pipes getrennte Liste von Begriffen zur Einbeziehung von Ereignissen. Wenn diese Liste nicht leer ist, werden nur Ereignisse angezeigt, deren Titel einen dieser Begriffe enthalten. Beispiel: 'Geburtstag|Jahrestag|Wichtig'", 52 | "entity_overrides": "Entitäts-Überschreibungen", 53 | "entity_overrides_note": "Diese Einstellungen überschreiben die globalen Haupteinstellungen für diesen bestimmten Kalender.", 54 | "compact_events_to_show": "Kompakte Ereignisse zur Ansicht", 55 | "entity_compact_events_note": "Anzahl der Ereignisse überschreiben, die für diesen Kalender im Kompaktmodus angezeigt werden sollen.", 56 | "show_time": "Zeit anzeigen", 57 | "entity_show_time_note": "Ereigniszeiten nur für diesen Kalender anzeigen oder ausblenden.", 58 | "show_location": "Ort anzeigen", 59 | "entity_show_location_note": "Veranstaltungsorte nur für diesen Kalender anzeigen oder ausblenden.", 60 | "split_multiday_events": "Mehrtägige Ereignisse aufteilen", 61 | "entity_split_multiday_note": "Für diesen Kalender mehrtägige Ereignisse in einzelne Tagessegmente aufteilen.", 62 | 63 | "core_settings": "Haupteinstellungen", 64 | "time_range": "Zeitbereich", 65 | "time_range_note": "Dieser Zeitbereich definiert den regulären Anzeigemodus, der zur erweiterten Ansicht wird, wenn der Kompaktmodus konfiguriert ist.", 66 | "days_to_show": "Anzahl Tage anzeigen", 67 | "days_to_show_note": "Anzahl der Tage, die von der API abgerufen und im Kalender angezeigt werden sollen.", 68 | "start_date": "Startdatum", 69 | "start_date_mode": "Startdatumsmodus", 70 | "start_date_mode_default": "Standard (heute)", 71 | "start_date_mode_fixed": "Festes Datum", 72 | "start_date_mode_offset": "Relatives Offset", 73 | "start_date_fixed": "Festes Startdatum", 74 | "start_date_offset": "Relatives Offset von heute", 75 | "start_date_offset_note": "Eine positive oder negative Zahl eingeben, um den Wert vom heutigen Tag abzuweichen (z.B. +1 für morgen, -5 für vor fünf Tagen).", 76 | "compact_mode": "Kompaktmodus", 77 | "compact_mode_note": "Im Kompaktmodus werden zunächst weniger Tage und/oder Ereignisse angezeigt. Die Karte kann durch Tippen oder Halten auf die Vollansicht erweitert werden, wenn sie mit der Aktion 'Erweitern' konfiguriert ist.", 78 | "compact_days_to_show": "Anzahl kompakter Tage anzeigen", 79 | "compact_events_complete_days": "Komplette Tage im Kompaktmodus", 80 | "compact_events_complete_days_note": "Wenn aktiviert und mindestens ein Ereignis eines Tages angezeigt wird, werden alle Ereignisse dieses Tages angezeigt.", 81 | "event_visibility": "Sichtbarkeit von Ereignissen", 82 | "show_past_events": "Vergangene Ereignisse anzeigen", 83 | "show_empty_days": "Leere Tage anzeigen", 84 | "filter_duplicates": "Doppelte Ereignisse filtern", 85 | "language_time_formats": "Sprach- & Zeitformate", 86 | "language": "Sprache", 87 | "language_mode": "Sprachmodus", 88 | "language_code": "Sprachcode", 89 | "language_code_note": "Einen 2-Buchstaben-Sprachcode eingeben (z.B., 'en', 'de', 'fr')", 90 | "time_24h": "Zeitformat", 91 | "system": "Systemstandard", 92 | "custom": "Benutzerdefiniert", 93 | "12h": "12-Stunden", 94 | "24h": "24-Stunden", 95 | 96 | "appearance_layout": "Aussehen & Layout", 97 | "title_styling": "Titelgestaltung", 98 | "title": "Titel", 99 | "title_font_size": "Schriftgröße", 100 | "title_color": "Farbe", 101 | "card_styling": "Kartengestaltung", 102 | "background_color": "Hintergrundfarbe", 103 | "height_mode": "Höhenmodus", 104 | "auto": "Automatische Höhe", 105 | "fixed": "Feste Höhe", 106 | "maximum": "Maximale Höhe", 107 | "height_value": "Höhenangabe", 108 | "fixed_height_note": "Karte behält immer genau diese Höhe bei, unabhängig vom Inhalt", 109 | "max_height_note": "Karte wächst mit Inhalt bis zu dieser maximalen Höhe", 110 | "event_styling": "Ereignisgestaltung", 111 | "event_background_opacity": "Ereignishintergrund-Opazität", 112 | "vertical_line_width": "Vertikale Linienbreite", 113 | "spacing_alignment": "Abstand & Ausrichtung", 114 | "day_spacing": "Abstand Tag", 115 | "event_spacing": "Abstand Ereignis", 116 | "additional_card_spacing": "Zusätzlicher Kartenabstand", 117 | 118 | "date_display": "Datumsanzeige", 119 | "vertical_alignment": "Vertikale Ausrichtung", 120 | "date_vertical_alignment": "Vertikale Datumsausrichtung", 121 | "date_formatting": "Datumsformatierung", 122 | "top": "Oben", 123 | "middle": "Mitte", 124 | "bottom": "Unten", 125 | "weekday_font": "Wochentag Schrift", 126 | "weekday_font_size": "Wochentag Schriftgröße", 127 | "weekday_color": "Wochentag Farbe", 128 | "day_font": "Tag Schrift", 129 | "day_font_size": "Tag Schriftgröße", 130 | "day_color": "Tag Farbe", 131 | "month_font": "Monat Schrift", 132 | "show_month": "Monat anzeigen", 133 | "month_font_size": "Monat Schriftgröße", 134 | "month_color": "Monat Farbe", 135 | "weekend_highlighting": "Wochenende Hervorhebung", 136 | "weekend_weekday_color": "Wochenende Wochentag Farbe", 137 | "weekend_day_color": "Wochenende Tag Farbe", 138 | "weekend_month_color": "Wochenende Monat Farbe", 139 | "today_highlighting": "Heute Hervorhebung", 140 | "today_weekday_color": "Heute Wochentag Farbe", 141 | "today_day_color": "Heute Tag Farbe", 142 | "today_month_color": "Heute Monat Farbe", 143 | "today_indicator": "Heute Indikator", 144 | "dot": "Gepunktet", 145 | "pulse": "Pulsierend", 146 | "glow": "Glühend", 147 | "emoji": "Emoji", 148 | "emoji_value": "Emoji", 149 | "emoji_indicator_note": "Einzelnes Emoji-Zeichen eingeben wie 🗓️", 150 | "image_path": "Bildpfad", 151 | "image_indicator_note": "Pfad zum Bild wie /local/image.jpg", 152 | "today_indicator_position": "Heute Indikator Position", 153 | "today_indicator_color": "Heute Indikator Farbe", 154 | "today_indicator_size": "Heute Indikator Größe", 155 | "week_numbers_separators": "Wochennummern & Trennzeichen", 156 | "week_numbers": "Wochennummerm", 157 | "first_day_of_week": "Erster Tag der Woche", 158 | "sunday": "Sonntag", 159 | "monday": "Montag", 160 | "show_week_numbers": "Wochennummern anzeigen", 161 | "week_number_note_iso": "ISO (Europa/International): Die erste Woche enthält den ersten Donnerstag des Jahres. Erstellt eine einheitliche Wochennummerierung über die Jahre hinweg (ISO 8601 Standard).", 162 | "week_number_note_simple": "Einfach (Nord-America): Wochen werden unabhängig vom Wochentag fortlaufend ab dem 1. Januar gezählt. Die erste Woche kann teilweise sein. Intuitiver, aber weniger standardisiert.", 163 | "show_current_week_number": "Aktuelle Wochennummer anzeigen", 164 | "week_number_font_size": "Wochennummer Schriftgröße", 165 | "week_number_color": "Wochennummer Farbe", 166 | "week_number_background_color": "Wochennummer Hintergerundfarbe", 167 | "day_separator": "Tagestrenner", 168 | "show_day_separator": "Tagestrenner anzeigen", 169 | "day_separator_width": "Tagestrenner Breite", 170 | "day_separator_color": "Tagestrenner Farbe", 171 | "week_separator": "Wochentrenner", 172 | "show_week_separator": "Wochentrenner anzeigen", 173 | "week_separator_width": "Wochentrenner Breite", 174 | "week_separator_color": "Wochentrenner Farbe", 175 | "month_separator": "Monatstrenner", 176 | "show_month_separator": "Monatstrenner anzeigen", 177 | "month_separator_width": "Monatstrenner Breite", 178 | "month_separator_color": "Monatstrenner Farbe", 179 | 180 | "event_display": "Ereignisanzeige", 181 | "event_title": "Ereignisname", 182 | "event_font_size": "Ereignis Schriftgröße", 183 | "empty_day_color": "Leerer Tag Farbe", 184 | "time": "Zeit", 185 | "show_single_allday_time": "Zeit für ganztägige Einzelveranstaltungen anzeigen", 186 | "show_end_time": "Endzeit anzeigen", 187 | "time_font_size": "Zeit Schriftgröße", 188 | "time_color": "Zeit Farbe", 189 | "time_icon_size": "Zeit Symbol Größe", 190 | "location": "Ort", 191 | "remove_location_country": "Land entfernen", 192 | "location_font_size": "Ort Schriftgröße", 193 | "location_color": "Ort Farbe", 194 | "location_icon_size": "Ort Symbol Größe", 195 | "custom_country_pattern": "Länder nach Muster entfernen", 196 | "custom_country_pattern_note": "Ländernamen als reguläres Ausdrucksmuster eingeben (z.B., 'USA|United States|Canada'). Bei Ereignissen mit Orten, die mit diesen Mustern enden, wird das Land entfernt.", 197 | "progress_indicators": "Fortschrittsindikatoren", 198 | "show_countdown": "Countdown anzeigen", 199 | "show_progress_bar": "Fortschrittsbalken anzeigen", 200 | "progress_bar_color": "Fortschrittsbalken Farbe", 201 | "progress_bar_height": "Fortschrittsbalken Höhe", 202 | "progress_bar_width": "Fortschrittsbalken Breite", 203 | "multiday_event_handling": "Mehrtägige Ereignisbehandlung", 204 | 205 | "weather_integration": "Wetterintegration", 206 | "weather_entity_position": "Wetter-Entität & Position", 207 | "weather_entity": "Wetter-Entität", 208 | "weather_position": "Wetterposition", 209 | "date": "Datum", 210 | "event": "Ereignis", 211 | "both": "Beides", 212 | "date_column_weather": "Datumsspalte Wetter", 213 | "show_conditions": "Bedingungen anzeigen", 214 | "show_high_temp": "Hohe Temperatur anzeigen", 215 | "show_low_temp": "Niedrige Temperatur anzeigen", 216 | "icon_size": "Symbolgröße", 217 | "font_size": "Schriftgröße", 218 | "color": "Farbe", 219 | "event_row_weather": "Ereignisreihe Wetter", 220 | "show_temp": "Temperatur anzeigen", 221 | 222 | "interactions": "Interaktionen", 223 | "tap_action": "Aktion antippen", 224 | "hold_action": "Aktion halten", 225 | "more_info": "Weitere Informationen", 226 | "navigate": "Navigieren", 227 | "url": "URL", 228 | "call_service": "Service aufrufen", 229 | "expand": "Kompakte/Erweiterte Ansicht umschalten", 230 | "navigation_path": "Navigationspfad", 231 | "url_path": "URL", 232 | "service": "Service", 233 | "service_data": "Service Daten (JSON)", 234 | "refresh_settings": "Einstellungen aktualisieren", 235 | "refresh_interval": "Aktualisierungsintervall (Minuten)", 236 | "refresh_on_navigate": "Aktualisieren beim Zurücknavigieren", 237 | 238 | "deprecated_config_detected": "Veraltete Konfigurationsoptionen erkannt.", 239 | "deprecated_config_explanation": "Einige Optionen in dieser Konfiguration werden nicht mehr unterstützt.", 240 | "deprecated_config_update_hint": "Bitte aktualisieren, um die Kompatibilität sicherzustellen.", 241 | "update_config": "Konfiguration aktualisieren…" 242 | } 243 | } 244 | -------------------------------------------------------------------------------- /src/utils/helpers.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Helper utilities for Calendar Card Pro 3 | * 4 | * General purpose utility functions for debouncing, memoization, 5 | * performance monitoring, and other common tasks. 6 | */ 7 | 8 | //----------------------------------------------------------------------------- 9 | // COLOR UTILITIES 10 | //----------------------------------------------------------------------------- 11 | 12 | /** 13 | * Convert any color format to RGBA with specific opacity 14 | * 15 | * @param color - Color in any valid CSS format 16 | * @param opacity - Opacity value (0-100) 17 | * @returns RGBA color string 18 | */ 19 | export function convertToRGBA(color: string, opacity: number): string { 20 | // If color is a CSS variable, we need to handle it specially 21 | if (color.startsWith('var(')) { 22 | // Create a temporary CSS variable with opacity 23 | return `rgba(var(--calendar-color-rgb, 3, 169, 244), ${opacity / 100})`; 24 | } 25 | 26 | if (color === 'transparent') { 27 | return color; 28 | } 29 | 30 | // Create temporary element to compute the color 31 | const tempElement = document.createElement('div'); 32 | tempElement.style.display = 'none'; 33 | tempElement.style.color = color; 34 | document.body.appendChild(tempElement); 35 | 36 | // Get computed color in RGB format 37 | const computedColor = getComputedStyle(tempElement).color; 38 | document.body.removeChild(tempElement); 39 | 40 | // If computation failed, return original color 41 | if (!computedColor) return color; 42 | 43 | // Handle RGB format (rgb(r, g, b)) 44 | const rgbMatch = computedColor.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/); 45 | if (rgbMatch) { 46 | const [, r, g, b] = rgbMatch; 47 | return `rgba(${r}, ${g}, ${b}, ${opacity / 100})`; 48 | } 49 | 50 | // If already RGBA, replace the alpha component 51 | const rgbaMatch = computedColor.match(/^rgba\((\d+),\s*(\d+),\s*(\d+),\s*[\d.]+\)$/); 52 | if (rgbaMatch) { 53 | const [, r, g, b] = rgbaMatch; 54 | return `rgba(${r}, ${g}, ${b}, ${opacity / 100})`; 55 | } 56 | 57 | // Fallback to original color if parsing fails 58 | return color; 59 | } 60 | 61 | //----------------------------------------------------------------------------- 62 | // INDICATOR TYPE DETECTION 63 | //----------------------------------------------------------------------------- 64 | 65 | /** 66 | * Checks if a string is an emoji 67 | * 68 | * @param str String to check 69 | * @returns True if the string is an emoji 70 | */ 71 | export function isEmoji(str: string): boolean { 72 | // Basic emoji detection using Unicode ranges 73 | const emojiRegex = /[\u{1F300}-\u{1F6FF}\u{1F900}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/u; 74 | return str.length <= 2 && emojiRegex.test(str); 75 | } 76 | 77 | /** 78 | * Determine the type of today indicator based on the value 79 | * 80 | * @param value The today_indicator value from config 81 | * @returns Type of indicator ('dot', 'pulse', 'glow', 'mdi', 'image', 'emoji', 'none') 82 | */ 83 | export function getTodayIndicatorType(value: string | boolean): string { 84 | // Handle boolean/undefined cases 85 | if (value === undefined || value === false) { 86 | return 'none'; 87 | } 88 | 89 | if (value === true) { 90 | return 'dot'; 91 | } 92 | 93 | // Handle string values 94 | if (typeof value === 'string') { 95 | // Check for special values 96 | if (value === 'pulse' || value === 'glow') { 97 | return value; 98 | } 99 | 100 | // Check for MDI icon format 101 | if (value.startsWith('mdi:')) { 102 | return 'mdi'; 103 | } 104 | 105 | // Check for image path 106 | if ( 107 | value.startsWith('/') || 108 | value.includes('.png') || 109 | value.includes('.jpg') || 110 | value.includes('.svg') || 111 | value.includes('.webp') || 112 | value.includes('.gif') 113 | ) { 114 | return 'image'; 115 | } 116 | 117 | // Check if it's an emoji (this is an approximation) 118 | // More sophisticated emoji detection could be added if needed 119 | const emojiRegex = /[\p{Emoji}]/u; 120 | if (emojiRegex.test(value)) { 121 | return 'emoji'; 122 | } 123 | 124 | // Default to dot for other strings 125 | return 'dot'; 126 | } 127 | 128 | return 'none'; 129 | } 130 | 131 | //----------------------------------------------------------------------------- 132 | // ID GENERATION FUNCTIONS 133 | //----------------------------------------------------------------------------- 134 | 135 | /** 136 | * Generate a random instance ID 137 | * 138 | * @returns {string} Random alphanumeric identifier 139 | */ 140 | export function generateInstanceId(): string { 141 | return Math.random().toString(36).substring(2, 15); 142 | } 143 | 144 | /** 145 | * Generate a deterministic ID based on calendar config 146 | * Creates a stable ID that persists across page reloads 147 | * but changes when the data requirements change 148 | * 149 | * @param entities Array of calendar entities 150 | * @param daysToShow Number of days to display 151 | * @param showPastEvents Whether to show past events 152 | * @param startDate Optional custom start date 153 | * @returns Deterministic ID string based on input parameters 154 | */ 155 | export function generateDeterministicId( 156 | entities: Array, 157 | daysToShow: number, 158 | showPastEvents: boolean, 159 | startDate?: string, 160 | ): string { 161 | // Extract just the entity IDs, normalized for comparison 162 | const entityIds = entities 163 | .map((e) => (typeof e === 'string' ? e : e.entity)) 164 | .sort() 165 | .join('_'); 166 | 167 | // Normalize ISO date format to YYYY-MM-DD for caching 168 | let normalizedStartDate = ''; 169 | if (startDate) { 170 | try { 171 | if (startDate.includes('T')) { 172 | // It's an ISO date, extract just the date part 173 | normalizedStartDate = startDate.split('T')[0]; 174 | } else { 175 | normalizedStartDate = startDate; 176 | } 177 | } catch { 178 | normalizedStartDate = startDate; // Fallback to original 179 | } 180 | } 181 | 182 | // Include the normalized startDate in the ID 183 | const startDatePart = normalizedStartDate ? `_${normalizedStartDate}` : ''; 184 | 185 | // Create a base string with all data-affecting parameters 186 | const baseString = `calendar_${entityIds}_${daysToShow}_${showPastEvents ? 1 : 0}${startDatePart}`; 187 | 188 | // Hash it for a compact, consistent ID 189 | return hashString(baseString); 190 | } 191 | 192 | /** 193 | * Simple string hash function for creating deterministic IDs 194 | * Converts a string into a stable hash value for use as an identifier 195 | * 196 | * @param str - Input string to hash 197 | * @returns Alphanumeric hash string 198 | */ 199 | export function hashString(str: string): string { 200 | let hash = 0; 201 | for (let i = 0; i < str.length; i++) { 202 | const char = str.charCodeAt(i); 203 | hash = (hash << 5) - hash + char; 204 | hash = hash & hash; // Convert to 32bit integer 205 | } 206 | // Convert to alphanumeric string 207 | return Math.abs(hash).toString(36); 208 | } 209 | 210 | //----------------------------------------------------------------------------- 211 | // LOCALE & FORMATTING UTILITIES 212 | //----------------------------------------------------------------------------- 213 | 214 | /** 215 | * Determines whether to use 24-hour time format based on Home Assistant settings 216 | * 217 | * This function examines Home Assistant locale settings to determine the 218 | * appropriate time format. It handles explicit settings (24h/12h), language-based 219 | * preferences, and system preferences by checking browser/OS settings. 220 | * 221 | * @param locale - Home Assistant locale object 222 | * @param fallbackTo24h - Whether to default to 24h format if detection fails 223 | * @returns Boolean indicating whether to use 24-hour format 224 | */ 225 | export function getTimeFormat24h( 226 | locale?: { time_format?: string; language?: string }, 227 | fallbackTo24h: boolean = true, 228 | ): boolean { 229 | if (!locale) return fallbackTo24h; 230 | 231 | // Handle different time_format values 232 | if (locale.time_format === '24') { 233 | return true; 234 | } else if (locale.time_format === '12') { 235 | return false; 236 | } else if (locale.time_format === 'language' && locale.language) { 237 | // Use language to determine format 238 | return is24HourByLanguage(locale.language); 239 | } else if (locale.time_format === 'system') { 240 | // Handle 'system' setting by detecting browser/OS preference 241 | try { 242 | // Create a formatter without specifying hour12 option 243 | const formatter = new Intl.DateTimeFormat(navigator.language, { 244 | hour: 'numeric', 245 | }); 246 | // Format afternoon time (13:00) and check if it has AM/PM markers 247 | const formattedTime = formatter.format(new Date(2000, 0, 1, 13, 0, 0)); 248 | return !formattedTime.match(/AM|PM|am|pm/); 249 | } catch { 250 | // Default to language-based detection on error 251 | return locale.language ? is24HourByLanguage(locale.language) : fallbackTo24h; 252 | } 253 | } 254 | 255 | // Default to fallback value for other cases 256 | return fallbackTo24h; 257 | 258 | // Internal helper function for language-based detection 259 | function is24HourByLanguage(language: string): boolean { 260 | // Languages/locales that typically use 24h format 261 | const likely24hLanguages = [ 262 | 'de', 263 | 'fr', 264 | 'es', 265 | 'it', 266 | 'pt', 267 | 'nl', 268 | 'ru', 269 | 'pl', 270 | 'sv', 271 | 'no', 272 | 'fi', 273 | 'da', 274 | 'cs', 275 | 'sk', 276 | 'sl', 277 | 'hr', 278 | 'hu', 279 | 'ro', 280 | 'bg', 281 | 'el', 282 | 'tr', 283 | 'zh', 284 | 'ja', 285 | 'ko', 286 | ]; 287 | 288 | // Extract base language code (e.g., 'de-AT' -> 'de') 289 | const baseLanguage = language.split('-')[0].toLowerCase(); 290 | 291 | return likely24hLanguages.includes(baseLanguage); 292 | } 293 | } 294 | 295 | /** 296 | * Formats a date according to Home Assistant locale settings 297 | * 298 | * @param date - Date to format 299 | * @param locale - Home Assistant locale object 300 | * @param fallbackFormat - Format to use if detection fails ('system' | 'YYYY-MM-DD') 301 | * @returns Formatted date string 302 | */ 303 | export function formatDateByLocale( 304 | date: Date, 305 | locale?: { date_format?: string; language?: string }, 306 | fallbackFormat: 'system' | 'YYYY-MM-DD' = 'YYYY-MM-DD', 307 | ): string { 308 | if (!date || isNaN(date.getTime())) { 309 | return ''; 310 | } 311 | 312 | // If no locale provided or format is explicitly set to YYYY-MM-DD 313 | if (!locale || locale.date_format === 'YYYY-MM-DD') { 314 | return formatDateAsYYYYMMDD(date); 315 | } 316 | 317 | try { 318 | // Use system locale if specified or no explicit format 319 | if (!locale.date_format || locale.date_format === 'system') { 320 | const localLanguage = locale.language || navigator.language; 321 | return new Intl.DateTimeFormat(localLanguage, { 322 | year: 'numeric', 323 | month: '2-digit', 324 | day: '2-digit', 325 | }).format(date); 326 | } 327 | 328 | // If language-based format is specified 329 | if (locale.date_format === 'language' && locale.language) { 330 | return new Intl.DateTimeFormat(locale.language, { 331 | year: 'numeric', 332 | month: '2-digit', 333 | day: '2-digit', 334 | }).format(date); 335 | } 336 | 337 | // Handle any custom formats (could be extended) 338 | if (locale.date_format === 'DD/MM/YYYY') { 339 | const day = String(date.getDate()).padStart(2, '0'); 340 | const month = String(date.getMonth() + 1).padStart(2, '0'); 341 | const year = date.getFullYear(); 342 | return `${day}/${month}/${year}`; 343 | } 344 | 345 | if (locale.date_format === 'MM/DD/YYYY') { 346 | const day = String(date.getDate()).padStart(2, '0'); 347 | const month = String(date.getMonth() + 1).padStart(2, '0'); 348 | const year = date.getFullYear(); 349 | return `${month}/${day}/${year}`; 350 | } 351 | } catch (error) { 352 | console.warn('Error formatting date:', error); 353 | } 354 | 355 | // Fallback to YYYY-MM-DD or system format 356 | return fallbackFormat === 'YYYY-MM-DD' 357 | ? formatDateAsYYYYMMDD(date) 358 | : new Intl.DateTimeFormat().format(date); 359 | } 360 | 361 | // Helper function for YYYY-MM-DD format 362 | function formatDateAsYYYYMMDD(date: Date): string { 363 | const year = date.getFullYear(); 364 | const month = String(date.getMonth() + 1).padStart(2, '0'); 365 | const day = String(date.getDate()).padStart(2, '0'); 366 | return `${year}-${month}-${day}`; 367 | } 368 | 369 | /** 370 | * Filter out default values from configuration 371 | * This helps avoid bloated YAML configuration by removing unnecessary properties 372 | * 373 | * @param config User configuration to filter 374 | * @param defaultConfig Default configuration to compare against 375 | * @returns Filtered configuration without default values 376 | */ 377 | export function filterDefaultValues( 378 | config: Record, 379 | defaultConfig: Record, 380 | ): Record { 381 | // Skip filtering if config is not an object 382 | if (!config || typeof config !== 'object' || Array.isArray(config)) { 383 | return config; 384 | } 385 | 386 | // Make a copy of the config to avoid mutating the original 387 | const result = Array.isArray(config) 388 | ? ([] as unknown as Record) 389 | : ({} as Record); 390 | 391 | // Process each property in the config 392 | for (const [key, value] of Object.entries(config)) { 393 | // Skip undefined values 394 | if (value === undefined) { 395 | continue; 396 | } 397 | 398 | // Special handling for show_week_numbers to allow null value through 399 | if (key === 'show_week_numbers' && (value === null || value === '')) { 400 | continue; // Filter out both null and empty string values for show_week_numbers 401 | } 402 | 403 | // Special handling for entity arrays 404 | if (key === 'entities' && Array.isArray(value)) { 405 | result[key] = value; 406 | continue; 407 | } 408 | 409 | // Special handling for weather config - preserve entire structure once defined 410 | if (key === 'weather' && typeof value === 'object' && value !== null) { 411 | // Deep clone the weather config to preserve the full structure 412 | result[key] = structuredClone ? structuredClone(value) : JSON.parse(JSON.stringify(value)); 413 | continue; 414 | } 415 | 416 | // Check if this is a default value 417 | const isDefaultValue = defaultConfig && key in defaultConfig && defaultConfig[key] === value; 418 | 419 | if (!isDefaultValue) { 420 | // For nested objects, recursively filter 421 | if ( 422 | value !== null && 423 | typeof value === 'object' && 424 | !Array.isArray(value) && 425 | defaultConfig && 426 | typeof defaultConfig[key] === 'object' && 427 | !Array.isArray(defaultConfig[key]) 428 | ) { 429 | const nestedResult = filterDefaultValues( 430 | value as Record, 431 | defaultConfig[key] as Record, 432 | ); 433 | 434 | // Only add the nested object if it has properties 435 | if (Object.keys(nestedResult).length > 0) { 436 | result[key] = nestedResult; 437 | } 438 | } else { 439 | // Otherwise add the value directly 440 | result[key] = value; 441 | } 442 | } 443 | } 444 | 445 | return result; 446 | } 447 | -------------------------------------------------------------------------------- /docs/architecture.md: -------------------------------------------------------------------------------- 1 | # Calendar Card Pro Architecture 2 | 3 | This document provides a high-level overview of the Calendar Card Pro architecture, explaining how different modules work together to create a performant and maintainable calendar card for Home Assistant. 4 | 5 | ## Directory Structure 6 | 7 | ``` 8 | src/ 9 | ├── calendar-card-pro.ts # Main entry point and component class 10 | ├── config/ # Configuration-related code 11 | │ ├── config.ts # DEFAULT_CONFIG and config helpers 12 | │ ├── constants.ts # Application constants and defaults 13 | │ └── types.ts # TypeScript interface definitions 14 | ├── interaction/ # User interaction handling 15 | │ ├── actions.ts # Action execution (tap, hold, etc.) 16 | │ └── feedback.ts # Visual feedback (ripple, hold indicators) 17 | ├── rendering/ # UI rendering code 18 | │ ├── editor.ts # Card editor component 19 | │ ├── render.ts # Component rendering functions 20 | │ └── styles.ts # CSS styles and dynamic styling 21 | ├── translations/ # Localization support 22 | │ ├── localize.ts # Translation functions 23 | │ └── languages/ # Translation files (24 supported languages) 24 | │ ├── en.json # English translations 25 | │ ├── de.json # German translations 26 | │ └── ... # Other language files 27 | └── utils/ # Utility functions 28 | ├── events.ts # Calendar event fetching and processing 29 | ├── format.ts # Date and text formatting 30 | ├── helpers.ts # Generic utilities (color, ID generation) 31 | └── logger.ts # Logging system 32 | ``` 33 | 34 | ## Module Responsibilities 35 | 36 | ### Main Component (`calendar-card-pro.ts`) 37 | 38 | The main entry point serves as the orchestrator for the entire card: 39 | 40 | - **Web Component Registration**: Defines the custom element using `@customElement` decorator 41 | - **Lifecycle Management**: Handles component connection, disconnection, and updates 42 | - **Property Definition**: Defines reactive properties via LitElement's `@property` decorator 43 | - **State Management**: Manages loading state, expanded state, and events data 44 | - **Event Handling**: Sets up user interaction handling (tap, hold, keyboard) 45 | - **Configuration Processing**: Handles config updates from Home Assistant 46 | - **Rendering Coordination**: Builds the component's DOM structure 47 | 48 | Key design patterns: 49 | 50 | - Uses [LitElement](https://lit.dev/) for efficient DOM updates and property management 51 | - Follows Home Assistant's component conventions for seamless integration 52 | - Implements computed properties via getters for derived state 53 | 54 | ### Configuration (`config/`) 55 | 56 | Manages all configuration aspects of the card: 57 | 58 | - **config.ts**: 59 | 60 | - Defines default configuration (`DEFAULT_CONFIG`) 61 | - Provides helper functions for normalizing entity configurations 62 | - Detects configuration changes that require data refresh 63 | - Generates stub configurations for the card editor 64 | 65 | - **constants.ts**: 66 | 67 | - Defines global constants organized by category 68 | - Sets default values and timing parameters 69 | - Centralizes cache-related settings 70 | 71 | - **types.ts**: 72 | - Defines TypeScript interfaces for all component parts 73 | - Documents config properties and their purposes 74 | - Provides type safety throughout the application 75 | 76 | ### Interaction (`interaction/`) 77 | 78 | Handles all user interaction with the card: 79 | 80 | - **actions.ts**: 81 | 82 | - Processes user actions (tap, hold, etc.) 83 | - Dispatches Home Assistant events 84 | - Handles navigation and service calls 85 | - Manages toggle/expand actions 86 | 87 | - **feedback.ts**: 88 | - Creates visual feedback for user interactions 89 | - Manages hold indicators with proper timing 90 | - Handles cleanup of temporary DOM elements 91 | 92 | ### Rendering (`rendering/`) 93 | 94 | Generates the HTML and CSS for the card: 95 | 96 | - **render.ts**: 97 | 98 | - Contains pure functions for rendering card elements 99 | - Generates HTML templates for days, events, and states 100 | - Uses the lit-html templating system 101 | - Implements optimized rendering of event lists 102 | 103 | - **styles.ts**: 104 | 105 | - Defines CSS styles as LitElement templates 106 | - Generates dynamic style properties based on configuration 107 | - Manages theme variable integration 108 | 109 | - **editor.ts**: 110 | - Implements the card configuration editor 111 | - Handles schema validation for the editor UI 112 | 113 | ### Translations (`translations/`) 114 | 115 | Provides internationalization support: 116 | 117 | - **localize.ts**: 118 | 119 | - Manages language detection and selection 120 | - Handles translation lookups with fallbacks 121 | - Formats dates according to locale-specific patterns 122 | 123 | - **languages/\*.json**: 124 | - Contains translation strings for each supported language 125 | - Defines month names, day names, and UI strings 126 | 127 | ### Utilities (`utils/`) 128 | 129 | Provides core functionality across the card: 130 | 131 | - **events.ts**: 132 | 133 | - Fetches calendar events from Home Assistant API 134 | - Implements caching system for calendar data 135 | - Processes and filters events based on configuration 136 | - Groups events by day for display 137 | 138 | - **format.ts**: 139 | 140 | - Formats dates and times for display 141 | - Handles all-day and multi-day events 142 | - Processes location strings 143 | - Manages time formatting (12/24 hour) 144 | 145 | - **helpers.ts**: 146 | 147 | - Provides color manipulation utilities 148 | - Generates deterministic IDs for caching 149 | - Implements hash functions for cache keys 150 | 151 | - **logger.ts**: 152 | - Provides tiered logging system 153 | - Handles error, warning, info, and debug messages 154 | - Includes version information in logs 155 | 156 | ## Module Interaction Flow 157 | 158 | ```mermaid 159 | graph TD 160 | Main[Calendar Card Pro Main] --> Config[Configuration] 161 | Main --> Events[Event Processing] 162 | Main --> Render[Rendering] 163 | Main --> State[State Management] 164 | Main --> Inter[Interactions] 165 | 166 | %% Configuration interactions 167 | Config --> Constants[Constants] 168 | Config --> Types[Type Definitions] 169 | 170 | %% Event processing flow 171 | Events --> Cache[LocalStorage Cache] 172 | Events --> API[Home Assistant API] 173 | Events --> Format[Formatting] 174 | 175 | %% Rendering flow 176 | Render --> DOM[DOM Operations] 177 | Render --> Styles[CSS Generation] 178 | Render --> Localize[Translations] 179 | 180 | %% Interaction flow 181 | Inter --> Actions[Action Handling] 182 | Inter --> Ripple[Ripple Effects] 183 | Inter --> Feedback[Visual Feedback] 184 | 185 | %% State management 186 | State --> Lifecycle[Component Lifecycle] 187 | State --> Refresh[Refresh Timer] 188 | State --> Visibility[Page Visibility] 189 | 190 | %% Cross-cutting concerns 191 | Logger[Logging System] --> Main 192 | Logger --> Events 193 | Logger --> Render 194 | Logger --> Inter 195 | ``` 196 | 197 | ## Data Flow 198 | 199 | ### Event Data Flow 200 | 201 | 1. **Initial Load**: 202 | - Component initializes and calls `updateEvents()` 203 | - `events.ts` generates a cache key based on configured entities and settings 204 | - Cache is checked first, API used only if needed 205 | - Events are stored in local storage with configurable expiration 206 | 2. **Data Processing**: 207 | 208 | - Raw calendar events are filtered for relevant dates 209 | - Events are grouped by day using `groupEventsByDay()` 210 | - Each event is enhanced with formatted time and location strings 211 | - Entity-specific styling is applied to each event 212 | 213 | 3. **Rendering Flow**: 214 | 215 | - Main component calls `render()` which uses the `Render` module 216 | - Dynamic styles are generated based on configuration 217 | - Days and events are rendered with proper CSS classes 218 | - Loading, error, and empty states are handled appropriately 219 | 220 | 4. **Refresh Mechanisms**: 221 | - Automatic refresh via `refresh_interval` configuration 222 | - Manual refresh when page visibility changes 223 | - Forced refresh when configuration changes 224 | - Cache invalidation based on timing and parameters 225 | 226 | ### Interaction Flow 227 | 228 | 1. **User Input**: 229 | - Pointer events (mouse/touch) captured in main component 230 | - Hold detection with visual feedback 231 | - Keyboard navigation support 232 | 2. **Action Execution**: 233 | - `actions.ts` handles tap/hold actions 234 | - Expansion toggle, navigation, and service calls 235 | - Home Assistant service integration 236 | 237 | ## Optimizations 238 | 239 | ### Performance Optimizations 240 | 241 | 1. **Smart Caching**: 242 | 243 | - Cached event data with configurable lifetime 244 | - Deterministic cache keys based on configuration 245 | - Selective cache invalidation 246 | 247 | 2. **Efficient Rendering**: 248 | 249 | - Pure rendering functions to improve performance 250 | - Stable DOM structure for card-mod compatibility 251 | - Efficient updates with lit-html 252 | 253 | 3. **Resource Management**: 254 | - Proper cleanup on disconnection 255 | - Event listener management 256 | - Timer cleanup 257 | 258 | ### UX Optimizations 259 | 260 | 1. **Progressive Loading**: 261 | - Clean loading states during data fetching 262 | - Optimized transitions between states 263 | 2. **Adaptive Display**: 264 | 265 | - Compact/expanded view modes 266 | - Empty state handling 267 | - Responsive sizing 268 | 269 | 3. **Visual Feedback**: 270 | - Material ripple effects 271 | - Hold indicators 272 | - Focus states for keyboard navigation 273 | 274 | ## Advanced Features 275 | 276 | ### Start Date Configuration 277 | 278 | The card supports a `start_date` configuration option that allows viewing calendar data from any specified date rather than just today: 279 | 280 | 1. **Date Parsing**: Handles both YYYY-MM-DD format and ISO format 281 | 2. **API Integration**: Uses the start date to fetch the appropriate time window from the API 282 | 3. **Cache Integration**: Includes the start date in cache keys to ensure proper data refresh when changed 283 | 284 | ### Multi-Calendar Styling 285 | 286 | Each calendar entity can have custom styling: 287 | 288 | 1. **Per-Entity Colors**: Customize text color by calendar source 289 | 2. **Accent Colors**: Vertical line colors for visual separation 290 | 3. **Background Colors**: Optional semi-transparent backgrounds 291 | 4. **Labels**: Entity-specific labels or emoji for visual differentiation 292 | 293 | ### Smart Event Formatting 294 | 295 | Event display adapts based on event type: 296 | 297 | 1. **All-Day Events**: Special handling for single and multi-day all-day events 298 | 2. **Ongoing Events**: Shows "Ends today/tomorrow" for multi-day events 299 | 3. **Past Event Styling**: Visual distinction for events that have ended 300 | 4. **Location Processing**: Smart location string formatting with country removal 301 | 302 | ### Progressive Rendering 303 | 304 | The calendar card implements efficient rendering to maintain responsiveness even with many events: 305 | 306 | 1. **Pure Function Pattern**: Render functions are implemented as pure functions that generate TemplateResults 307 | 2. **Stable DOM Structure**: The card maintains a consistent DOM structure for compatibility with card-mod 308 | 3. **Efficient Updates**: Uses lit-html's efficient diffing algorithm to minimize DOM operations 309 | 310 | ```typescript 311 | // Example of pure function rendering approach 312 | export function renderEvent( 313 | event: Types.CalendarEventData, 314 | day: Types.EventsByDay, 315 | index: number, 316 | config: Types.Config, 317 | language: string, 318 | ): TemplateResult { 319 | // Determine styles and classes based on event properties 320 | const eventClasses = { 321 | event: true, 322 | 'event-first': index === 0, 323 | 'event-last': index === day.events.length - 1, 324 | 'past-event': isPastEvent(event), 325 | }; 326 | 327 | // Return immutable template 328 | return html` 329 | 330 | ${index === 0 ? html`...` : ''} 331 | 332 | 333 | 334 | 335 | `; 336 | } 337 | ``` 338 | 339 | ### Smart Caching 340 | 341 | The card implements a multi-level caching strategy: 342 | 343 | 1. **Event Data Caching**: 344 | 345 | - Calendar events are cached in localStorage 346 | - Cache key includes entities, days to show, past events setting, and start date 347 | - Cache invalidation is automatic when configuration changes 348 | - Cache duration is configurable through refresh_interval setting 349 | 350 | 2. **Deterministic IDs**: 351 | 352 | - Each card instance generates a deterministic ID based on configuration 353 | - The ID remains stable across page loads but changes when configuration changes 354 | - This ensures proper cache handling when multiple calendar cards exist 355 | 356 | 3. **Intelligent Cache Refresh**: 357 | - Cache is refreshed automatically based on configured interval 358 | - Manual refreshes are rate-limited to prevent API abuse 359 | - Reactive to page visibility changes and Home Assistant reconnection events 360 | 361 | ## Design Principles 362 | 363 | The code follows these core principles: 364 | 365 | 1. **Separation of Concerns**: 366 | 367 | - Each module has a clear, focused responsibility 368 | - Pure functions where possible for easier testing 369 | - Clear interfaces between subsystems 370 | 371 | 2. **Progressive Enhancement**: 372 | 373 | - Works with minimal configuration 374 | - Gracefully handles missing data or API errors 375 | - Degrades elegantly in constrained environments 376 | 377 | 3. **Type Safety**: 378 | 379 | - Comprehensive TypeScript interfaces 380 | - Minimal use of `any` type 381 | - Runtime type guards where needed 382 | 383 | 4. **Maintainability**: 384 | - Consistent code style and patterns 385 | - Detailed comments and documentation 386 | - Clear function signatures and module organization 387 | 388 | ## Maintenance Guidelines 389 | 390 | When modifying code: 391 | 392 | 1. **Module Boundaries**: 393 | 394 | - Keep changes within appropriate module boundaries 395 | - Update related modules when necessary 396 | - Follow existing patterns for consistency 397 | 398 | 2. **Type Safety**: 399 | 400 | - Update types in types.ts when changing data structures 401 | - Use type annotations for clarity 402 | - Avoid using `any` type when possible 403 | 404 | 3. **Testing Considerations**: 405 | 406 | - Test with various calendar types (Google Calendar, CalDAV, etc.) 407 | - Test with different screen sizes and device types 408 | - Test with large calendar datasets for performance 409 | 410 | 4. **Performance**: 411 | 412 | - Consider performance implications of new features 413 | - Use pure functions for rendering components 414 | - Implement appropriate caching for expensive operations 415 | 416 | 5. **Cleanup**: 417 | 418 | - Always clean up event listeners and timers 419 | - Manage memory carefully, especially for long-lived components 420 | - Implement proper disconnectedCallback handling 421 | 422 | 6. **Configuration**: 423 | - Make new features configurable when appropriate 424 | - Provide sensible defaults in constants.ts 425 | - Document new configuration options in README.md 426 | 427 | By following these architectural principles, Calendar Card Pro maintains a clean, maintainable codebase that delivers excellent performance and user experience. 428 | --------------------------------------------------------------------------------