Source

client/ui/data/calendar.js

/** 
 * 
 * The **Calendar** derives from [DataComponent](kiss.ui.DataComponent.html).
 * 
 * @param {object} config
 * @param {string} [config.date] - The initial date to display in the timeline (default = today)
 * @param {string} [config.period] - "month" (default) | "3 weeks" | "2 weeks" | "1 week" | "1 week + details"
 * @param {boolean} [config.startOnMonday]
 * @param {boolean} [config.showWeekend]
 * @param {string} config.dateField - The field to use as reference for the calendar
 * @param {string} [config.timeField]
 * @param {Collection} config.collection - The data source collection
 * @param {object} [config.record] - Record to persist the view configuration into the db
 * @param {object[]} [config.columns] - Where each column is: {title: "abc", type: "text|number|integer|float|date|button", id: "fieldId", button: {config}, renderer: function() {}}
 * @param {string} [config.color] - Hexa color code. Ex: #00aaee
 * @param {boolean} [config.showToolbar] - false to hide the toolbar (default = true)
 * @param {boolean} [config.showActions] - false to hide the custom actions button (default = true)
 * @param {boolean} [config.showSetup] - false to hide the setup button (default = true)
 * @param {boolean} [config.canFilter] - false to hide the filter button (default = true)
 * @param {boolean} [config.canSelectFields] - false to hide the button to select fields (default = true)
 * @param {boolean} [config.canChangePeriod] - false to hide the possibility to change period (1 month, 2 weeks...) (default = true)
 * @param {boolean} [config.canCreateRecord] - Can we create new records from the calendar?
 * @param {object[]} [config.actions] - Array of menu actions, where each menu entry is: {text: "abc", icon: "fas fa-check", action: function() {}}
 * @param {number|string} [config.width]
 * @param {number|string} [config.height]
 * @returns this
 * 
 * ## Generated markup
 * ```
 * <a-calendar class="a-calendar">
 *      <div class="calendar-toolbar">
 *          <!-- Calendar toolbar items -->
 *      </div>
 *      <div class="calendar-body">
 *          <!-- Calendar entries here -->
 *      </div>
 * </a-calendar>
 * ```
 */
kiss.ui.Calendar = class Calendar extends kiss.ui.DataComponent {
    /**
     * Its a Custom Web Component. Do not use the constructor directly with the **new** keyword.
     * Instead, use one of the 3 following methods:
     * 
     * Create the Web Component and call its **init** method:
     * ```
     * const myCalendar = document.createElement("a-calendar").init(config)
     * ```
     * 
     * Or use the shorthand for it:
     * ```
     * const myCalendar = createCalendar({
     *   id: "my-calendar",
     *   color: "#00aaee",
     *   collection: kiss.app.collections["meetings"],
     *   
     *   // We can add custom methods, and also override default ones
     *   methods: {
     * 
     *      // Override the createRecord method
     *      createRecord(model) {
     *          // Create a record from this model
     *          console.log(model)
     *      },
     * 
     *      // Override the selectRecord method
     *      selectRecord(record) {
     *          // Show the clicked record
     *          console.log(record)
     *      },
     * 
     *      sayHello: () => console.log("Hello"),
     *   }
     * })
     * 
     * myCalendar.render()
     * ```
     */
    constructor() {
        super()
    }

    /**
     * Generates a Calendar from a JSON config
     * 
     * @ignore
     * @param {object} config - JSON config
     * @returns {HTMLElement}
     */
    init(config) {
        // This component must be resized with its parent container
        config.autoSize = true

        // Init the parent DataComponent
        super.init(config)

        // Options
        this.showToolbar = (config.showToolbar !== false)
        this.showActions = (config.showActions !== false)
        this.showSetup = (config.showSetup !== false)
        this.canSearch = (config.canSearch !== false)
        this.canFilter = (config.canFilter !== false)
        this.canSelectFields = (config.canSelectFields !== false)
        this.canChangePeriod = (config.canChangePeriod !== false)
        this.color = config.color || "#00aaee"
        this.actions = config.actions || []

        // Build calendar skeletton markup
        let id = this.id
        this.innerHTML =
            /*html*/
            `<div id="calendar-toolbar:${id}" class="calendar-toolbar">
                <div id="create:${id}"></div>
                <div id="actions:${id}"></div>
                <div id="setup:${id}"></div>
                <div id="select:${id}"></div>
                <div id="filter:${id}"></div>
                <div id="refresh:${id}"></div>
                <div class="spacer"></div>
                <div id="title:${id}" class="calendar-title"></div>
                <div class="spacer"></div>
                <div id="pager-index:${id}" class="calendar-toolbar-pager-index"></div>
                <div id="pager-mode:${id}"></div>
                <div id="pager-previous:${id}"></div>
                <div id="pager-next:${id}"></div>
                <div id="pager-today:${id}"></div>
                <div id="layout:${id}"></div>
            </div>
            <div id="calendar-body:${id}" class="calendar-body">`.removeExtraSpaces()

        // Set calendar components
        this.calendar = this.querySelector(".calendar")
        this.calendarToolbar = this.querySelector(".calendar-toolbar")
        this.calendarBody = this.querySelector(".calendar-body")

        this._initColumns(config.columns)
            ._initCalendarParams(config)
            ._initTexts()
            ._initElementsVisibility()
            ._initEvents()
            ._initSubscriptions()

        return this
    }

    /**
     * Load data into the calendar.
     * 
     * @private
     * @ignore
     */
    async load() {
        try {
            log(`kiss.ui - Calendar ${this.id} - Loading collection <${this.collection.id} (changed: ${this.collection.hasChanged})>`)

            this.collection.filter = this.filter
            this.collection.filterSyntax = this.filterSyntax
            await this.collection.find({}, true)

            this._renderToolbar()
            this.showCalendar(this.date)

        } catch (err) {
            log(err)
            log(`kiss.ui - Calendar ${this.id} - Couldn't load data properly`)
        }
    }

    /**
     * Reload the data and re-render
     */
    async reload() {
        await this.load()
        this._initColumns()
        this._render()
    }

    /**
     * Update the calendar layout
     */
    updateLayout() {
        if (this.isConnected) {
            this._render()
        }
    }

    /**
     * Update the calendar color (toolbar buttons + modal windows)
     * 
     * @param {string} newColor
     */
    async setColor(newColor) {
        this.color = newColor
        Array.from(this.calendarToolbar.children).forEach(item => {
            if (item && item.firstChild && item.firstChild.type == "button") item.firstChild.setIconColor(newColor)
        })
        this._render()
    }

    /**
     * Show the calendar at a given date
     * 
     * @param {date|string} date - Date given as a Date or an ISO date string like "2023-06-24"
     * @returns this
     */
    showCalendar(date) {
        if (typeof date == "string" && kiss.tools.isISODate(date, true)) date = new Date(date)
        const animation = (date.toISO() == this.date.toISO()) ? "fadeIn" : ((date.toISO() < this.date.toISO()) ? "slideInLeft" : "slideInRight")
        this.date = date
        this._render()

        this.calendarBody.setAnimation({
            name: animation,
            speed: "light"
        })
        return this
    }

    /**
     * Show the window to setup the calendar:
     * - source date field
     * - source time field
     * - prefered layout (1 month, 2 weeks, 1 week...)
     * - show week-ends
     * - week starts on monday
     */
    showSetupWindow() {
        let dateFields = this.model.getFieldsByType("date")
            .filter(field => !field.deleted)
            .map(field => {
                return {
                    value: field.id,
                    label: field.label.toTitleCase()
                }
            })

        let timeFields = this.model.getFieldsByType("select")
            .filter(field => !field.deleted && field.template == "time")
            .map(field => {
                return {
                    value: field.id,
                    label: field.label.toTitleCase()
                }
            })

        createPanel({
            icon: "fas fa-calendar",
            title: txtTitleCase("setup the calendar"),
            headerBackgroundColor: this.color,
            modal: true,
            draggable: true,
            closable: true,
            align: "center",
            verticalAlign: "center",
            width: 400,

            defaultConfig: {
                labelPosition: "top",
                optionsColor: this.color
            },

            items: [
                // Source date field
                {
                    type: "select",
                    id: "calendar-datefield:" + this.id,
                    label: txtTitleCase("date field used"),
                    options: dateFields,
                    maxHeight: () => kiss.screen.current.height - 200,
                    value: this.dateField,
                    events: {
                        change: async function () {
                            let dateField = this.getValue()
                            let viewId = this.id.split(":")[1]
                            publish("EVT_VIEW_SETUP:" + viewId, {
                                dateField
                            })
                        }
                    }
                },
                // Source time field
                {
                    type: "select",
                    id: "calendar-timefield:" + this.id,
                    label: txtTitleCase("time field used"),
                    options: timeFields,
                    maxHeight: () => kiss.screen.current.height - 200,
                    value: this.timeField,
                    events: {
                        change: async function () {
                            let timeField = this.getValue()
                            let viewId = this.id.split(":")[1]
                            publish("EVT_VIEW_SETUP:" + viewId, {
                                timeField
                            })
                        }
                    }
                },
                // Default period
                {
                    type: "select",
                    id: "calendar-period:" + this.id,
                    label: txtTitleCase("default period"),
                    options: [{
                            label: txtTitleCase("month"),
                            value: "month"
                        },
                        {
                            label: "3 " + txtTitleCase("weeks"),
                            value: "3 weeks"
                        },
                        {
                            label: "2 " + txtTitleCase("weeks"),
                            value: "2 weeks"
                        },
                        {
                            label: "1 " + txtTitleCase("week"),
                            value: "1 week"
                        },
                        {
                            label: "1 " + txtTitleCase("week") + " + " + txtTitleCase("details"),
                            value: "1 week + details"
                        }
                    ],
                    value: this.period || "month",
                    events: {
                        change: async function () {
                            let period = this.getValue()
                            let viewId = this.id.split(":")[1]
                            publish("EVT_VIEW_SETUP:" + viewId, {
                                period
                            })
                        }
                    }
                },
                // Show week-end ?
                {
                    type: "checkbox",
                    id: "calendar-showWeekend:" + this.id,
                    label: txtTitleCase("show week-ends"),
                    labelPosition: "right",
                    shape: "switch",
                    iconColorOn: this.color,
                    value: this.showWeekend,
                    events: {
                        change: async function () {
                            let showWeekend = this.getValue()
                            let viewId = this.id.split(":")[1]
                            publish("EVT_VIEW_SETUP:" + viewId, {
                                showWeekend
                            })

                            if (showWeekend == true) {
                                $("calendar-startOnMonday:" + viewId).show()
                            } else {
                                $("calendar-startOnMonday:" + viewId).hide()
                            }
                        }
                    }
                },
                // Weeks start on monday ?
                {
                    hidden: !this.showWeekend,
                    type: "checkbox",
                    id: "calendar-startOnMonday:" + this.id,
                    label: txtTitleCase("weeks start on monday"),
                    labelPosition: "right",
                    shape: "switch",
                    iconColorOn: this.color,
                    value: this.startOnMonday,
                    events: {
                        change: async function () {
                            let startOnMonday = this.getValue()
                            let viewId = this.id.split(":")[1]
                            publish("EVT_VIEW_SETUP:" + viewId, {
                                startOnMonday
                            })
                        }
                    }
                }
            ]
        }).render()
    }

    /**
     * Show the window just under the fields selector button
     */
    showFieldsWindow() {
        let selectionButton = $("select:" + this.id)
        const box = selectionButton.getBoundingClientRect()
        super.showFieldsWindow(box.left, box.top + 40, this.color)
    }

    /**
     * Show the window just under the filter button
     */
    showFilterWindow() {
        super.showFilterWindow(null, null, this.color)
    }

    /**
     * Define the specific calendar params:
     * - the initial date
     * - date field used to filter and display records
     * - time field used
     * - displayed period (1 month, 2 months...)
     * - start on Monday
     * - show weekend
     * 
     * @private
     * @ignore
     * @param {object} config - {date, dateField, timeField, period, startOnMonday, showWeekend
     * @returns this
     */
    _initCalendarParams(config) {
        this.date = this.config.date || new Date()

        if (this.record) {
            this.dateField = config.dateField || this.record.config.dateField
            this.timeField = config.timeField || this.record.config.timeField
            this.period = config.period || this.record.config.period || "month"
            this.startOnMonday = (config.hasOwnProperty("startOnMonday")) ? !!config.startOnMonday : (this.record.config.startOnMonday !== false)
            this.showWeekend = (config.hasOwnProperty("showWeekend")) ? !!config.showWeekend : (this.record.config.showWeekend !== false)

        } else {
            this.dateField = config.dateField || this.config.dateField
            this.timeField = config.timeField || this.config.timeField
            this.period = config.period || this.config.period || "month"
            this.startOnMonday = (config.hasOwnProperty("startOnMonday")) ? !!config.startOnMonday : (this.config.startOnMonday !== false)
            this.showWeekend = (config.hasOwnProperty("showWeekend")) ? !!config.showWeekend : (this.config.showWeekend !== false)
        }

        // Defaults to the first date field, or the creation date if no date field was found
        if (!this.dateField) {
            let modelDateFields = this.model.getFieldsByType(["date"])
            if (modelDateFields.length != 0) {
                this.dateField = modelDateFields[0].id
            } else {
                this.dateField = "createdAt"
            }
        }

        // Defaults to the first time field
        if (!this.timeField && this.timeField !== "") {
            let modelTimeFields = this.model.getFieldsByType(["select"])
            modelTimeFields = modelTimeFields.filter(field => field.template == "time")

            if (modelTimeFields.length != 0) {
                this.timeField = modelTimeFields[0].id
            }
        }

        return this
    }

    /**
     * Set toolbar visibility
     * 
     * @private
     * @ignore
     * @returns this
     */
    _initElementsVisibility() {
        if (this.showToolbar == false) this.calendarToolbar.style.display = "none"
        return this
    }    

    /**
     * Initialize subscriptions to PubSub
     * 
     * TODO: don't reload the view when records are inserted/deleted/updated outside of the current viewport
     * 
     * @private
     * @ignore
     * @returns this
     */
    _initSubscriptions() {
        super._initSubscriptions()

        const viewModelId = this.modelId.toUpperCase()

        this.subscriptions = this.subscriptions.concat([
            // Local events (not coming from websocket)
            subscribe("EVT_VIEW_SETUP:" + this.id, (msgData) => this._updateConfig(msgData)),

            // React to database mutations
            subscribe("EVT_DB_INSERT:" + viewModelId, (msgData) => this._reloadWhenNeeded(msgData)),
            subscribe("EVT_DB_UPDATE:" + viewModelId, (msgData) => this._updateOneAndReload(msgData)),
            subscribe("EVT_DB_DELETE:" + viewModelId, (msgData) => this._reloadWhenNeeded(msgData)),
            subscribe("EVT_DB_INSERT_MANY:" + viewModelId, (msgData) => this._reloadWhenNeeded(msgData, 2000)),
            subscribe("EVT_DB_UPDATE_MANY:" + viewModelId, (msgData) => this._reloadWhenNeeded(msgData, 2000)),
            subscribe("EVT_DB_DELETE_MANY:" + viewModelId, (msgData) => this._reloadWhenNeeded(msgData, 2000)),
            subscribe("EVT_DB_UPDATE_BULK", (msgData) => this._reloadWhenNeeded(msgData, 2000))
        ])

        return this
    }

    /**
     * Initialize all calendar events
     * 
     * @private
     * @ignore
     * @returns this
     */
    _initEvents() {
        this.onclick = async (event) => {
            const clickedElement = event.target
            const recordElement = clickedElement.closest(".calendar-record")
            if (!recordElement) return

            if (recordElement.classList.contains("calendar-record")) {
                const recordId = recordElement.getAttribute("recordid")
                const record = await this.collection.getRecord(recordId)
                await this.selectRecord(record)
                return event
            }
        }
        return this
    }

    /**
     * Render the calendar
     * 
     * @private
     * @ignore
     * @returns this
     */
    _render() {
        switch (this.period) {
            case "month":
                return this._renderAsWeeks(6)
            case "3 weeks":
                return this._renderAsWeeks(3)
            case "2 weeks":
                return this._renderAsWeeks(2)
            case "1 week":
                return this._renderAsWeeks(1)
            case "1 week + details":
                return this._renderAsWeeks(1, true)
            default:
                return this._renderAsWeeks(6)
        }
    }

    /**
     * Render the calendar for 2, 3 or 6 weeks
     * 
     * @private
     * @ignore
     * @param {number} numberOfWeeks - 1 to 6 (month)
     * @param {boolean} expanded - If true, display large items in the calendar
     * @returns this
     */
    _renderAsWeeks(numberOfWeeks, expanded) {
        let date = this.date
        let currentMonth = date.getMonth()
        let currentDate = (this.startOnMonday) ? this._getPreviousMonday(date) : this._getPreviousSunday(date)
        this.startDate = currentDate

        switch (this.period) {
            case "month":
                this.date.setDate(1)
                break
            default:
                this.date = this._getPreviousMonday(this.date)
        }

        let calendarHTML = /*html*/ `
            <div class="calendar-header">
                ${this.weekDays.map(day => `<div class="calendar-header-title">${day}</div>`).join("")}
            </div>`

        for (let weekIndex = 1; weekIndex <= numberOfWeeks; weekIndex++) {
            calendarHTML += `<div class="calendar-week">`
            for (let dayIndex = 1; dayIndex <= 7; dayIndex++) {
                let day = currentDate.getDate()
                let month = currentDate.getMonth()
                let year = currentDate.getFullYear()
                let dayId = this._getDayId(day, month + 1, year)
                let dayClass = ""

                // Week-end => Alternate background color
                if (currentDate.getDay() == 0 || currentDate.getDay() == 6) {
                    dayClass = (this.showWeekend) ? "calendar-weekend" : "calendar-no-weekend"
                }

                // Other months => Light font color
                if (month != currentMonth) day = `<span class="calendar-otherMonth">${day}</span>`

                calendarHTML += `<div class="calendar-day ${dayClass}"><div>${day}</div><div class="day-items" id="${dayId}"></div></div>`
                currentDate = new Date(kiss.formula.ADJUST_DATE(currentDate, 0, 0, 1, 0, 0, 0))
            }
            calendarHTML += `</div>`
        }

        this.endDate = currentDate
        this._renderToolbarTitle()
        this.calendarBody.innerHTML = calendarHTML

        this._renderRecords(expanded)
        this._renderToday()
        return this
    }

    /**
     * Render records inside the calendar
     * 
     * @private
     * @ignore
     * @param {boolean} expanded - If true, display items as cards in the calendar
     */
    _renderRecords(expanded) {
        let records = this.collection.records.filter(record => record[this.dateField] >= this.startDate.toISO() && record[this.dateField] <= this.endDate.toISO())
        if (this.timeField) records = records.sortBy(this.timeField)

        records.forEach(record => {
            const date = new Date(record[this.dateField]).toISO()
            const calendarCell = $(date)
            if (!calendarCell) return

            const recordHtml = document.createElement("div")
            recordHtml.innerHTML = (!expanded) ? this._renderRecord(record) : this._renderRecordAsCard(record)
            recordHtml.classList.add("calendar-record")
            recordHtml.style.borderColor = this.model.color
            recordHtml.setAttribute("recordid", record.id)
            calendarCell.appendChild(recordHtml)
        })
    }

    /**
     * Render a single record
     * 
     * @private
     * @ignore
     * @param {object} record
     * @returns {string} Html for a single record
     */
    _renderRecord(record) {
        let recordHtml = ((this.timeField) ? record[this.timeField] : "") || ""
        if (recordHtml) recordHtml = `<span style="color: ${this.model.color}">${recordHtml}</span>`

        this.columns
            .filter(column => column.hidden !== true)
            .forEach(column => {
                let field = this.model.getField(column.id)
                if (!field) return

                let value = record[column.id]
                if (!value && value !== false) return

                recordHtml += " ยท " + this._renderSingleValue(field, value, record)
            })
        return recordHtml
    }

    /**
     * Highlight today
     * 
     * @private
     * @ignore
     */
    _renderToday() {
        let today = new Date().toISO()
        let todayCell = $(today)
        if (!todayCell) return

        let day = todayCell.parentNode.children[0]
        day.classList.add("calendar-today")
        day.style.color = "#ffffff"
        day.style.backgroundColor = this.color
    }    

    /**
     * Render a single record as a Card for 1 week view
     * 
     * @private
     * @ignore
     * @param {object} record
     * @returns {string} Html for a single record
     */
    _renderRecordAsCard(record) {
        let recordHtml = ((this.timeField) ? record[this.timeField] : "") || ""
        if (recordHtml) recordHtml = `<span style="color: ${this.model.color}">${recordHtml}</span>`

        this.columns
            .filter(column => column.hidden !== true)
            .forEach(column => {
                let field = this.model.getField(column.id)
                if (!field) return
                if (["attachment", "password", "link"].includes(field.type)) return

                let value = record[column.id]
                if (!value && value !== false) return

                let valueHtml = this._renderSingleValue(field, value, record)
                recordHtml += /*html*/ `
                    <div class="calendar-record-field">
                        <div class="calendar-record-label">${field.label} ${(field.unit) ? `(${field.unit})` : ""}</div>
                        <div class="calendar-record-value">${valueHtml}</div>
                    </div>
                `.removeExtraSpaces()
            })

        return recordHtml
    }

    /**
     * Render a single value inside a card
     * 
     * @private
     * @ignore
     * @param {object} field - Field to render
     * @param {*} value - Field value
     * @param {object} value - The record, useful for custom renderers
     * @returns {string} Html for the value
     */
    _renderSingleValue(field, value, record) {

        // Convert summary & lookup fields to mimic the type of their source field
        let type = field.type
        if (type == "lookup") {
            type = field.lookup.type
        } else if (type == "summary") {
            if (field.summary.type == "directory" && field.summary.operation == "LIST_NAMES") type = "directory"
        }

        if (field.valueRenderer) return field.valueRenderer(value, record)

        switch (type) {
            case "date":
                return new Date(value).toLocaleDateString()
            case "directory":
                return this._rendererForDirectoryFields(value)
            case "select":
                return this._rendererForSelectFields(field, value)
            case "checkbox":
                return this._rendererForCheckboxFields(field, value)
            case "rating":
                return this._rendererForRatingFields(field, value)
            case "attachment":
            case "aiImage":
                return "..."
            case "password":
                return "***"
            default:
                return value
        }
    }

    /**
     * Renderer for "Checkbox" fields
     * 
     * @private
     * @ignore
     * @param {object} field
     * @param {boolean} value - Checkbox values
     * @returns {string} Html for the value
     */
    _rendererForCheckboxFields(field, value) {
        const shape = field.shape || "square"
        const iconColorOn = field.iconColorOn || "#000000"

        try {
            if (field.type == "lookup") {
                const linkId = field.lookup.linkId
                const linkField = this.model.getField(linkId)
                const foreignModelId = linkField.link.modelId
                const foreignModel = kiss.app.models[foreignModelId]
                const sourceField = foreignModel.getField(field.lookup.fieldId)
                shape = sourceField.shape
                iconColorOn = sourceField.iconColorOn
            }
        } catch (err) {
            log("kiss.ui - Calendar - Couldn't generate renderer for checkboxes", 4)
            return value
        }

        const iconClasses = kiss.ui.Checkbox.prototype.getIconClasses()
        const defaultIconOn = iconClasses[shape]["on"]
        const defaultIconOff = iconClasses[shape]["off"]
        return `<span ${(value === true) ? `style="color: ${iconColorOn}"` : ""} class=\"${(value === true) ? defaultIconOn + " datatable-type-checkbox-checked" : defaultIconOff + " datatable-type-checkbox-unchecked"}\"></span>`
    }

    /**
     * Renderer for "Rating" fields
     * 
     * @private
     * @ignore
     * @param {object} field
     * @param {string|string[]} values - Select field values.
     * @returns {string} Html for the value
     */
    _rendererForRatingFields(field, value) {
        const iconColorOn = field.iconColorOn || "#ffd139"
        const iconColorOff = field.iconColorOff || "#dddddd"
        const shape = field.shape || "star"
        const iconClasses = kiss.ui.Rating.prototype.getIconClasses()
        const icon = iconClasses[shape]
        const max = field.max || 5

        let html = ""
        for (let i = 0; i < max; i++) {
            const color = (i < value) ? iconColorOn : iconColorOff
            html += /*html*/ `<span class="rating ${icon}" style="color: ${color}" index=${i}></span>`
        }
        return html
    }

    /**
     * Renderer for "Select" fields
     * 
     * @private
     * @ignore
     * @param {object} field
     * @param {string|string[]} values - Select field values.
     * @returns {string} Html for the value
     */
    _rendererForSelectFields(field, values) {
        const options = (typeof field.options == "function") ? field.options() : field.options

        // If no options, returns default layout
        if (!options) {
            return [].concat(values).map(value => {
                if (!value) return ""
                return `<span class="field-select-value">${value}</span>`
            }).join("")
        }

        // If options, returns values with the right option colors
        return [].concat(values).map(value => {
            if (!value) return ""

            let option = options.find(option => option.value === value)

            if (!option) option = {
                value
            }

            if (!option.value || option.value == " ") return ""

            return `<span class="field-select-value" ${(option.color) ? `style="color: #ffffff; background-color: ${option.color}"` : ""}>${option.value}</span>`
        }).join("")
    }

    /**
     * Render for "Directory" fields
     * 
     * @private
     * @ignore
     * @param {boolean} values
     */
    _rendererForDirectoryFields(values) {
        let listOfNames = "-"
        if (values) {
            listOfNames = [].concat(values).map(value => {
                if (!value) return ""

                let name
                switch (value) {
                    case "*":
                        name = kiss.directory.roles.everyone.label
                        break
                    case "$authenticated":
                        name = kiss.directory.roles.authenticated.label
                        break
                    case "$creator":
                        name = kiss.directory.roles.creator.label
                        break
                    case "$nobody":
                        name = kiss.directory.roles.nobody.label
                        break
                    default:
                        name = kiss.directory.getEntryName(value)
                }

                return (name) ? name : ""
            }).join(", ")
        }

        return listOfNames
    }

    /**
     * Update a single record then reload the view if required.
     * 
     * @private
     * @ignore
     * @param {object} msgData - The original pubsub message
     */
    async _updateOneAndReload(msgData) {
        const filterFields = kiss.db.mongo.getFilterFields(this.filter)
        let dateHasChanged = false
        let timeHasChanged = false
        let filterHasChanged = false

        let updates = msgData.data
        for (let fieldId of Object.keys(updates)) {
            if (this.dateField == fieldId) dateHasChanged = true
            if (this.timeField == fieldId) timeHasChanged = true
            if (filterFields.indexOf(fieldId) != -1) filterHasChanged = true
        }

        if (dateHasChanged || timeHasChanged || filterHasChanged) {
            this._reloadWhenNeeded(msgData, 2000)
        }
        else {
            this._updateRecord(msgData.id)
        }
    }

    /**
     * Update a single record of the calendar.
     * 
     * Does nothing if the record is not displayed on the active page.
     * 
     * @private
     * @ignore
     * @param {string} recordId 
     */
    _updateRecord(recordId) {
        const record = this.collection.getRecord(recordId)
        const recordNode = document.querySelector(`.calendar-record[recordid="${recordId}"]`)

        if (recordNode) {
            const replacementNode = document.createElement("div")
            replacementNode.classList.add("calendar-record")
            replacementNode.style.borderColor = this.model.color
            replacementNode.innerHTML = (this.period.includes("details")) ? this._renderRecordAsCard(record) : this._renderRecord(record)
            recordNode.parentNode.replaceChild(replacementNode, recordNode)
            replacementNode.setAttribute("recordid", recordId)
        }
    }

    /**
     * Update the calendar configuration
     * 
     * @private
     * @ignore
     * @param {object} newConfig 
     */
    async _updateConfig(newConfig) {
        if (newConfig.hasOwnProperty("dateField")) this.dateField = newConfig.dateField
        if (newConfig.hasOwnProperty("timeField")) this.timeField = newConfig.timeField
        if (newConfig.hasOwnProperty("period")) this.period = newConfig.period
        if (newConfig.hasOwnProperty("startOnMonday")) this.startOnMonday = newConfig.startOnMonday
        if (newConfig.hasOwnProperty("showWeekend")) this.showWeekend = newConfig.showWeekend

        this._initTexts()
        this._render()

        let currentConfig
        if (this.record) {
            currentConfig = this.record.config
        }
        else {
            currentConfig = {
                dateField: this.dateField,
                timeField: this.timeField,
                period: this.period,
                startOnMonday: this.startOnMonday,
                showWeekend: this.showWeekend,
                columns: this.columns
            }
        }

        let config = Object.assign(currentConfig, newConfig)
        await this.updateConfig({
            config
        })
    }

    /**
     * Render the toolbar
     * 
     * @private
     * @ignore
     */
    _renderToolbar() {
        if (this.isToolbarRendered) return
        this.isToolbarRendered = true

        // New record creation button
        createButton({
            hidden: !this.canCreateRecord,
            class: "calendar-create-record",
            target: "create:" + this.id,
            text: this.config.createRecordText || this.model.name.toTitleCase(),
            icon: "fas fa-plus",
            iconColor: this.color,
            borderWidth: "3px",
            borderRadius: "32px",
            action: async () => this.createRecord(this.model)
        }).render()

        // Setup date/time button
        createButton({
            hidden: !this.showSetup,
            target: "setup:" + this.id,
            tip: txtTitleCase("setup the calendar"),
            icon: "fas fa-cog",
            iconColor: this.color,
            width: 32,
            action: () => this.showSetupWindow()
        }).render()

        // Column selection button
        createButton({
            hidden: !this.canSelectFields,
            target: "select:" + this.id,
            tip: txtTitleCase("#display fields"),
            icon: "fas fa-bars fa-rotate-90",
            iconColor: this.color,
            width: 32,
            action: () => this.showFieldsWindow()
        }).render()

        // Filtering button
        createButton({
            hidden: !this.canFilter,
            target: "filter:" + this.id,
            tip: txtTitleCase("to filter"),
            icon: "fas fa-filter",
            iconColor: this.color,
            width: 32,
            action: () => this.showFilterWindow()
        }).render()

        // Pager display mode
        if (this.canChangePeriod) {
            let _this = this
            createSelect({
                target: "pager-mode:" + this.id,
                id: "pager-mode:" + this.id,
                options: [{
                        label: txtTitleCase("month"),
                        value: "month"
                    },
                    {
                        label: "3 " + txtTitleCase("weeks"),
                        value: "3 weeks"
                    },
                    {
                        label: "2 " + txtTitleCase("weeks"),
                        value: "2 weeks"
                    },
                    {
                        label: "1 " + txtTitleCase("week"),
                        value: "1 week"
                    },
                    {
                        label: "1 " + txtTitleCase("week") + " + " + txtTitleCase("details"),
                        value: "1 week + details"
                    }
                ],
                optionsColor: this.color,
                value: this.period || "month",
                fieldWidth: 150,
                styles: {
                    "this": "align-items: center;",
                    "field-label": "white-space: nowrap;",
                    "field-select": "white-space: nowrap;",
                },
                events: {
                    change: async function () {
                        _this.period = this.getValue()
                        _this._render()
                    }
                }
            }).render()
        }

        // Pager previous
        createButton({
            target: "pager-previous:" + this.id,
            icon: "fas fa-chevron-left",
            iconColor: this.color,
            width: 32,
            events: {
                click: () => {
                    let previousDate
                    switch (this.period) {
                        case "month":
                            previousDate = kiss.formula.ADJUST_DATE(this.date, 0, -1, 0, 0, 0, 0)
                            break
                        case "3 weeks":
                            previousDate = kiss.formula.ADJUST_DATE(this.startDate, 0, 0, -21, 0, 0, 0)
                            break
                        case "2 weeks":
                            previousDate = kiss.formula.ADJUST_DATE(this.startDate, 0, 0, -14, 0, 0, 0)
                            break
                        case "1 week":
                        case "1 week + details":
                            previousDate = kiss.formula.ADJUST_DATE(this.startDate, 0, 0, -7, 0, 0, 0)
                            break
                        default:
                    }
                    this.showCalendar(previousDate)
                }
            }
        }).render()

        // Pager next
        createButton({
            target: "pager-next:" + this.id,
            icon: "fas fa-chevron-right",
            iconColor: this.color,
            width: 32,
            events: {
                click: () => {
                    let nextDate
                    switch (this.period) {
                        case "month":
                            nextDate = kiss.formula.ADJUST_DATE(this.date, 0, 1, 0, 0, 0, 0)
                            break
                        default:
                            nextDate = kiss.formula.ADJUST_DATE(this.endDate, 0, 0, 1, 0, 0, 0)
                    }
                    this.showCalendar(nextDate)
                }
            }
        }).render()

        // Pager today
        const todayButton = {
            target: "pager-today:" + this.id,
            icon: "fas fa-stop",
            iconColor: this.color,
            events: {
                click: () => {
                    let currentDate = new Date()
                    let startDate = currentDate
                    switch (this.period) {
                        case "month":
                            startDate.setDate(1)
                            break
                        default:
                            startDate = this._getPreviousMonday(currentDate)
                    }
                    this.showCalendar(startDate)
                }
            }
        }
        if (!kiss.screen.isMobile) todayButton.text = txtTitleCase("today")
        createButton(todayButton).render()

        // View refresh button
        if (!kiss.screen.isMobile) {
            createButton({
                target: "refresh:" + this.id,
                tip: txtTitleCase("refresh"),
                icon: "fas fa-undo-alt",
                iconColor: this.color,
                width: 32,
                events: {
                    click: () => this.reload()
                }
            }).render()
        }
    }

    /**
     * Render the title inside the toolbar
     * 
     * @private
     * @ignore
     */
    _renderToolbarTitle() {
        let title
        this.startDay = this.startDate.getDate()
        this.startMonth = this.startDate.getMonth()
        this.startYear = this.startDate.getFullYear()
        this.endDay = new Date(kiss.formula.ADJUST_DATE(this.endDate, 0, 0, -1, 0, 0, 0))
        this.endMonth = this.endDay.getMonth()
        this.endYear = this.endDay.getFullYear()
        this.endDay = this.endDay.getDate()

        switch (this.period) {
            case "month":
                title = this.months[this.date.getMonth()].toUpperCase() + " " + this.date.getFullYear()
                break
            default:
                title =
                    String(this.startDay).padStart(2, "0") +
                    " " +
                    this.months[this.startMonth] +
                    " - " +
                    String(this.endDay).padStart(2, "0") +
                    " " +
                    this.months[this.endMonth] +
                    " " +
                    ((new Date().getFullYear() != this.endYear) ? this.endYear : "")
        }

        this.calendarTitle = title
        $("title:" + this.id).innerHTML = this.calendarTitle
    }

    /**
     * Get the previous monday
     * 
     * @private
     * @ignore
     */
    _getPreviousMonday(date) {
        let newDate = new Date(date)
        let dayOfWeek = date.getDay()
        if (dayOfWeek !== 1) newDate.setDate(date.getDate() - ((dayOfWeek === 0) ? 6 : dayOfWeek - 1))
        return newDate
    }

    /**
     * Get the previous sunday
     * 
     * @private
     * @ignore
     */
    _getPreviousSunday(date) {
        let newDate = new Date(date)
        let dayOfWeek = date.getDay()
        if (dayOfWeek !== 0) newDate.setDate(date.getDate() - dayOfWeek)
        return newDate
    }

    /**
     * Init the localized texts
     * 
     * @private
     * @ignore
     * @returns this
     */
    _initTexts() {
        this.months = [
            txtTitleCase("january"),
            txtTitleCase("february"),
            txtTitleCase("march"),
            txtTitleCase("april"),
            txtTitleCase("may"),
            txtTitleCase("june"),
            txtTitleCase("july"),
            txtTitleCase("august"),
            txtTitleCase("september"),
            txtTitleCase("october"),
            txtTitleCase("november"),
            txtTitleCase("december")
        ]

        this.weekDays = [
            txtTitleCase("sunday"),
            txtTitleCase("monday"),
            txtTitleCase("tuesday"),
            txtTitleCase("wednesday"),
            txtTitleCase("thursday"),
            txtTitleCase("friday"),
            txtTitleCase("saturday")
        ]

        if (this.showWeekend === false) {
            this.weekDays.shift()
            this.weekDays.pop()
        }

        if (this.showWeekend && this.startOnMonday) {
            let lastElement = this.weekDays.shift()
            this.weekDays.push(lastElement)
        }

        if (kiss.screen.isMobile && kiss.screen.isVertical()) {
            this.months = this.months.map(month => month.substring(0, 4) + ".")
        }

        return this
    }

    /**
     * Generates a simple day id
     * 
     * @private
     * @ignore
     * @param {number} day
     * @param {number} month
     * @param {number} year
     * @returns {string} String like "2023-07-17"
     */
    _getDayId(day, month, year) {
        return year + "-" + (month + "").padStart(2, "0") + "-" + (day + "").padStart(2, "0")
    }
}

// Create a Custom Element and add a shortcut to create it
customElements.define("a-calendar", kiss.ui.Calendar)

/**
 * Shorthand to create a new Calendar. See [kiss.ui.Calendar](kiss.ui.Calendar.html)
 * 
 * @param {object} config
 * @returns HTMLElement
 */
const createCalendar = (config) => document.createElement("a-calendar").init(config)

;