gitlab-time-report 1.3.0

Library to generate statistics and charts from GitLab time tracking data.
Documentation
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8"/>
    <meta content="width=device-width, initial-scale=1.0" name="viewport"/>
    <title>$main_title</title>
    <link href="data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyBpZD0iRWJlbmVfMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB2ZXJzaW9uPSIxLjEiIHZpZXdCb3g9IjAgMCAzMiAzMiI+CiAgPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDI5LjguMywgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDIuMS4xIEJ1aWxkIDMpICAtLT4KICA8ZGVmcz4KICAgIDxzdHlsZT4KICAgICAgLnN0MCB7CiAgICAgICAgZmlsbDogI2ZmZjsKICAgICAgfQoKICAgICAgLnN0MSB7CiAgICAgICAgZmlsbDogIzFlMWYyOTsKICAgICAgICBzdHJva2U6ICMxZTFmMjk7CiAgICAgICAgc3Ryb2tlLWxpbmVqb2luOiByb3VuZDsKICAgICAgICBzdHJva2Utd2lkdGg6IDEuNXB4OwogICAgICB9CiAgICA8L3N0eWxlPgogIDwvZGVmcz4KICA8Y2lyY2xlIGNsYXNzPSJzdDAiIGN4PSIxNS44MyIgY3k9IjE1Ljg2IiByPSIxNS4zOSIvPgogIDxwYXRoIGNsYXNzPSJzdDEiIGQ9Ik0xMy41MywxNi41OWw0LjItNy4yM2MtMy4yOS0xLjkxLTcuNTctMS4zOS0xMC4yOCwxLjUtMy4xNywzLjM2LTMuMDEsOC42Ni4zNSwxMS44MywzLjM2LDMuMTcsOC42NiwzLjAxLDExLjgzLS4zNS41OC0uNjIsMS4wNS0xLjMsMS40MS0yLjAybC03LjUxLTMuNzNaIi8+CiAgPHBhdGggY2xhc3M9InN0MSIgZD0iTTE4LjcsMTQuNjRsNi42OCwzLjMyYzEuMTctMy4wNy40Ni02LjY3LTIuMDgtOS4wNy0uMjctLjI1LS41Ni0uNDgtLjg1LS42OWwtMy43NCw2LjQ0WiIvPgo8L3N2Zz4="
          rel="icon"
          type="image/x-icon">
    <style>
        :root {
            --font-size: 15px;
            --bg-color: #f5f6fa;
            --bg-color-light: #fff;
            --shadow-color: rgb(66, 68, 90);
            --text-color: #1e1f29;
            --gap: 20px;
            --border-color: #ddd;
        }

        @media (prefers-color-scheme: dark) {
            :root {
                --bg-color: #282f3c;
                --bg-color-light: #1e2631;
                --shadow-color: rgb(66, 68, 90, 0);
                --text-color: #e5e7ef;
                --border-color: var(--text-color);
            }
        }

        * {
            font-family: system-ui;
            box-sizing: border-box;
            color: var(--text-color);
        }

        body {
            background-color: var(--bg-color);
            font-size: var(--font-size);
        }

        h2 {
            font-size: 1.1rem;
        }

        table {
            border-collapse: collapse;
            width: 100%;
        }

        td, th {
            border: 1px solid var(--border-color);
            padding: var(--gap) calc(var(--gap) / 2);
            border-right: none;
            border-left: none;
        }

        th {
            text-align: left;
            text-transform: uppercase;
            cursor: pointer;
            white-space: nowrap;
        }

        th::after {
            content: "⇅";
            width: 1.2em;
            display: inline-block;
            opacity: 0.35;
            padding-left: 10px;
        }

        th:hover::after {
            opacity: 1;
        }

        th[data-sort-dir="asc"]::after {
            content: "▲";
            opacity: 1;
        }

        th[data-sort-dir="desc"]::after {
            content: "▼";
            opacity: 1;
        }

        tfoot {
            font-weight: bold;
        }

        h1 {
            margin: 0 0 5px;
        }

        header, .bg_section {
            margin: var(--gap);
        }

        header, .bg_section, .chart_elem > div {
            padding: var(--gap);
            border-radius: 8px;
            box-shadow: 1px 1px 15px -14px var(--shadow-color);
            background-color: var(--bg-color-light);
        }

        .table-wrapper {
            width: 100%;
            overflow-x: auto;
        }

        .table-no-data {
            text-align: center;
            font-weight: bold;
        }

        .chart_elem, .flex-container {
            display: grid;
            grid-template-columns: repeat(auto-fit, minmax(600px, 1fr));
            justify-content: stretch;
            gap: var(--gap);
            margin: var(--gap);
        }

        .chart_elem > div {
            width: 100%;
            height: clamp(350px, 70vh, 600px);
        }

        .flex-container > section {
            margin: 0;
        }

        .timestamp {
            visibility: hidden;
        }

        @media (max-width: 768px) {
            :root {
                --font-size: 14px;
                --gap: 15px;
            }

            .chart_elem,
            .flex-container {
                grid-template-columns: 1fr;
            }

            .chart_elem > div {
                height: 130vw;
            }

            .flex-container {
                display: block;
            }

            .flex-container .bg_section:first-of-type {
                margin-bottom: var(--gap);
            }
        }
    </style>
</head>
<body>
<header>
    <h1>$main_title</h1>
    <span>Data generated on <span class="timestamp">$timestamp</span></span>
</header>
<main>
    <section>
        <div class="chart_elem">
            $charts_divs
        </div>
    </section>
    <section class="bg_section">
        <h2>$sub_title_time_per_user</h2>
        <div class="table-wrapper">
            $table_time_per_user
        </div>
    </section>
    <section class="bg_section">
        <h2>$sub_title_time_logs_today</h2>
        <div class="table-wrapper">
            $table_time_logs_today
        </div>
    </section>
    <div class="flex-container">
        <section class="bg_section">
            <h2>$sub_title_time_per_label</h2>
            <div class="table-wrapper">
                $table_time_per_label
            </div>
        </section>
        <section class="bg_section">
            <h2>$sub_title_time_per_milestone</h2>
            <div class="table-wrapper">
                $table_time_per_milestone
            </div>
        </section>
    </div>
</main>
<script>
    function convertTimestampToLocal() {
        const mainLanguage = navigator.languages[0];
        const format = {
            year: 'numeric',
            month: '2-digit',
            day: '2-digit',
            hour: '2-digit',
            minute: '2-digit',
            timeZoneName: 'short'
        };

        const timestamps = document.querySelectorAll('.timestamp');
        for (const timestamp of timestamps) {
            const old_timestamp = timestamp.textContent;
            const date = new Date(old_timestamp);

            timestamp.textContent = date.toLocaleString(mainLanguage, format);
            timestamp.style.visibility = "visible";
        }
    }

    function makeTablesSortable(selector = 'table') {
        const HM_REGEX = /^(\d+)h\s+(\d{2})m$/;

        function getRawCellValue(row, columnIndex) {
            const cell = row.children[columnIndex];
            if (!cell) return "";
            return (cell.dataset.sortValue || cell.textContent).trim();
        }

        function toMinutes(raw) {
            const match = raw.match(HM_REGEX);
            if (!match) return 0;
            const hours = parseInt(match[1], 10);
            const minutes = parseInt(match[2], 10);
            return hours * 60 + minutes;
        }

        function detectColumnType(rows, columnIndex) {
            if (rows.length === 0) return "";
            return rows.every(row => HM_REGEX.test(getRawCellValue(row, columnIndex)))
                ? "duration"
                : "string";
        }

        document.querySelectorAll(selector).forEach(function (table) {
            const thead = table.tHead;
            const tbody = table.tBodies[0];
            if (!thead || !tbody) return;

            const headers = thead.querySelectorAll('th');

            headers.forEach(function (currentHeader, columnIndex) {
                currentHeader.setAttribute('aria-sort', 'none');

                currentHeader.addEventListener('click', function () {
                    const rows = Array.from(tbody.querySelectorAll('tr'));
                    if (rows.length === 0) return;

                    const columnType = detectColumnType(rows, columnIndex);

                    const currentDirection = currentHeader.dataset.sortDir === 'asc' ? 'desc' : 'asc';

                    // Reset all other headers
                    headers.forEach(function (header) {
                        if (header !== currentHeader) {
                            header.removeAttribute('data-sort-dir');
                            header.setAttribute('aria-sort', 'none');
                        }
                    });

                    currentHeader.dataset.sortDir = currentDirection;
                    currentHeader.setAttribute(
                        'aria-sort',
                        currentDirection === 'asc' ? 'ascending' : 'descending'
                    );

                    rows.sort(function (rowA, rowB) {
                        const rawA = getRawCellValue(rowA, columnIndex);
                        const rawB = getRawCellValue(rowB, columnIndex);

                        if (columnType === "duration") {
                            const a = toMinutes(rawA);
                            const b = toMinutes(rawB);
                            return currentDirection === "asc" ? a - b : b - a;
                        }
                        return currentDirection === "asc"
                            ? rawA.localeCompare(rawB, undefined, {numeric: true})
                            : rawB.localeCompare(rawA, undefined, {numeric: true});
                    });

                    // Re-attach rows in sorted order
                    rows.forEach(function (row) {
                        tbody.appendChild(row);
                    });
                });
            });
        });
    }

    function resizeCharts() {
        if (!window.echarts) return;

        const charts = document.querySelectorAll('.chart_elem > div');
        charts.forEach(function (el) {
            const chart = echarts.getInstanceByDom(el);
            if (chart) {
                chart.resize();
            }
        });
    }

    let resizeTimeout;
    window.addEventListener('resize', function () {
        clearTimeout(resizeTimeout);
        resizeTimeout = setTimeout(resizeCharts, 300);
    });


    function setColorTheme(theme) {
        if (theme) {
            return theme;
        }
        const prefersDarkScheme =
            window.matchMedia &&
            window.matchMedia('(prefers-color-scheme: dark)').matches;

        return prefersDarkScheme ? 'dark' : '';
    }

    function getChartBackgroundColor() {
        return getComputedStyle(document.documentElement)
            .getPropertyValue('--bg-color-light')
            .trim();
    }

    convertTimestampToLocal();
    makeTablesSortable("table");
</script>
$external_script_tags
<script>
    $charts_js
</script>
</body>
</html>