Fri, 06 Feb 2026 18:44:47 +0100
add monthly summaries - resolves #699
/* Copyright 2025 Mike Becker. All rights reserved. * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: * * 1. Redistributions of source code must retain the above copyright * notice, this list of conditions and the following disclaimer. * * 2. Redistributions in binary form must reproduce the above copyright * notice, this list of conditions and the following disclaimer in the * documentation and/or other materials provided with the distribution. * * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ #include "html.h" #include <ranges> #include <cstdio> #include <cassert> using namespace std::chrono; namespace html { static constexpr const char* weekdays[] = {"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"}; static unsigned indentation; static const char *tabs = " "; static void indent(int change = 0) { indentation += change; assert(indentation <= max_indentation); fwrite(tabs, 4, indentation, stdout); } static std::string encode(const std::string &data) { std::string buffer; buffer.reserve(data.size()+16); for (const char &pos: data) { switch (pos) { case '&': buffer.append("&"); break; case '\"': buffer.append("""); break; case '\'': buffer.append("'"); break; case '<': buffer.append("<"); break; case '>': buffer.append(">"); break; case '#': buffer.append("#"); break; default: buffer.append(&pos, 1); break; } } return buffer; } static std::string escape_json(const std::string &raw) { using std::string_view_literals::operator ""sv; auto replace_all = [](std::string str, char chr, std::string_view repl) static { size_t pos = str.find(chr); if (pos == std::string::npos) return str; std::string result = std::move(str); do { result.replace(pos, 1, repl); pos += repl.length(); } while ((pos = result.find(chr, pos)) != std::string::npos); return result; }; return replace_all(replace_all(raw, '\\', "\\\\"), '\"', "\\\""sv); } static std::string build_tag_list(fm::tag_lists tags, bool hide_repo_names) { std::string tags_json; if (hide_repo_names) { for (const auto &tags_vector: tags | std::views::values) { for (const auto &tag: tags_vector) { tags_json += escape_json(tag); tags_json += ' '; } } if (!tags_json.empty()) { tags_json.pop_back(); } } else { tags_json += '{'; for (const auto &[repo, tags_vector] : tags) { tags_json += "\"" + escape_json(repo) + "\":\""; for (const auto &tag: tags_vector) { tags_json += escape_json(tag); tags_json += ' '; } if (!tags_vector.empty()) { tags_json.pop_back(); } tags_json += "\","; } tags_json.pop_back(); if (!tags.empty()) { tags_json += '}'; } } return tags_json; } static std::string build_tag_array(fm::tag_lists tags, bool hide_repo_names) { std::string tags_json; if (hide_repo_names) { tags_json += '['; for (const auto &tags_vector: tags | std::views::values) { for (const auto &tag: tags_vector) { tags_json += '"'; tags_json += escape_json(tag); tags_json += "\","; } } if (!tags.empty()) { tags_json.pop_back(); } tags_json += ']'; } else { tags_json += '{'; for (const auto &[repo, tags_vector] : tags) { tags_json += "\"" + escape_json(repo) + "\":["; for (const auto &tag: tags_vector) { tags_json += '"'; tags_json += escape_json(tag); tags_json += "\","; } if (!tags_vector.empty()) { tags_json.pop_back(); } tags_json += "],"; } tags_json.pop_back(); if (!tags.empty()) { tags_json += '}'; } } return tags_json; } } void html::styles_and_script() { puts(R"( <style> table.heatmap { table-layout: fixed; border-collapse: separate; border-spacing: 2px; font-family: sans-serif; } table.heatmap td, table.heatmap th { text-align: center; font-size: 0.75rem; border: solid 4px transparent; border-radius: 3px; height: 1rem; } table.heatmap td { width: 1rem; } table.heatmap td:hover, table.heatmap td.popup-open { filter: hue-rotate(90deg); } table.heatmap th:hover[data-total], table.heatmap th.popup-open { background-color: #B3E7F2; } table.heatmap td.out-of-range { background-color: gray; } table.heatmap td.zero-commits { background-color: #E3E3E3; } table.heatmap td.one-commit { background-color: #80E7A0; } table.heatmap td.up-to-5-commits { background-color: #30D350; } table.heatmap td.up-to-10-commits { background-color: #00BF00; } table.heatmap td.up-to-20-commits { background-color: #00A300; } table.heatmap td.commit-spam { background-color: #008000; } table.heatmap td[data-tags]:not([data-tags=""]) { border-color: gold; } /* Popup styles */ .commit-popup { position: absolute; background-color: #fff; border: 1px solid #ccc; border-radius: 4px; box-shadow: 0 2px 10px rgba(0,0,0,0.2); padding: .2rem; z-index: 1000; width: 40ch; font-family: sans-serif; font-size: smaller; display: none; word-wrap: break-word; } .commit-popup h3 { margin-top: 0; border-bottom: 1px solid #eee; padding-bottom: 5px; } .commit-popup h4 { margin-top: .5em; margin-bottom: .5em; } .commit-popup h5 { margin-top: 0; } .commit-popup ul { margin: 0; padding-left: 20px; } .commit-popup li { margin-bottom: 5px; } .commit-popup .close-btn { position: absolute; top: 5px; right: 8px; cursor: pointer; font-weight: bold; } </style> <script> document.addEventListener('DOMContentLoaded', function() { // Create popup element const popup = document.createElement('div'); popup.className = 'commit-popup'; document.body.appendChild(popup); // Add click event listeners to all commit cells const cells = document.querySelectorAll('table.heatmap td:not(.out-of-range, .zero-commits), table.heatmap th:not(.zero-commits)'); cells.forEach(cell => { cell.addEventListener('click', function(e) { const date = this.dataset.date; // Create popup content let content = '<span class="close-btn">×</span>'; if (date === undefined) { // monthly summary const total_commits = this.dataset.total; content += `<h3>Total: ${total_commits} commit${total_commits !== 1 ? 's' : ''}</h3>`; if (this.dataset.commits === undefined) { const tags = this.dataset.tags ? JSON.parse(this.dataset.tags) : []; if (tags.length === 0) { content += 'No tags.'; } else { content += '<ul>'; tags.forEach(tag => { content += `<li>${tag}</li>`; }); content += '</ul>'; } } else { content += '<ul>'; const commits = JSON.parse(this.dataset.commits); const repos = Object.keys(commits).sort(); const tags = this.dataset.tags ? JSON.parse(this.dataset.tags) : {}; for (const repo of repos) { content += `<li>${repo} (${commits[repo]} commit${commits[repo] !== 1 ? 's' : ''})</li>`; if (tags[repo] && tags[repo].length > 0) { content += '<ul>'; tags[repo].forEach(tag => { content += `<li>${tag}</li>`; }); content += '</ul>'; } } content += '</ul>'; } } else { const summaries = JSON.parse(this.dataset.summaries); if (Array.isArray(summaries)) { const summaries = this.dataset.summaries ? JSON.parse(this.dataset.summaries) : undefined; content += `<h3>${date}: ${summaries.length} commit${summaries.length !== 1 ? 's' : ''}</h3>`; if (this.dataset.tags) { content += `<h5>Tags: ${this.dataset.tags}</h5>`; } content += '<ul>'; summaries.forEach(summary => { content += `<li>${summary}</li>`; }); content += '</ul>'; } else { const repos = Object.keys(summaries).sort(); const tags = this.dataset.tags ? JSON.parse(this.dataset.tags) : {}; const total_commits = this.dataset.total; content += `<h3>${date}: ${total_commits} commit${total_commits !== 1 ? 's' : ''}</h3>`; for (const repo of repos) { const commits = summaries[repo]; if (repos.length > 1) { content += `<h4>${repo} (${commits.length} commit${commits.length !== 1 ? 's' : ''})</h4>`; } else { content += `<h4>${repo}</h4>`; } if (tags[repo]) { content += `<h5>Tags: ${tags[repo]}</h5>`; } content += '<ul>'; commits.forEach(commit => { content += `<li>${commit}</li>`; }); content += '</ul>'; } } } popup.innerHTML = content; // Position popup near the cell const rect = this.getBoundingClientRect(); popup.style.left = rect.left + window.scrollX + 'px'; popup.style.top = (rect.bottom + window.scrollY + 5) + 'px'; popup.style.display = 'block'; // Highlight the cell to which the popup belongs document.querySelectorAll('table.heatmap .popup-open').forEach(old_cell => { old_cell.classList.toggle("popup-open"); }); cell.classList.toggle("popup-open"); // Add close button handler const closeBtn = popup.querySelector('.close-btn'); if (closeBtn) { closeBtn.addEventListener('click', function() { popup.style.display = 'none'; cell.classList.toggle("popup-open"); }); } e.stopPropagation(); }); }); // Close popup when clicking elsewhere document.addEventListener('click', function(e) { if (!popup.contains(e.target)) { popup.style.display = 'none'; document.querySelectorAll('table.heatmap .popup-open').forEach(cell => { cell.classList.toggle("popup-open"); }); } }); }); </script>)"); } void html::open(bool fragment, unsigned char fragment_indent) { if (fragment) { indent(fragment_indent); puts("<div class=\"heatmap-content\">"); indentation++; } else { puts(R"(<!DOCTYPE html> <html> <head> <meta charset="UTF-8">)"); styles_and_script(); puts(R"( </head> <body> <div class="heatmap-content">)"); indentation = 3; } } void html::close(bool fragment) { if (fragment) { indent(-1); puts("</div>"); } else { puts(" </div>\n </body>\n</html>"); } } void html::chart_begin(const std::string& repo, const std::string& author) { indent(); printf("<div class=\"chart\" data-repo=\"%s\" data-author=\"%s\">\n", encode(repo).c_str(), encode(author).c_str()); indentation++; } void html::chart_end() { indent(-1); puts("</div>"); } void html::heading_repo(const std::string& repo) { indent(); printf("<h1 data-repo=\"%1$s\">%1$s</h1>\n", encode(repo).c_str()); } void html::heading_author(const std::string& author, unsigned int total_commits) { indent(); printf("<h2 title=\"Total commits: %u\">%s</h2>\n", total_commits, encode(author).c_str()); } void html::table_begin(year y, bool hide_repo_names, const std::array<fm::commit_summary, 12> &commits_per_month) { static constexpr const char* months[] = {"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"}; // compute the column spans, first unsigned colspans[12] = {}; { unsigned total_cols = 0; sys_days day{year_month_day{y, January, 1d}}; for (unsigned col = 0; col < 12; ++col) { while (total_cols < columns && year_month_day{day}.month() <= month{col + 1}) { ++total_cols; ++colspans[col]; day += days{7}; } } } // now render the table heading indent(); puts("<table class=\"heatmap\">"); indent(1); puts("<tr>"); indent(1); puts("<th></th>"); for (unsigned i = 0 ; i < 12 ; i++) { indent(); const fm::commit_summary &summary = commits_per_month[i]; const unsigned total = summary.count(); if (total > 0) { std::string commit_summary; if (!hide_repo_names) { std::string commit_summary_json; commit_summary_json += '{'; for (const auto &[repo, count] : summary.commits) { commit_summary_json += std::format("\"{}\": {},", escape_json(repo), count); } if (!summary.commits.empty()) { commit_summary_json.pop_back(); } commit_summary_json += '}'; commit_summary = std::format("data-commits=\"{}\"", encode(commit_summary_json)); } std::string tags = build_tag_array(summary.tags_with_date, hide_repo_names); printf("<th scope=\"col\" title=\"Total commits: %u\" colspan=\"%d\" data-total=\"%u\" %s data-tags=\"%s\">%s</th>\n", total, colspans[i], total, commit_summary.c_str(), encode(tags).c_str(), months[i]); } else { printf("<th scope=\"col\" class=\"zero-commits\" colspan=\"%d\">%s</th>\n", colspans[i], months[i]); } } indent(-1); puts("</tr>"); } void html::table_end() { indent(-1); puts("</table>"); } void html::row_begin(unsigned int row) { indent(); puts("<tr>"); indent(1); printf("<th scope=\"row\">%s</th>\n", weekdays[row]); } void html::row_end() { indent(-1); puts("</tr>"); } void html::cell_out_of_range() { indent(); puts("<td class=\"out-of-range\"></td>"); } void html::cell(year_month_day ymd, bool hide_repo_names, const fm::commits &commits) { const char *color_class; if (commits.count() == 0) { color_class = "zero-commits"; } else if (commits.count() == 1) { color_class = "one-commit"; } else if (commits.count() <= 5) { color_class = "up-to-5-commits"; } else if (commits.count() <= 10) { color_class = "up-to-10-commits"; } else if (commits.count() <= 20) { color_class = "up-to-20-commits"; } else { color_class = "commit-spam"; } // Build a JSON object of commit summaries auto add_summaries = [](std::string &json, const std::vector<std::string> &summaries) static { // We have to iterate in reverse order to sort the summaries chronologically for (const auto &summary : summaries | std::views::reverse) { json += "\"" + escape_json(summary) + "\","; } json.pop_back(); }; std::string summaries_json; if (hide_repo_names) { summaries_json += '['; for (const auto &summaries: commits.summaries | std::views::values) { add_summaries(summaries_json, summaries); } summaries_json += ']'; } else { summaries_json += '{'; for (const auto &[repo, summaries] : commits.summaries) { summaries_json += "\"" + escape_json(repo) + "\":["; add_summaries(summaries_json, summaries); summaries_json += "],"; } if (!commits.summaries.empty()) { summaries_json.pop_back(); } summaries_json += '}'; } // Build a JSON object of tags std::string tags_json = build_tag_list(commits.tags, hide_repo_names); // Output std::string date_str = std::format("{}, {}-{:02d}-{:02d}", weekdays[weekday(ymd).iso_encoding() - 1], static_cast<int>(ymd.year()), static_cast<unsigned>(ymd.month()), static_cast<unsigned>(ymd.day())); const unsigned total = commits.count(); indent(); printf("<td class=\"%s\" title=\"%s: %u %s\" data-total=\"%u\" data-date=\"%s\" data-summaries=\"%s\" data-tags=\"%s\"></td>\n", color_class, date_str.c_str(), total, total == 1 ? "commit" : "commits", total, date_str.c_str(), encode(summaries_json).c_str(), encode(tags_json).c_str() ); }