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()
);