# HG changeset patch # User Mike Becker # Date 1770399887 -3600 # Node ID 857af79337d5385668778324d9d59d84e92cc2d4 # Parent bae9922f46810765792d0fb0f2c4bedc0530a597 add monthly summaries - resolves #699 diff -r bae9922f4681 -r 857af79337d5 CHANGELOG --- a/CHANGELOG Fri Feb 06 16:23:50 2026 +0100 +++ b/CHANGELOG Fri Feb 06 18:44:47 2026 +0100 @@ -1,6 +1,7 @@ Version 1.2.0 - tbd - Add --styles-and-script option to output the default CSS and Javascript +- Add monthly summaries Version 1.1.2 - 2025-12-15 diff -r bae9922f4681 -r 857af79337d5 src/commit-data.h --- a/src/commit-data.h Fri Feb 06 16:23:50 2026 +0100 +++ b/src/commit-data.h Fri Feb 06 18:44:47 2026 +0100 @@ -31,13 +31,19 @@ #include namespace fm { + using commit_counts = std::unordered_map< + std::string, // repository name + unsigned>; + using summaries_lists = std::unordered_map< + std::string, // repository name + std::vector >; + using tag_lists = std::unordered_map< + std::string, // repository name + std::vector >; + struct commits final { - std::unordered_map< - std::string, // repository name - std::vector > summaries; - std::unordered_map< - std::string, // repository name - std::vector > tags; + summaries_lists summaries; + tag_lists tags; [[nodiscard]] unsigned count(const std::string &repo) const { return summaries.at(repo).size(); @@ -45,8 +51,23 @@ [[nodiscard]] unsigned count() const { return std::accumulate( - summaries.begin(), summaries.end(), 0u, - [](unsigned sum, const auto &pair) { return sum + pair.second.size(); }); + summaries.begin(), summaries.end(), 0u, + [](unsigned sum, const auto &pair) { return sum + pair.second.size(); }); + } + }; + + struct commit_summary final { + tag_lists tags_with_date; + commit_counts commits; + + [[nodiscard]] unsigned count(const std::string &repo) const { + return commits.at(repo); + } + + [[nodiscard]] unsigned count() const { + return std::accumulate( + commits.begin(), commits.end(), 0u, + [](unsigned sum, const auto &pair) { return sum + pair.second; }); } }; } diff -r bae9922f4681 -r 857af79337d5 src/heatmap.cpp --- a/src/heatmap.cpp Fri Feb 06 16:23:50 2026 +0100 +++ b/src/heatmap.cpp Fri Feb 06 18:44:47 2026 +0100 @@ -68,15 +68,32 @@ } } -std::array fm::heatmap::commits_per_month( +std::array fm::heatmap::commits_per_month( const std::string& repo, const std::string& author, chrono::year year ) const { - std::array result{}; - for (auto&& [ymd, commits] : m_heatmap.at(repo).at(author)) { + std::array result{}; + for (auto &&[ymd, commits]: m_heatmap.at(repo).at(author)) { if (ymd.year() != year) continue; - result[static_cast(ymd.month())-1] += commits.count(); + commit_summary &cs = result[static_cast(ymd.month()) - 1]; + for (auto &&[reponame, summaries]: commits.summaries) { + cs.commits[reponame] += summaries.size(); + } + for (auto &&[reponame, tags]: commits.tags) { + if (tags.empty()) continue; + std::string tag_list = tags.at(0); + for (unsigned i = 1; i < tags.size(); ++i) { + tag_list.append(", "); + tag_list.append(tags.at(i)); + } + cs.tags_with_date[reponame].emplace_back( + std::format("{} on {}-{:02}-{:02}", + tag_list, + static_cast(ymd.year()), + static_cast(ymd.month()), + static_cast(ymd.day()))); + } } return result; } diff -r bae9922f4681 -r 857af79337d5 src/heatmap.h --- a/src/heatmap.h Fri Feb 06 16:23:50 2026 +0100 +++ b/src/heatmap.h Fri Feb 06 18:44:47 2026 +0100 @@ -59,7 +59,7 @@ return m_heatmap; } - [[nodiscard]] std::array commits_per_month( + [[nodiscard]] std::array commits_per_month( const std::string &repo, const std::string& author, std::chrono::year year diff -r bae9922f4681 -r 857af79337d5 src/html.cpp --- 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 = '×'; - if (Array.isArray(summaries)) { - content += `

${date}: ${summaries.length} commit${summaries.length !== 1 ? 's' : ''}

`; - if (this.dataset.tags) { - content += `
Tags: ${this.dataset.tags}
`; + if (date === undefined) { + // monthly summary + const total_commits = this.dataset.total; + content += `

Total: ${total_commits} commit${total_commits !== 1 ? 's' : ''}

`; + if (this.dataset.commits === undefined) { + const tags = this.dataset.tags ? JSON.parse(this.dataset.tags) : []; + if (tags.length === 0) { + content += 'No tags.'; + } else { + content += '
    '; + tags.forEach(tag => { + content += `
  • ${tag}
  • `; + }); + content += '
'; + } + } else { + content += '
    '; + 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 += `
  • ${repo} (${commits[repo]} commit${commits[repo] !== 1 ? 's' : ''})
  • `; + if (tags[repo] && tags[repo].length > 0) { + content += '
      '; + tags[repo].forEach(tag => { + content += `
    • ${tag}
    • `; + }); + content += '
    '; + } + } + content += '
'; } - content += '
    '; - summaries.forEach(summary => { - content += `
  • ${summary}
  • `; - }); - content += '
'; } 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 += `

${date}: ${total_commits} commit${total_commits !== 1 ? 's' : ''}

`; - for (const repo of repos) { - const commits = summaries[repo]; - if (repos.length > 1) { - content += `

${repo} (${commits.length} commit${commits.length !== 1 ? 's' : ''})

`; - } else { - content += `

${repo}

`; - } - if (tags[repo]) { - content += `
Tags: ${tags[repo]}
`; + const summaries = JSON.parse(this.dataset.summaries); + if (Array.isArray(summaries)) { + const summaries = this.dataset.summaries ? JSON.parse(this.dataset.summaries) : undefined; + content += `

${date}: ${summaries.length} commit${summaries.length !== 1 ? 's' : ''}

`; + if (this.dataset.tags) { + content += `
Tags: ${this.dataset.tags}
`; } content += '
    '; - commits.forEach(commit => { - content += `
  • ${commit}
  • `; + summaries.forEach(summary => { + content += `
  • ${summary}
  • `; }); content += '
'; + } else { + const repos = Object.keys(summaries).sort(); + const tags = this.dataset.tags ? JSON.parse(this.dataset.tags) : {}; + const total_commits = this.dataset.total; + content += `

${date}: ${total_commits} commit${total_commits !== 1 ? 's' : ''}

`; + for (const repo of repos) { + const commits = summaries[repo]; + if (repos.length > 1) { + content += `

${repo} (${commits.length} commit${commits.length !== 1 ? 's' : ''})

`; + } else { + content += `

${repo}

`; + } + if (tags[repo]) { + content += `
Tags: ${tags[repo]}
`; + } + content += '
    '; + commits.forEach(commit => { + content += `
  • ${commit}
  • `; + }); + content += '
'; + } } } 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 &commits_per_month) { +void html::table_begin(year y, bool hide_repo_names, const std::array &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(""); for (unsigned i = 0 ; i < 12 ; i++) { indent(); - printf("%s\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("%s\n", + total, colspans[i], total, + commit_summary.c_str(), + encode(tags).c_str(), months[i]); + } else { + printf("%s\n", + colspans[i], months[i]); + } } indent(-1); puts(""); @@ -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(ymd.year()), - static_cast(ymd.month()), - static_cast(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 &summaries) { + auto add_summaries = [](std::string &json, const std::vector &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(ymd.year()), + static_cast(ymd.month()), + static_cast(ymd.day())); + const unsigned total = commits.count(); indent(); - printf("\n", + printf("\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() ); diff -r bae9922f4681 -r 857af79337d5 src/html.h --- a/src/html.h Fri Feb 06 16:23:50 2026 +0100 +++ b/src/html.h Fri Feb 06 18:44:47 2026 +0100 @@ -45,7 +45,7 @@ void chart_end(); void heading_repo(const std::string& repo); void heading_author(const std::string& author, unsigned int total_commits); - void table_begin(std::chrono::year y, const std::array &commits_per_month); + void table_begin(std::chrono::year y, bool hide_repo_names, const std::array &commits_per_month); void table_end(); void row_begin(unsigned int row); void row_end(); diff -r bae9922f4681 -r 857af79337d5 src/main.cpp --- a/src/main.cpp Fri Feb 06 16:23:50 2026 +0100 +++ b/src/main.cpp Fri Feb 06 18:44:47 2026 +0100 @@ -309,9 +309,10 @@ html::chart_begin(repo, author); const auto commits_per_month = heatmap.commits_per_month(repo, author, report_year); - const auto total_commits = std::accumulate(commits_per_month.begin(), commits_per_month.end(), 0u); + const auto total_commits = std::accumulate(commits_per_month.begin(), commits_per_month.end(), 0u, + [](unsigned sum, const auto &summary) { return sum + summary.count(); }); html::heading_author(author, total_commits); - html::table_begin(report_year, commits_per_month); + html::table_begin(report_year, settings.separate, commits_per_month); // initialize counters unsigned column = 0, row = 0;