--- a/src/html.cpp Fri Feb 06 16:23:50 2026 +0100 +++ b/src/html.cpp Fri Feb 06 18:44:47 2026 +0100 @@ -71,6 +71,91 @@ } 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() { @@ -98,6 +183,10 @@ 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; } @@ -186,45 +275,79 @@ 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)'); + 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; - const summaries = JSON.parse(this.dataset.summaries); // Create popup content let content = '<span class="close-btn">×</span>'; - if (Array.isArray(summaries)) { - content += `<h3>${date}: ${summaries.length} commit${summaries.length !== 1 ? 's' : ''}</h3>`; - if (this.dataset.tags) { - content += `<h5>Tags: ${this.dataset.tags}</h5>`; + 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>'; } - 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) : {}; - let total_commits = 0; - for (const repo of repos) total_commits += summaries[repo].length; - 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>`; + 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>'; - commits.forEach(commit => { - content += `<li>${commit}</li>`; + 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; @@ -236,7 +359,7 @@ popup.style.display = 'block'; // Highlight the cell to which the popup belongs - document.querySelectorAll('table.heatmap td.popup-open').forEach(old_cell => { + document.querySelectorAll('table.heatmap .popup-open').forEach(old_cell => { old_cell.classList.toggle("popup-open"); }); cell.classList.toggle("popup-open"); @@ -258,7 +381,7 @@ document.addEventListener('click', function(e) { if (!popup.contains(e.target)) { popup.style.display = 'none'; - document.querySelectorAll('table.heatmap td.popup-open').forEach(cell => { + document.querySelectorAll('table.heatmap .popup-open').forEach(cell => { cell.classList.toggle("popup-open"); }); } @@ -318,7 +441,7 @@ encode(author).c_str()); } -void html::table_begin(year y, const std::array<unsigned int, 12> &commits_per_month) { +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] = {}; @@ -326,7 +449,7 @@ unsigned total_cols = 0; sys_days day{year_month_day{y, January, 1d}}; for (unsigned col = 0; col < 12; ++col) { - while (total_cols < html::columns && year_month_day{day}.month() <= month{col + 1}) { + while (total_cols < columns && year_month_day{day}.month() <= month{col + 1}) { ++total_cols; ++colspans[col]; day += days{7}; @@ -343,8 +466,31 @@ puts("<th></th>"); for (unsigned i = 0 ; i < 12 ; i++) { indent(); - printf("<th scope=\"col\" title=\"Total commits: %u\" colspan=\"%d\">%s</th>\n", - commits_per_month[i], colspans[i], months[i]); + 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>"); @@ -388,33 +534,8 @@ color_class = "commit-spam"; } - - // Format date for display - char date_str[32]; - sprintf(date_str, "%s, %d-%02u-%02u", - weekdays[weekday(ymd).iso_encoding() - 1], - static_cast<int>(ymd.year()), - static_cast<unsigned>(ymd.month()), - static_cast<unsigned>(ymd.day())); - - // Utility function to escape strings in JSON - auto escape_json = [](std::string raw) static { - 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(std::move(raw), '\\', "\\\\"), '\"', "\\\""sv); - }; - // Build a JSON object of commit summaries - auto add_summaries = [escape_json](std::string &json, const std::vector<std::string> &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) + "\","; @@ -442,45 +563,23 @@ } // Build a JSON object of tags - std::string tags_json; - if (hide_repo_names) { - for (const auto &tags_vector: commits.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] : commits.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 += "\","; - } - // note: in contrast to summaries, we want an empty string here when there's nothing to report - tags_json.pop_back(); - if (!commits.tags.empty()) { - tags_json += '}'; - } - } + 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-date=\"%s\" data-summaries=\"%s\" data-tags=\"%s\"></td>\n", + 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, - commits.count(), - commits.count() == 1 ? "commit" : "commits", - date_str, + date_str.c_str(), + total, + total == 1 ? "commit" : "commits", + total, + date_str.c_str(), encode(summaries_json).c_str(), encode(tags_json).c_str() );