├── .eslintrc.yaml ├── .gitignore ├── LICENSE ├── README.md ├── babel.config.js ├── card.png ├── dist ├── calendar-card.js └── calendar-card.js.map ├── hacs.json ├── package.json ├── src ├── calendar-event.js ├── defaults.js ├── event.tools.js ├── html.tools.js ├── index-editor.js ├── index.js ├── locales.js ├── moment.js ├── style-editor.js └── style.js └── webpack ├── config.common.js ├── config.dev.js └── config.prod.js /.eslintrc.yaml: -------------------------------------------------------------------------------- 1 | extends: airbnb-base 2 | rules: 3 | no-else-return: 0 4 | no-underscore-dangle: 0 5 | nonblock-statement-body-position: 0 6 | curly: 0 7 | no-return-assign: 0 8 | consistent-return: 0 9 | no-mixed-operators: 0 10 | class-methods-use-this: 0 11 | no-nested-ternary: 0 12 | camelcase: 0 13 | globals: 14 | window: true 15 | Event: true 16 | customElements: true -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | package-lock.json -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Leonardo Merza 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # as of hass 0.115 a built in calendar card is included so this card is archived and will no longer be developed. if any features are missing in the new card, open pull requests against lovelace to get them added. 2 | 3 | # Calendar Card for Home Assistant 4 | Show Google calendar events 5 | 6 | 7 | 8 | 9 | [![GitHub Release][releases-shield]][releases] 10 | [![License][license-shield]](LICENSE.md) 11 | 12 | ![Project Maintenance][maintenance-shield] 13 | [![GitHub Activity][commits-shield]][commits] 14 | [![hacs_badge](https://img.shields.io/badge/HACS-Default-orange.svg?style=for-the-badge)](https://github.com/custom-components/hacs) 15 | 16 | ## Features 17 | --- 18 | * Show the next 5 events on your Google Calendar (default set by home assistant) 19 | * Set custom time and date format for each event 20 | * Click on event to open in your Google calendar app 21 | * Integrate multiple calendars 22 | * Update notifications via custom_updater 23 | * Click on event location to open maps app 24 | * Language support 25 | * Progress bar for ongoing events 26 | * Split multiday events 27 | * Notifications of new events 28 | * Customize date time formats 29 | * Enable kiosk mode (no click events) 30 | 31 | 32 | ## Installation 33 | --- 34 | You should have setup Google calendar integration or Caldav integration in HomeAssistant. 35 | Installation through [HACS](https://github.com/custom-components/hacs) 36 | 37 | ## Options 38 | --- 39 | | Name | Type | Requirement | Description | 40 | | -------------------------------- | ------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | 41 | | type | string | **Required** | `custom:calendar-card` | 42 | | entities | object | **Required** | List of calendars to display | 43 | | dateTopFormat | string | **Optional** | `DD` Format for top line of event date | 44 | | dateBottomFormat | string | **Optional** | `ddd` Format to bottom line of event date | 45 | | disableLinks | boolean | **Optional** | `false` Disables all links (to open calendar and location) | 46 | | useSourceUrl | boolean | **Optional** | `false` Open events via the source url instead of html link | 47 | | endText | string | **Optional** | `End` Set custom text for event end time | 48 | | eventsLimit | integer | **Optional** | `99` Maximum number of events to show (shows rest of day after cut off) | 49 | | fullDayEventText | string | **Optional** | `All day` Set custom text for a full day event | 50 | | hardLimit | boolean | **Optional** | `false` Overrides `eventsLimit` default of showing rest of day's events even after cutoff | 51 | | hideDeclined | boolean | **Optional** | `false` Hides events that you declined | 52 | | hideHeader | boolean | **Optional** | `false` Hide the header regardless of value | 53 | | hidePastEvents | boolean | **Optional** | `false` Hide events that have passed | 54 | | hideTime | boolean | **Optional** | `false` Hides event time section entirely | 55 | | highlightToday | boolean | **Optional** | `false` Hightlight's today's events | 56 | | ignoreEventsByLocationExpression | string | **Optional** | Simple case insensitive regex to ignore events that match location | 57 | | ignoreEventsExpression | string | **Optional** | Simple case insensitive regex to ignore events that match title | 58 | | maxHeight | boolean | **Optional** | `false` Sets max height for card to 500px and overflows the rest | 59 | | notifyEntity | Entity | **Optional** | Send a notification on new events | 60 | | notifyDateTimeFormat | string | **Optional** | `MM/DD/YYYY HH:mma` Format for event date/time in notify message (see [here](https://momentjs.com/docs/#/displaying/format/) for options) | 61 | | numberOfDays | number | **Optional** | `7` Number of days to display from calendars | 62 | | removeFromEventTitle | string | **Optional** | Removes substring from all event titles (case insensitive) | 63 | | progressBar | boolean | **Optional** | `false` Adds progress bar to ongoing events | 64 | | showEventOrigin | boolean | **Optional** | `false` Shows what calendar each event is from | 65 | | showLocation | boolean | **Optional** | `false` Shows location address | 66 | | showLocationIcon | boolean | **Optional** | `true` Shows map icon when event has a location | 67 | | showMultiDay | boolean | **Optional** | `false` Split multiday events into per day | 68 | | startText | string | **Optional** | `Start` Set custom text for event start time | 69 | | title | string | **Optional** | `Calendar` Header shown at top of card | 70 | | timeFormat | string | **Optional** | `HH:mm` Format to show event time (see [here](https://momentjs.com/docs/#/displaying/format/) for options) | 71 | 72 | ## Configurations 73 | --- 74 | ```yaml 75 | type: custom:calendar-card 76 | title: "My Calendar" 77 | progressBar: true 78 | entities: 79 | - calendar.ljmerzagmailcom 80 | ``` 81 | 82 | ## You want more than 5 Google events? 83 | Open the `google_calendars.yaml` file and and `max_results: 20` for each calendar items you want more events for. See documentation at [Home Assistant](https://www.home-assistant.io/components/calendar.google/) 84 | 85 | --- 86 | 87 | Enjoy my card? Help me out for a couple of :beers: or a :coffee:! 88 | 89 | [![coffee](https://www.buymeacoffee.com/assets/img/custom_images/black_img.png)](https://www.buymeacoffee.com/JMISm06AD) 90 | 91 | 92 | [commits-shield]: https://img.shields.io/github/commit-activity/y/ljmerza/calendar-card.svg?style=for-the-badge 93 | [commits]: https://github.com/ljmerza/calendar-card/commits/master 94 | [license-shield]: https://img.shields.io/github/license/ljmerza/calendar-card.svg?style=for-the-badge 95 | [maintenance-shield]: https://img.shields.io/badge/maintainer-Leonardo%20Merza%20%40ljmerza-blue.svg?style=for-the-badge 96 | [releases-shield]: https://img.shields.io/github/release/ljmerza/calendar-card.svg?style=for-the-badge 97 | [releases]: https://github.com/ljmerza/calendar-card/releases 98 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "useBuiltIns": "usage", 7 | "debug": true, 8 | "targets": "> 0.25%, not dead", 9 | "shippedProposals": true 10 | } 11 | ] 12 | ] 13 | } -------------------------------------------------------------------------------- /card.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ljmerza/calendar-card/1543f080ae6df06df4d799b2ed1954582409b4e8/card.png -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Calendar Card", 3 | "render_readme": true, 4 | "filename": "dist/calendar-card.js" 5 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "calendar-card", 3 | "version": "3.109.1", 4 | "description": "A calendar card for Home Assistant Lovelace UI", 5 | "keywords": [ 6 | "home-assistant", 7 | "homeassistant", 8 | "hass", 9 | "automation", 10 | "lovelace", 11 | "custom-cards", 12 | "Google Calendar" 13 | ], 14 | "main": "src/index.js", 15 | "module": "src/index.js", 16 | "repository": "git@github.com:ljmerza/calendar-card.git", 17 | "author": "Leonardo Merza ", 18 | "license": "MIT", 19 | "dependencies": { 20 | "@babel/polyfill": "^7.4.4", 21 | "core-js": "^2.6.5", 22 | "lit-element": "^2.2.1" 23 | }, 24 | "devDependencies": { 25 | "@babel/cli": "^7.5.5", 26 | "@babel/core": "^7.5.5", 27 | "@babel/preset-env": "^7.5.5", 28 | "@babel/register": "^7.5.5", 29 | "babel-loader": "^8.0.6", 30 | "eslint": "^5.16.0", 31 | "eslint-config-airbnb-base": "^13.2.0", 32 | "eslint-plugin-import": "^2.18.2", 33 | "webpack": "^4.39.2", 34 | "webpack-cli": "^3.3.7", 35 | "webpack-merge": "^4.2.1" 36 | }, 37 | "scripts": { 38 | "lint": "eslint ./src", 39 | "start": "npx webpack --watch --config webpack/config.dev.js", 40 | "build": "npx webpack --config webpack/config.prod.js" 41 | } 42 | } -------------------------------------------------------------------------------- /src/calendar-event.js: -------------------------------------------------------------------------------- 1 | import moment from './moment'; 2 | 3 | 4 | /** 5 | * Creates an generalized Calendar Event to use when creating the calendar card 6 | * There can be Google Events and CalDav Events. This class normalizes those 7 | */ 8 | export default class CalendarEvent { 9 | 10 | constructor(calendarEvent, config) { 11 | this._calendarEvent = calendarEvent; 12 | this._config = config; 13 | } 14 | 15 | get rawEvent(){ 16 | return this._calendarEvent; 17 | } 18 | 19 | get id() { 20 | return (this.rawEvent.id || this.rawEvent.uid) + this.title; 21 | } 22 | 23 | get originCalendar(){ 24 | return this.rawEvent.originCalendar; 25 | } 26 | 27 | get entity() { 28 | return this._calendarEvent.hassEntity || {}; 29 | } 30 | 31 | get originName() { 32 | const originCalendar = this.originCalendar; 33 | if (originCalendar && originCalendar.name) 34 | return originCalendar.name; 35 | 36 | const entity = this.entity; 37 | if (entity && entity.attributes && entity.attributes.friendly_name) 38 | return entity.attributes.friendly_name; 39 | 40 | if (originCalendar && originCalendar.entity) 41 | return originCalendar.entity; 42 | 43 | return entity && entity.entity || entity || 'Unknown'; 44 | } 45 | 46 | /** 47 | * get the start time for an event 48 | * @return {String} 49 | */ 50 | get startDateTime() { 51 | if (this._startDateTime === undefined){ 52 | const date = this.rawEvent.start && this.rawEvent.start.date || this.rawEvent.start.dateTime || this.rawEvent.start || ''; 53 | this._startDateTime = this._processDate(date); 54 | } 55 | 56 | return this._startDateTime.clone(); 57 | } 58 | 59 | /** 60 | * get the end time for an event 61 | * @return {String} 62 | */ 63 | get endDateTime() { 64 | if (this._endDateTime === undefined) { 65 | const date = this.rawEvent.end && this.rawEvent.end.date || this.rawEvent.end.dateTime || this.rawEvent.end; 66 | this._endDateTime = this._processDate(date, true); 67 | } 68 | 69 | return this._endDateTime.clone(); 70 | } 71 | 72 | get addDays(){ 73 | return this.rawEvent.addDays !== undefined ? this.rawEvent.addDays : false; 74 | } 75 | 76 | get daysLong() { 77 | return this.rawEvent.daysLong; 78 | } 79 | 80 | get isFirstDay(){ 81 | return this.rawEvent._isFirstDay; 82 | } 83 | 84 | get isLastDay(){ 85 | return this.rawEvent._isLastDay; 86 | } 87 | 88 | /** 89 | * 90 | * @param {string} date 91 | * @param {boolean} isEndDate 92 | */ 93 | _processDate(date, isEndDate=false){ 94 | if (!date) return date; 95 | 96 | date = moment(date); 97 | 98 | // add days to a start date for multi day event 99 | if (this.addDays !== false) { 100 | if (!isEndDate && this.addDays) date = date.add(this.addDays, 'days'); 101 | 102 | // if not the last day and we are modifying the endDateTime then 103 | // set end dateTimeDate as end of start day for that partial event 104 | if (!this.isLastDay && isEndDate) { 105 | date = moment(this.startDateTime).endOf('day'); 106 | 107 | } else if (this.isLastDay && !isEndDate) { 108 | // if last day and start time then set start as start of day 109 | date = date.startOf('day'); 110 | } 111 | } 112 | 113 | return date; 114 | } 115 | 116 | /** 117 | * is this recurring? 118 | * @return {boolean} 119 | */ 120 | get isRecurring() { 121 | return !!this.rawEvent.recurringEventId; 122 | } 123 | 124 | get isDeclined() { 125 | const attendees = this.rawEvent.attendees || []; 126 | return attendees.filter(a => a.self && a.responseStatus === 'declined').length !== 0; 127 | } 128 | 129 | /** 130 | * get the URL for an event 131 | * @return {String} 132 | */ 133 | get htmlLink() { 134 | return this.rawEvent.htmlLink || ''; 135 | } 136 | 137 | /** 138 | * get the URL from the source element 139 | * @return {String} 140 | */ 141 | get sourceUrl() { 142 | return (this.rawEvent.source) ? this.rawEvent.source.url || '' : ''; 143 | } 144 | 145 | /** 146 | * is a multiday event (not all day) 147 | * @return {Boolean} 148 | */ 149 | get isMultiDay() { 150 | // if more than 24 hours we automatically know it's multi day 151 | if (this.endDateTime.diff(this.startDateTime, 'hours') > 24) return true; 152 | 153 | // end date could be at midnight which is not multi day but is seen as the next day 154 | // subtract one minute and if that made it one day then its NOT one day 155 | const daysDifference = Math.abs(this.startDateTime.date() - this.endDateTime.subtract(1, 'minute').date()); 156 | if (daysDifference === 1 && this.endDateTime.hours() === 0 && this.endDateTime.minutes() === 0) return false; 157 | 158 | return !!daysDifference; 159 | } 160 | 161 | /** 162 | * is the event a full day event? 163 | * @return {Boolean} 164 | */ 165 | get isAllDayEvent() { 166 | const isMidnightStart = this.startDateTime.startOf('day').diff(this.startDateTime) === 0; 167 | const isMidnightEnd = this.endDateTime.startOf('day').diff(this.endDateTime) === 0; 168 | if (isMidnightStart && isMidnightEnd) return true; 169 | 170 | // check for days that are between multi days - they ARE all day 171 | if(!this.isFirstDay && !this.isLastDay && this.daysLong) return true; 172 | 173 | return isMidnightStart && isMidnightEnd; 174 | } 175 | 176 | /** 177 | * split this event into a multi day event 178 | * @param {*} newEvent 179 | */ 180 | splitIntoMultiDay(newEvent) { 181 | const partialEvents = []; 182 | 183 | // multi days start at two days 184 | // every 24 hours is a day. if we do get some full days then just add to 1 daysLong 185 | let daysLong = 2; 186 | const fullDays = parseInt(this.endDateTime.subtract(1, 'minutes').diff(this.startDateTime, 'hours') / 24); 187 | if (fullDays) daysLong = fullDays + 1; 188 | 189 | for (let i = 0; i < daysLong; i++) { 190 | // copy event then add the current day/total days to 'new' event 191 | const copiedEvent = JSON.parse(JSON.stringify(newEvent.rawEvent)); 192 | copiedEvent.addDays = i; 193 | copiedEvent.daysLong = daysLong; 194 | 195 | copiedEvent._isFirstDay = i === 0; 196 | copiedEvent._isLastDay = i === (daysLong - 1); 197 | 198 | const partialEvent = new CalendarEvent(copiedEvent, this._config); 199 | 200 | // only add event if starting before the config numberOfDays 201 | const endDate = moment().startOf('day').add(this._config.numberOfDays, 'days'); 202 | if (endDate.isAfter(partialEvent.startDateTime)) { 203 | partialEvents.push(partialEvent) 204 | } 205 | } 206 | 207 | return partialEvents; 208 | } 209 | 210 | /** 211 | * get the title for an event 212 | * @return {String} 213 | */ 214 | get title() { 215 | let title = this.rawEvent.summary || this.rawEvent.title || ''; 216 | 217 | if (this.rawEvent.daysLong){ 218 | title += ` (${this.addDays + 1}/${this.daysLong})`; 219 | } 220 | 221 | // if given config then remove piece of text from all event titles 222 | if (this._config.removeFromEventTitle){ 223 | const regEx = new RegExp(this._config.removeFromEventTitle, 'i'); 224 | title = title.replace(regEx, ''); 225 | } 226 | 227 | return title; 228 | } 229 | 230 | /** 231 | * get the description for an event 232 | * @return {String} 233 | */ 234 | get description() { 235 | return this.rawEvent.description; 236 | } 237 | 238 | /** 239 | * parse location for an event 240 | * @return {String} 241 | */ 242 | get location() { 243 | if (!this.rawEvent.location) return ''; 244 | return this.rawEvent.location.split(',')[0] || ''; 245 | } 246 | 247 | /** 248 | * get location address for an event 249 | * @return {String} 250 | */ 251 | get locationAddress() { 252 | if (!this.rawEvent.location) return ''; 253 | 254 | const address = this.rawEvent.location.substring(this.rawEvent.location.indexOf(',') + 1); 255 | return address.split(' ').join('+'); 256 | } 257 | } 258 | -------------------------------------------------------------------------------- /src/defaults.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | export default { 4 | title: 'Calendar', 5 | numberOfDays: 7, 6 | timeFormat: 'HH:mma', 7 | dateTopFormat: 'DD', 8 | dateBottomFormat: 'ddd', 9 | hideTime: false, 10 | progressBar: false, 11 | showLocation: false, 12 | showLocationIcon: true, 13 | hidePastEvents: false, 14 | showMultiDay: false, 15 | eventsLimit: 99, 16 | showEventOrigin: false, 17 | hideHeader: false, 18 | highlightToday: false, 19 | ignoreEventsExpression: '', 20 | ignoreEventsByLocationExpression: '', 21 | removeFromEventTitle: '', 22 | maxHeight: false, 23 | hardLimit: false, 24 | hideDeclined: false, 25 | notifyEntity: null, 26 | disableLinks: false, 27 | useSourceUrl: false, 28 | notifyDateTimeFormat: 'MM/DD/YYYY HH:mma', 29 | fullDayEventText: 'All day', 30 | startText: 'Start', 31 | endText: 'End', 32 | }; 33 | -------------------------------------------------------------------------------- /src/event.tools.js: -------------------------------------------------------------------------------- 1 | import moment from './locales'; 2 | import CalendarEvent from './calendar-event'; 3 | 4 | 5 | /** 6 | * group events by the day it's on 7 | * @param {Array} events 8 | * @param {Object} config 9 | * @return {Array} 10 | */ 11 | export function groupEventsByDay(events, config) { 12 | 13 | let groupedEvents = events.reduce((groupedEvents, event) => { 14 | const day = moment(event.startDateTime).format('YYYY-MM-DD'); 15 | const matchingDateIndex = groupedEvents.findIndex(group => group.day === day); 16 | 17 | if (matchingDateIndex > -1) { 18 | groupedEvents[matchingDateIndex].events.push(event); 19 | } else { 20 | groupedEvents.push({ day, events: [event] }); 21 | } 22 | 23 | return groupedEvents; 24 | }, []); 25 | 26 | // if we want to show all events for a day even if they go over the events 27 | // limit then we have to keep track of the number of events by day and 28 | // stop at the END of the current day that goes over the limit 29 | let numberOfEvents = 0; 30 | let hasMaxedOutEvents = false; 31 | groupedEvents = groupedEvents.map(group => { 32 | // if we maxed out then dont worry about any other days after that 33 | if (hasMaxedOutEvents) return; 34 | 35 | // accumulate how many events are in each day 36 | numberOfEvents += group.events.length; 37 | 38 | // did we max the number of events we want to show during this day? 39 | hasMaxedOutEvents = config.eventsLimit < numberOfEvents; 40 | 41 | // if we maxed out events by default we show the rest of the curent day's events 42 | // even if they go over max - but if this config is true then dont goover max no matter what 43 | if (config.hardLimit){ 44 | const numberOfEventsOver = numberOfEvents - config.eventsLimit; 45 | group.events = group.events.slice(0, group.events.length - numberOfEventsOver); 46 | } 47 | 48 | return group; 49 | }).filter(Boolean); // filter out empty days that we may have maxed out on 50 | 51 | return groupedEvents; 52 | } 53 | 54 | /** 55 | * opens a link in a new tab if config allows it 56 | * @param {CalendarEvent} event 57 | */ 58 | export function openLink(e, link, config) { 59 | if (!link || config.disableLinks) return; 60 | window.open(link); 61 | } 62 | 63 | export async function sendNotificationForNewEvents(config, hass, events, oldEvents) { 64 | if (!oldEvents || !config.notifyEntity) return events; 65 | 66 | const newEvents = events.filter(event => { 67 | const alreadyExisted = oldEvents.find(oldEvent => oldEvent.id === event.id); 68 | return !alreadyExisted; 69 | }); 70 | 71 | for await(const newEvent of newEvents){ 72 | try { 73 | const title = `New Calendar Event: ${newEvent.title}`; 74 | const message = getEventDateTime(newEvent, config, config.notifyDateTimeFormat); 75 | await hass.callService('notify', config.notifyEntity, { title, message }); 76 | 77 | } catch(e){ 78 | console.error(e); 79 | } 80 | } 81 | 82 | return events; 83 | } 84 | 85 | /** 86 | * converts an event's start/end datetime objects into a UI string 87 | * @param {CalendarEvent} event 88 | * @param {Config} config 89 | * @param {String} timeFormat 90 | * @return {String} 91 | */ 92 | export const getEventDateTime = (event, config, timeFormat) => { 93 | if (event.isAllDayEvent) return config.fullDayEventText; 94 | 95 | const start = event.startDateTime && event.startDateTime.format(timeFormat); 96 | const end = event.endDateTime && event.endDateTime.format(timeFormat); 97 | 98 | const date = (event.isFirstDay && `${config.startText}: ${start}`) || (event.isLastDay && `${config.endText}: ${end}`) || (start && end && `${start} - ${end}`) || ''; 99 | return date; 100 | } 101 | 102 | 103 | /** 104 | * gets all events for all calendars added to this card's config 105 | * @return {Promise} 106 | */ 107 | export async function getAllEvents(config, hass){ 108 | 109 | // create url params 110 | const dateFormat = 'YYYY-MM-DDTHH:mm:ss'; 111 | const today = moment().startOf('day'); 112 | const start = today.format(dateFormat); 113 | const end = today.add(config.numberOfDays, 'days').format(dateFormat); 114 | 115 | // for each calendar entity get all events 116 | // each entity may be a string of entity id or 117 | // an object with custom name given with entity id 118 | const allEvents = []; 119 | const failedEvents = []; 120 | 121 | const calendarEntityPromises = []; 122 | config.entities.forEach(entity => { 123 | const calendarEntity = (entity && entity.entity) || entity; 124 | const url = `calendars/${calendarEntity}?start=${start}Z&end=${end}Z`; 125 | 126 | // make all requests at once 127 | calendarEntityPromises.push( 128 | hass.callApi('get', url) 129 | .then(rawEvents => { 130 | return rawEvents.map(event => { 131 | event.entity = entity; 132 | event.calendarEntity = calendarEntity; 133 | event.hassEntity = hass.states[calendarEntity]; 134 | return event; 135 | }); 136 | }) 137 | .then(events => { 138 | allEvents.push(...events); 139 | }) 140 | .catch(error => { 141 | failedEvents.push({ 142 | name: entity.name || calendarEntity, 143 | error 144 | }); 145 | }) 146 | ); 147 | }); 148 | 149 | // wait until all requests either succeed or fail 150 | await Promise.all(calendarEntityPromises); 151 | return { failedEvents, events: processEvents(allEvents, config) }; 152 | } 153 | 154 | /** 155 | * converts all calendar events to CalendarEvent objects 156 | * @param {Array} listo of raw caldav calendar events 157 | * @return {Promise>} 158 | */ 159 | export function processEvents(allEvents, config) { 160 | // for some reason Lit Element is trying to sync multiple times before this is complete causing 161 | // duplicate events - this forces unique events only by looking at calendar event id 162 | const uniqueEvents = allEvents.filter((event, index, self) => { 163 | // an event might have a uid or id (caldav has uid, google has id) via calendar-card/issues/37 164 | return index === self.findIndex(e => (e.id || e.uid) === (event.uid || event.id)); 165 | }); 166 | 167 | // convert each calendar object to a UI event 168 | let newEvents = uniqueEvents.reduce((events, caldavEvent) => { 169 | caldavEvent.originCalendar = config.entities.find(entity => entity.entity === caldavEvent.entity.entity); 170 | const newEvent = new CalendarEvent(caldavEvent, config); 171 | 172 | // if given ignoreEventsExpression value ignore events that match this title 173 | if (config.ignoreEventsExpression && newEvent.title) { 174 | const regex = new RegExp(config.ignoreEventsExpression, 'i'); 175 | if (regex.test(newEvent.title)) return events; 176 | } 177 | 178 | // if ide declined events then filter out events you have declined 179 | if (config.hideDeclined && newEvent.isDeclined) return events; 180 | 181 | // if given ignoreEventsByLocationExpression value ignore events that match this location 182 | if (config.ignoreEventsByLocationExpression && newEvent.location) { 183 | const regex = new RegExp(config.ignoreEventsByLocationExpression, 'i'); 184 | if (regex.test(newEvent.location)) return events; 185 | } 186 | 187 | /** 188 | * if we want to split multi day events and its a multi day event then 189 | * get how long then event is and for each day 190 | * copy the event, add # of days to start/end time for each event 191 | * then add as 'new' event 192 | */ 193 | if (config.showMultiDay && newEvent.isMultiDay) { 194 | const partialEvents = newEvent.splitIntoMultiDay(newEvent); 195 | events = events.concat(partialEvents); 196 | 197 | } else { 198 | events.push(newEvent); 199 | } 200 | 201 | return events; 202 | }, []); 203 | 204 | // remove events before today 205 | const today = moment().startOf('day'); 206 | newEvents = newEvents.filter(event => event.endDateTime.isAfter(today)); 207 | 208 | // if config to hide passed events then check that now 209 | if (config.hidePastEvents) { 210 | const now = moment(); 211 | newEvents = newEvents.filter(event => event.endDateTime.isAfter(now)); 212 | } 213 | 214 | // sort events by date starting with soonest 215 | newEvents.sort((a, b) => a.startDateTime.isBefore(b.startDateTime) ? -1 : 1); 216 | return newEvents; 217 | } 218 | -------------------------------------------------------------------------------- /src/html.tools.js: -------------------------------------------------------------------------------- 1 | import { html } from 'lit-element'; 2 | import moment from './locales'; 3 | 4 | import { getEventDateTime, openLink } from './event.tools'; 5 | 6 | /** 7 | * create card header 8 | * @return {TemplateResult} 9 | */ 10 | export function createHeader(config) { 11 | if (config.hideHeader || config.title === false) return html``; 12 | return html`
${config.title}
`; 13 | } 14 | 15 | /** 16 | * generates HTML for showing date an event is taking place 17 | * @param {number} index index of current day event 18 | * @param {Moment} momentDay 19 | */ 20 | export function getDateHtml(index, momentDay, config) { 21 | const top = index === 0 ? momentDay.format(config.dateTopFormat) : ''; 22 | const bottom = index === 0 ? momentDay.format(config.dateBottomFormat) : ''; 23 | 24 | return html` 25 |
${top}
26 |
${bottom}
27 | `; 28 | } 29 | 30 | /** 31 | * if event is going on now then build progress bar for event 32 | * @param {CalendarEvent} event 33 | * @return {TemplateResult} 34 | */ 35 | export function getProgressBar(event) { 36 | if (!event.startDateTime || !event.endDateTime || event.isAllDayEvent) return html``; 37 | 38 | const now = moment(new Date()); 39 | if (now.isBefore(event.startDateTime) || now.isSameOrAfter(event.endDateTime) || !event.startDateTime.isValid() || !event.endDateTime.isValid()) return html``; 40 | 41 | const nowSeconds = now.unix(); 42 | const startSeconds = event.startDateTime.unix(); 43 | const endSeconds = event.endDateTime.unix(); 44 | const secondsPercent = (nowSeconds - startSeconds) / (endSeconds - startSeconds) * 100; 45 | 46 | return html` 47 | 52 |
53 | `; 54 | } 55 | 56 | export function getEventOrigin(event, config){ 57 | if (!config.showEventOrigin) return html``; 58 | 59 | return html` 60 |
61 | ${event.originName} 62 | 63 |
64 | `; 65 | } 66 | 67 | /** 68 | * generates HTML for showing an event times 69 | * @param {CalendarEvent} event 70 | */ 71 | export function getTimeHtml(event, config) { 72 | if (config.hideTime === true) return html``; 73 | const date = getEventDateTime(event, config, config.timeFormat); 74 | return html`
${date}
`; 75 | } 76 | 77 | /** 78 | * generate link for an Anchor element 79 | * @param {Config} config 80 | * @param String} link 81 | */ 82 | export const getLink = (config, link) => { 83 | if (config.disableLinks) return '#'; 84 | else link; 85 | } 86 | 87 | /** 88 | * generate the html for showing an event location 89 | * @param {CalendarEvent} event 90 | */ 91 | export function getLocationHtml(event, config) { 92 | if (!event.location || !event.locationAddress) return html``; 93 | 94 | const link = `https://www.google.com/maps?daddr=${event.location} ${event.locationAddress}`; 95 | 96 | return html` 97 | openLink(e, link, config)} title='open location' class=${config.disableLinks ? 'no-pointer' : ''}> 98 | ${config.showLocationIcon ? 99 | html` 100 |
101 |   102 |
103 | ` : null 104 | } 105 |
106 | ${config.showLocation ? event.location : ''} 107 |
108 |
109 | `; 110 | } -------------------------------------------------------------------------------- /src/index-editor.js: -------------------------------------------------------------------------------- 1 | import { LitElement, html } from 'lit-element'; 2 | import style from './style-editor'; 3 | import defaultConfig from './defaults'; 4 | 5 | const fireEvent = (node, type, detail = {}, options = {}) => { 6 | const event = new Event(type, { 7 | bubbles: options.bubbles === undefined ? true : options.bubbles, 8 | cancelable: Boolean(options.cancelable), 9 | composed: options.composed === undefined ? true : options.composed, 10 | }); 11 | 12 | event.detail = detail; 13 | node.dispatchEvent(event); 14 | return event; 15 | }; 16 | 17 | 18 | export default class CalendarCardEditor extends LitElement { 19 | static get styles() { 20 | return style; 21 | } 22 | 23 | static get properties() { 24 | return { hass: {}, _config: {} }; 25 | } 26 | 27 | setConfig(config) { 28 | this._config = Object.assign({}, defaultConfig, config); 29 | } 30 | 31 | get entityOptions() { 32 | const entities = Object.keys(this.hass.states).filter(eid => eid.substr(0, eid.indexOf('.')) === 'calendar'); 33 | 34 | const entityOptions = entities.map(eid => { 35 | const matchingConfigEnitity = this._config.entities.find(entity => (entity && entity.entity || entity) === eid); 36 | const originalEntity = this.hass.states[eid]; 37 | 38 | return { 39 | entity: eid, 40 | name: (matchingConfigEnitity && matchingConfigEnitity.name) || originalEntity.attributes.friendly_name || eid, 41 | checked: !!matchingConfigEnitity 42 | } 43 | }); 44 | 45 | return entityOptions; 46 | } 47 | 48 | firstUpdated(){ 49 | this._firstRendered = true; 50 | } 51 | 52 | get entityNotifyOptions() { 53 | return Object.keys(this.hass.services.notify).sort(); 54 | } 55 | 56 | render() { 57 | if (!this.hass) return html``; 58 | 59 | return html` 60 |
61 | 62 |
63 | 69 | 70 |
71 | Hide Header 76 |
77 | 78 |
79 | Hide Time 84 | Progress Bar 89 |
90 | 91 |
92 | Show Location 97 | Show Location Icon 102 |
103 | 104 |
105 | Show MultDay 110 | Hide Past Events 115 |
116 | 117 |
118 | Show Event Origin 123 | Highlight Today's Events 128 |
129 | 130 |
131 | Show Event Origin 136 | Hard Limit 141 |
142 | 143 |
144 | Hide Declined Events 149 | Disable Links 154 |
155 | 156 |
157 | Max Height 162 |
163 | 164 |
165 |

Entities

166 | ${ 167 | this.entityOptions.map(entity => { 168 | return html` 169 |
170 | 175 | ${entity.entity} 176 | 177 | 178 | ${this._config.showEventOrigin ? 179 | html` 180 |
181 | 187 |
188 | ` : html`` 189 | } 190 |
191 | `; 192 | }) 193 | } 194 |
195 | 196 |
197 | 203 | 204 | 210 | 211 | 217 | 218 | 224 | 225 | 231 | 232 | 238 | 239 | 245 | 246 | 252 | 253 | 259 | 260 | 266 | 267 | 273 |
274 | 275 | 276 | 281 | 285 | ${ 286 | this.entityNotifyOptions.map(entity => { 287 | return html`${entity}`; 288 | }) 289 | } 290 | 291 | 292 | 293 | 299 |
300 | 301 |
302 | `; 303 | } 304 | 305 | /** 306 | * update config for a checkbox input 307 | * @param {*} ev 308 | */ 309 | checkboxChanged(ev){ 310 | if (this.cantFireEvent) return; 311 | const { target: { configValue }, detail: { value } } = ev; 312 | 313 | this._config = Object.assign({}, this._config, { [configValue]: value } ); 314 | fireEvent(this, 'config-changed', { config: this._config }); 315 | } 316 | 317 | /** 318 | * change on text input 319 | * @param {*} ev 320 | */ 321 | inputChanged(ev){ 322 | if (this.cantFireEvent) return; 323 | const { target: { configValue }, detail: { value } } = ev; 324 | 325 | this._config = Object.assign({}, this._config, { [configValue]: value } ); 326 | fireEvent(this, 'config-changed', { config: this._config }); 327 | } 328 | 329 | get entities(){ 330 | const entities = [...(this._config.entities || [])]; 331 | 332 | // convert any legacy entity strings into objects 333 | let entityObjects = entities.map(entity => { 334 | if(entity.entity) return entity; 335 | return { entity, name: entity }; 336 | }); 337 | 338 | return entityObjects; 339 | } 340 | 341 | /** 342 | * change the calendar name of an entity 343 | * @param {*} ev 344 | */ 345 | entityNameChanged({ target: { entityId }, detail: { value } }){ 346 | if (this.cantFireEvent) return; 347 | let entityObjects = [...this.entities]; 348 | 349 | entityObjects = entityObjects.map(entity => { 350 | if(entity.entity === entityId) entity.name = value || ''; 351 | return entity; 352 | }); 353 | 354 | this._config = Object.assign({}, this._config, { entities: entityObjects } ); 355 | fireEvent(this, 'config-changed', { config: this._config }); 356 | } 357 | 358 | /** 359 | * add or remove calendar entities from config 360 | * @param {*} ev 361 | */ 362 | entityChanged({ target: { entityId }, detail: { value } }){ 363 | if (this.cantFireEvent) return; 364 | let entityObjects = [...this.entities]; 365 | 366 | if(value){ 367 | const originalEntity = this.hass.states[entityId]; 368 | entityObjects.push({ entity: entityId, name: originalEntity.attributes.friendly_name || entityId }); 369 | 370 | } else { 371 | entityObjects = entityObjects.filter(entity => entity.entity !== entityId); 372 | } 373 | 374 | this._config = Object.assign({}, this._config, { entities: entityObjects } ); 375 | fireEvent(this, 'config-changed', { config: this._config }); 376 | } 377 | 378 | /** 379 | * stop events from firing if certains conditions not met 380 | */ 381 | get cantFireEvent(){ 382 | return (!this._config || !this.hass || !this._firstRendered); 383 | } 384 | } 385 | 386 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import moment from './locales'; 2 | 3 | import { LitElement, html } from 'lit-element'; 4 | import { repeat } from 'lit-html/directives/repeat'; 5 | import packageJson from '../package.json'; 6 | 7 | import { groupEventsByDay, openLink, getAllEvents, sendNotificationForNewEvents } from './event.tools'; 8 | 9 | import { 10 | getLocationHtml, createHeader, getDateHtml, 11 | getProgressBar, getEventOrigin, getTimeHtml, 12 | } from './html.tools'; 13 | 14 | import style from './style'; 15 | import defaultConfig from './defaults'; 16 | 17 | import CalendarCardEditor from './index-editor'; 18 | customElements.define('calendar-card-editor', CalendarCardEditor); 19 | 20 | /* eslint no-console: 0 */ 21 | console.info(`%c CALENDAR-CARD \n%c Version ${packageJson.version} `, "color: orange; font-weight: bold; background: black", "color: white; font-weight: bold; background: dimgray"); 22 | 23 | 24 | class CalendarCard extends LitElement { 25 | static get properties() { 26 | return { 27 | hass: { type: Object }, 28 | config: { type: Object }, 29 | events: { type: Object }, 30 | }; 31 | } 32 | 33 | constructor(){ 34 | super(); 35 | this.events = false; 36 | } 37 | 38 | static async getConfigElement() { 39 | return document.createElement("calendar-card-editor"); 40 | } 41 | 42 | /** 43 | * merge the user configuration with default configuration 44 | * @param {[type]} config 45 | */ 46 | setConfig(config) { 47 | config = { ...defaultConfig, ...config }; 48 | 49 | if (!config.entities || !config.entities.length) { 50 | throw new Error('You need to define at least one calendar entity via entities'); 51 | } 52 | 53 | if (config.entities && (isNaN(config.eventsLimit) || config.eventsLimit < 0)) { 54 | throw new Error('The eventsLimit option needs to be a positive number'); 55 | } 56 | 57 | // if checked entities has changed then update events 58 | const newNames = (config.entities || []).map(entity => entity.entity || entity); 59 | const oldNames = ((this.config || {}).entities || []).map(entity => entity.entity || entity); 60 | if(!this.config || JSON.stringify(newNames) !== JSON.stringify(oldNames) || config.numberOfDays !== this.config.numberOfDays) { 61 | this.cardNeedsUpdating = true; 62 | } 63 | 64 | // if anything changed then overall card needs updating 65 | if(JSON.stringify(config) !== JSON.stringify(this.config || {})) { 66 | this.cardNeedsUpdating = true; 67 | } 68 | 69 | this.config = { ...config }; 70 | } 71 | 72 | /** 73 | * get the size of the card 74 | * @return {Number} 75 | */ 76 | getCardSize() { 77 | return 8; 78 | } 79 | 80 | static get styles() { 81 | return style; 82 | } 83 | 84 | render() { 85 | this.updateCard(); 86 | 87 | return html` 88 | 89 | ${createHeader(this.config)} 90 | ${this.events ? html`${this.events}` : 91 | html` 92 |
93 | 94 |
95 | ` 96 | } 97 |
98 | `; 99 | } 100 | 101 | /** 102 | * updates the entire card 103 | * @return {TemplateResult} 104 | */ 105 | async updateCard() { 106 | moment.locale(this.hass.language); 107 | 108 | // dont update if we dont need it to conserve api calls 109 | if (!this.cardNeedsUpdating && moment().diff(this.lastEventsUpdate, 'seconds') < 600) return; 110 | 111 | this.lastEventsUpdate = moment(); 112 | this.cardNeedsUpdating = false; 113 | 114 | const { events, failedEvents } = await getAllEvents(this.config, this.__hass); 115 | const groupedEventsByDay = groupEventsByDay(events, this.config); 116 | 117 | // send notification of any new events if setup 118 | this.oldEvents = await sendNotificationForNewEvents(this.config, this.__hass, events, this.oldEvents); 119 | 120 | // get all failed calendar retrievals 121 | const failedCalendars = failedEvents.reduce((errorTemplate, failedEntity) => { 122 | return html` 123 | ${errorTemplate} 124 | 125 | ${failedEntity.name} 126 | ${failedEntity.error.error} 127 | 128 | 129 | `; 130 | }, html``); 131 | 132 | // get today to see what events are today 133 | const today = moment(new Date()); 134 | 135 | const calendar = groupedEventsByDay.reduce((htmlTemplate, eventDay) => { 136 | 137 | // for each event in a day create template for that event 138 | const eventsTemplate = repeat(eventDay.events, event => event.id, (event, index) => { 139 | const isLastEventInGroup = eventDay.events.length === index + 1; 140 | 141 | // add class to last event group 142 | const lastKls = isLastEventInGroup ? 'day-wrapper-last' : ''; 143 | 144 | // add class if config to hightlight today's events 145 | const eventDateTime = moment(eventDay.day); 146 | const todayKls = this.config.highlightToday && eventDateTime.isSame(today, "day") ? 'highlight-events' : ''; 147 | 148 | // use the source element url if it exists 149 | const linkUrl = this.config.useSourceUrl && event.sourceUrl ? event.sourceUrl : event.htmlLink; 150 | 151 | const disableLink = this.config.disableLinks || !linkUrl; 152 | 153 | return html` 154 | 155 | 156 | ${getDateHtml(index, eventDateTime, this.config)} 157 | 158 | openLink(e, linkUrl, this.config)}> 159 |
${event.title}
160 | ${getTimeHtml(event, this.config)} 161 | ${getEventOrigin(event, this.config)} 162 | ${this.config.progressBar ? getProgressBar(event) : ''} 163 | 164 | 165 | ${getLocationHtml(event, this.config)} 166 | 167 | 168 | ` 169 | }); 170 | 171 | return html` 172 | ${htmlTemplate} 173 | ${eventsTemplate} 174 | `; 175 | }, html``); 176 | 177 | this.events = html` 178 | 179 | 180 | ${failedCalendars} 181 | ${calendar} 182 | 183 |
184 | `; 185 | } 186 | } 187 | 188 | customElements.define('calendar-card', CalendarCard); 189 | -------------------------------------------------------------------------------- /src/style-editor.js: -------------------------------------------------------------------------------- 1 | import { css } from 'lit-element'; 2 | 3 | const style = css` 4 | .entities { 5 | margin-top: 30px; 6 | margin-top: 30px; 7 | } 8 | 9 | .entities paper-checkbox { 10 | display: block; 11 | margin-bottom: 0px; 12 | margin-left: 10px; 13 | } 14 | 15 | .entity-select { 16 | margin-top: 20px; 17 | } 18 | 19 | .checkbox-options:first-of-type { 20 | margin-top: 10px; 21 | } 22 | 23 | .checkbox-options:last-of-type { 24 | margin-bottom: 10px; 25 | } 26 | 27 | .checkbox-options { 28 | display: flex; 29 | } 30 | 31 | .checkbox-options paper-checkbox { 32 | margin-top: 5px; 33 | width: 50%; 34 | } 35 | 36 | .overall-config { 37 | margin-bottom: 10px; 38 | } 39 | 40 | .origin-calendar { 41 | width: 50%; 42 | margin-left: 35px; 43 | } 44 | `; 45 | 46 | export default style; 47 | -------------------------------------------------------------------------------- /src/style.js: -------------------------------------------------------------------------------- 1 | import { css } from 'lit-element'; 2 | 3 | const style = css` 4 | 5 | .calendar-card { 6 | display: flex; 7 | padding: 0 16px 4px; 8 | flex-direction: column; 9 | } 10 | 11 | .max-height { 12 | max-height: 500px; 13 | overflow-y: scroll; 14 | } 15 | 16 | .loader { 17 | width: 100%; 18 | padding-top: 30px; 19 | padding-bottom: 30px; 20 | display: flex; 21 | align-items: center; 22 | justify-content: center; 23 | } 24 | 25 | .header { 26 | font-family: var(--paper-font-headline_-_font-family); 27 | -webkit-font-smoothing: var(--paper-font-headline_-_-webkit-font-smoothing); 28 | font-size: var(--paper-font-headline_-_font-size); 29 | font-weight: var(--paper-font-headline_-_font-weight); 30 | letter-spacing: var(--paper-font-headline_-_letter-spacing); 31 | line-height: var(--paper-font-headline_-_line-height); 32 | text-rendering: var(--paper-font-common-expensive-kerning_-_text-rendering); 33 | opacity: var(--dark-primary-opacity); 34 | padding: 24px 0px 0px; 35 | } 36 | 37 | .no-pointer { 38 | cursor: default !important; 39 | } 40 | 41 | table { 42 | border-spacing: 0; 43 | margin-bottom: 10px; 44 | width: 100%; 45 | } 46 | 47 | .highlight-events .title, .highlight-events .date { 48 | color: var(--accent-color) !important; 49 | } 50 | 51 | .day-wrapper td { 52 | padding-top: 10px; 53 | cursor: pointer; 54 | } 55 | 56 | .day-wrapper.day-wrapper-last > td { 57 | padding-bottom: 10px; 58 | border-bottom: 1px solid; 59 | border-color: var(--accent-color); 60 | } 61 | 62 | .day-wrapper.day-wrapper-last:last-child > td { 63 | border-bottom: 0 !important; 64 | } 65 | 66 | .day-wrapper .overview { 67 | padding-left: 10px; 68 | cursor: pointer; 69 | } 70 | 71 | .day-wrapper .overview .time, 72 | .day-wrapper .location ha-icon { 73 | color: var(--secondary-text-color); 74 | } 75 | 76 | .day-wrapper hr.progress-bar { 77 | border-style: solid; 78 | border-color: var(--accent-color); 79 | border-width: 1px 0 0 0; 80 | color: var(--primary-color); 81 | display:inline-block; 82 | position:relative; 83 | top:-7px; 84 | width: 100%; 85 | margin: 0; 86 | } 87 | 88 | .day-wrapper ha-icon.progress-bar { 89 | display:block; 90 | height:9px; 91 | --mdc-icon-size: 9px; 92 | color: var(--accent-color); 93 | position:relative; 94 | top:5px; 95 | } 96 | 97 | .day-wrapper .overview { 98 | word-break: break-word; 99 | } 100 | 101 | .day-wrapper .location { 102 | max-width: 100px; 103 | word-break: break-word; 104 | } 105 | 106 | .day-wrapper .location a { 107 | text-decoration: none; 108 | display: flex; 109 | color: var(--accent-color); 110 | } 111 | 112 | .event-origin span { 113 | color: var(--accent-color); 114 | margin-right: -4px; 115 | } 116 | 117 | .event-origin ha-icon { 118 | position: relative; 119 | top: -1px; 120 | left: 4px; 121 | color: var(--accent-color); 122 | --mdc-icon-size: 13px; 123 | } 124 | `; 125 | 126 | export default style; 127 | -------------------------------------------------------------------------------- /webpack/config.common.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | entry: './src/index.js', 5 | output: { 6 | filename: 'calendar-card.js', 7 | path: path.resolve(__dirname, '../dist') 8 | }, 9 | module: { 10 | rules: [ 11 | { 12 | test: /\.m?js$/, 13 | include: [ 14 | /node_modules(?:\/|\\)lit-element|lit-html/ 15 | ], 16 | use: { 17 | loader: 'babel-loader' 18 | } 19 | } 20 | ] 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /webpack/config.dev.js: -------------------------------------------------------------------------------- 1 | const merge = require('webpack-merge'); 2 | const commonConfig = require('./config.common'); 3 | 4 | module.exports = merge(commonConfig, { 5 | mode: 'development', 6 | devtool: 'source-map', 7 | }); -------------------------------------------------------------------------------- /webpack/config.prod.js: -------------------------------------------------------------------------------- 1 | const merge = require('webpack-merge'); 2 | 3 | const commonConfig = require('./config.common'); 4 | 5 | 6 | module.exports = merge(commonConfig, { 7 | mode: 'production', 8 | optimization: { 9 | minimize: true 10 | }, 11 | output: { 12 | publicPath: '/local/' 13 | }, 14 | }); --------------------------------------------------------------------------------