HEX
Server: LiteSpeed
System: Linux houston.panomity.com 6.8.0-100-generic #100-Ubuntu SMP PREEMPT_DYNAMIC Tue Jan 13 16:40:06 UTC 2026 x86_64
User: nudepix (1011)
PHP: 7.4.33
Disabled: pcntl_alarm,pcntl_fork,pcntl_waitpid,pcntl_wait,pcntl_wifexited,pcntl_wifstopped,pcntl_wifsignaled,pcntl_wifcontinued,pcntl_wexitstatus,pcntl_wtermsig,pcntl_wstopsig,pcntl_signal,pcntl_signal_get_handler,pcntl_signal_dispatch,pcntl_get_last_error,pcntl_strerror,pcntl_sigprocmask,pcntl_sigwaitinfo,pcntl_sigtimedwait,pcntl_exec,pcntl_getpriority,pcntl_setpriority,pcntl_async_signals,pcntl_unshare,
Upload Files
File: /home/timetracker.panomity.com/assets/js/plugins/KimaiActiveRecords.js
/*
 * This file is part of the Kimai time-tracking app.
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

/*!
 * [KIMAI] KimaiActiveRecords: responsible to display the users active records
 */

import KimaiPlugin from '../KimaiPlugin';

export default class KimaiActiveRecords extends KimaiPlugin {

    constructor()
    {
        super();
        this._selector = '.ticktac-menu';
        this._selectorEmpty = '.ticktac-menu-empty';
        this._favIconUrl = null;
    }

    /**
     * @returns {string}
     */
    getId()
    {
        return 'active-records';
    }

    init()
    {
        // the menu can be hidden if user has no permissions to see it
        if (document.querySelector(this._selector) === null) {
            return;
        }

        const handleUpdate = () => {
            this.reloadActiveRecords();
        };

        document.addEventListener('kimai.timesheetUpdate', handleUpdate);
        document.addEventListener('kimai.timesheetDelete', handleUpdate);
        document.addEventListener('kimai.activityUpdate', handleUpdate);
        document.addEventListener('kimai.activityDelete', handleUpdate);
        document.addEventListener('kimai.projectUpdate', handleUpdate);
        document.addEventListener('kimai.projectDelete', handleUpdate);
        document.addEventListener('kimai.customerUpdate', handleUpdate);
        document.addEventListener('kimai.customerDelete', handleUpdate);

        // -----------------------------------------------------------------------
        // handle duration in the visible UI
        this._updateBrowserTitle = !!this.getConfiguration('updateBrowserTitle');

        const handle = () => {
            this._updateDuration();
        };
        this._updatesHandler = setInterval(handle, 10000);
        document.addEventListener('kimai.timesheetUpdate', handle);
        document.addEventListener('kimai.reloadedContent', handle);
    }

    // TODO we could unregister all handler and listener
    // _unregisterHandler() {
    //     clearInterval(this._updatesHandler);
    // }

    /**
     * Updates the duration of all running entries, both in the ticktac menus and in the listing pages.
     *
     * @private
     */
    _updateDuration()
    {
        // needs to search in document, to find all running entries, both in "ticktac" and listing pages
        const activeRecords = document.querySelectorAll('[data-since]:not([data-since=""])');

        if (this._updateBrowserTitle) {
            this._changeFavicon(activeRecords.length > 0);
        }

        if (activeRecords.length === 0) {
            if (this._updateBrowserTitle) {
                if (document.body.dataset['title'] === undefined) {
                    this._updateBrowserTitle = false;
                } else {
                    document.title = document.body.dataset['title'];
                }
            }
            return;
        }

        const DATE = this.getDateUtils();
        let durations = [];

        for (const record of activeRecords) {
            const duration = DATE.formatDuration(record.dataset['since']);
            // only use the ones from the menu for the title
            if (record.dataset['replacer'] !== undefined && record.dataset['title'] !== null && duration !== '?') {
                durations.push(duration);
            }
            // but update all on the page (running entries in list pages)
            record.textContent = duration;
        }

        if (durations.length === 0) {
            return;
        }

        if (this._updateBrowserTitle) {
            // only show the first found record, even if we have more
            document.title = durations.shift();
        }
    }

    /**
     * Adapts the ticktac menus according to the given entries (amount and duration).
     * Does not influence listing pages, as those refresh themselves.
     *
     * @param {array} entries
     * @private
     */
    _setEntries(entries)
    {
        const hasEntries = entries.length > 0;

        // these contain the "start" button
        for (let menuEmpty of document.querySelectorAll(this._selectorEmpty)) {
            menuEmpty.style.display = !hasEntries ? 'inline-block' : 'none';
        }

        // and they contain the "stop" button
        for (let menu of document.querySelectorAll(this._selector)) {
            menu.style.display = hasEntries ? 'inline-block' : 'none';
            if (!hasEntries) {
                // make sure that template entries in the menu are removed, otherwise they
                // might still be shown in the browsers title
                for (let record of menu.querySelectorAll('[data-since]')) {
                    record.dataset['since'] = '';
                }
            }

            const stop = menu.querySelector('.ticktac-stop');

            if (!hasEntries) {
                if (stop) {
                    stop.accesskey = null;
                }
                continue;
            }

            if (stop) {
                stop.accesskey = 's';
            }
            this._replaceInNode(menu, entries[0]);
        }

        this._updateDuration();
    }

    /**
     * @param {HTMLElement} node
     * @param {object} timesheet
     * @private
     */
    _replaceInNode(node, timesheet)
    {
        const date = this.getDateUtils();
        const allReplacer = node.querySelectorAll('[data-replacer]');
        for (let link of allReplacer) {
            const replacerName = link.dataset['replacer'];
            if (replacerName === 'url') {
                link.dataset['href'] = node.dataset['href'].replace('000', timesheet.id);
            } else if (replacerName === 'activity') {
                link.innerText = timesheet.activity.name;
            } else if (replacerName === 'project') {
                link.innerText = timesheet.project.name;
            } else if (replacerName === 'customer') {
                link.innerText = timesheet.project.customer.name;
            } else if (replacerName === 'duration') {
                link.dataset['since'] = timesheet.begin;
                link.innerText = date.formatDuration(timesheet.duration);
            }
        }
    }

    reloadActiveRecords()
    {
        /** @type {KimaiAPI} API */
        const API = this.getContainer().getPlugin('api');

        // TODO using the first found "ticktac" menu is working, but can be done better
        const apiUrl = document.querySelector(this._selector).dataset['api'];

        API.get(apiUrl, {}, (result) => {
            this._setEntries(result);
        });
    }

    /**
     * @param {boolean} running
     * @private
     */
    _changeFavicon(running)
    {
        const canvas = document.createElement('canvas');
        const orig = document.getElementById('favicon');
        if (this._favIconUrl === null) {
            this._favIconUrl = orig.href;
        }
        const link = orig.cloneNode(true);

        if (canvas.getContext && link) {
            const ratio = window.devicePixelRatio;
            const img = document.createElement('img');
            canvas.height = canvas.width = 16 * ratio;
            img.onload = function () {
                const ctx = canvas.getContext('2d');
                ctx.drawImage(this, 0, 0, canvas.width, canvas.height);
                if (running) {
                    const width = 5.5 * ratio;
                    ctx.fillStyle = 'rgb(182,57,57)';
                    ctx.fillRect((canvas.width / 2) - (width / 2), (canvas.height / 2) - (width / 2), width, width);
                }
                link.href = canvas.toDataURL('image/png');
                orig.remove();
                document.head.appendChild(link);
            };
            img.src = this._favIconUrl;
        }
    }
}