--- a/src/html.cpp Fri Jun 20 17:15:18 2025 +0200 +++ b/src/html.cpp Sat Jun 28 11:32:08 2025 +0200 @@ -24,6 +24,7 @@ #include "html.h" +#include <ranges> #include <cstdio> #include <cassert> @@ -36,7 +37,6 @@ static const char *tabs = " "; static void indent(int change = 0) { indentation += change; - assert(indentation >= 0); assert(indentation <= max_indentation); fwrite(tabs, 4, indentation, stdout); } @@ -61,6 +61,9 @@ case '>': buffer.append(">"); break; + case '#': + buffer.append("#"); + break; default: buffer.append(&pos, 1); break; @@ -79,22 +82,30 @@ puts(R"(<!DOCTYPE html> <html> <head> + <meta charset="UTF-8"> <style> table.heatmap { table-layout: fixed; border-collapse: collapse; font-family: sans-serif; } + table.heatmap td, table.heatmap th { text-align: center; border: solid 1px lightgray; height: 1.5em; } + table.heatmap td { border: solid 1px lightgray; width: 1.5em; height: 1.5em; } + + table.heatmap td:hover, table.heatmap td.popup-open { + filter: hue-rotate(90deg); + } + table.heatmap td.out-of-range { background-color: gray; } @@ -122,7 +133,105 @@ table.heatmap td.commit-spam { background-color: #008000; } + + /* 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; + } + + .commit-popup h3 { + margin-top: 0; + border-bottom: 1px solid #eee; + padding-bottom: 5px; + } + + .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)'); + 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> + <h3>${date}: ${summaries.length} commit${summaries.length !== '1' ? 's' : ''}</h3>`; + content += '<ul>'; + summaries.forEach(summary => { + content += `<li>${summary}</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 td.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 td.popup-open').forEach(cell => { + cell.classList.toggle("popup-open"); + }); + } + }); + }); + </script> </head> <body> <div class="heatmap-content">)"); @@ -217,28 +326,58 @@ puts("<td class=\"out-of-range\"></td>"); } -void html::cell(year_month_day ymd, unsigned commits) { +void html::cell(year_month_day ymd, const fm::commits &commits) { const char *color_class; - if (commits == 0) { + if (commits.count() == 0) { color_class = "zero-commits"; - } else if (commits == 1) { + } else if (commits.count() == 1) { color_class = "one-commit"; - } else if (commits <= 5) { + } else if (commits.count() <= 5) { color_class = "up-to-5-commits"; - } else if (commits <= 10) { + } else if (commits.count() <= 10) { color_class = "up-to-10-commits"; - } else if (commits <= 20) { + } else if (commits.count() <= 20) { color_class = "up-to-20-commits"; } else { color_class = "commit-spam"; } - indent(); - printf("<td class=\"%s\" title=\"%s, %d-%02u-%02u: %u %s\"></td>\n", - color_class, + + + // 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()), - commits, - commits == 1 ? "commit" : "commits"); + static_cast<unsigned>(ymd.day())); + + // Build a JSON array of commit summaries + // We have to iterate in reverse order to sort the summaries chronologically + std::string summaries_json = "["; + for (const auto &summary : commits.summaries | std::views::reverse) { + // Escape quotes in JSON + size_t pos = summary.find('\"'); + if (pos == std::string::npos) { + summaries_json += "\"" + summary + "\","; + } else { + std::string escaped = summary; + do { + escaped.replace(pos, 1, "\\\""); + pos += 2; + } while ((pos = escaped.find('\"', pos)) != std::string::npos); + summaries_json += "\"" + escaped + "\","; + } + } + summaries_json.pop_back(); + summaries_json += "]"; + + indent(); + printf("<td class=\"%s\" title=\"%s: %u %s\" data-date=\"%s\" data-summaries=\"%s\"></td>\n", + color_class, + date_str, + commits.count(), + commits.count() == 1 ? "commit" : "commits", + date_str, + encode(summaries_json).c_str() + ); }