src/html.cpp

changeset 75
857af79337d5
parent 73
707f42bb0484
--- 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()
     );

mercurial