feat(reports): Excel renderer + attachment + archive download

render_excel(report): one worksheet per location — interval table, a line chart,
and a Last/Base/Δ summary per window. Metric-driven, so it tracks whatever metric
set is configured.

- orchestrator: render report.xlsx alongside report.html, attach it to the email
  (dry-run until SMTP set), expose xlsx_path. Never lets a spreadsheet error sink
  the report.
- reports router: /list includes xlsx_url when present; new
  GET /archive/{date}/xlsx serves the saved spreadsheet.
- UI: Recent-reports rows get an "Excel" download link.

Verified: real Feb data -> valid .xlsx (sheet per NRL, interval table + chart +
summary with real values), attachment path runs, both archive routes registered.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-11 22:49:32 +00:00
parent 88887a92d8
commit ccb70698ba
4 changed files with 167 additions and 6 deletions
@@ -206,9 +206,10 @@ function loadRecentReports(projectId) {
}
box.innerHTML = j.reports.map(function (rp) {
var when = (rp.generated_at || '').replace('T', ' ').slice(0, 16);
return '<a href="' + rp.view_url + '" target="_blank" class="flex items-center justify-between px-3 py-2 text-sm hover:bg-gray-50 dark:hover:bg-gray-700">'
+ '<span class="font-medium text-gray-800 dark:text-gray-200">Night of ' + rp.night_date + '</span>'
+ '<span class="text-xs text-gray-400">' + when + ' UTC</span></a>';
var xlsx = rp.xlsx_url ? ' · <a href="' + rp.xlsx_url + '" class="text-indigo-600 dark:text-indigo-400 hover:underline">Excel</a>' : '';
return '<div class="flex items-center justify-between px-3 py-2 text-sm">'
+ '<a href="' + rp.view_url + '" target="_blank" class="font-medium text-gray-800 dark:text-gray-200 hover:underline">Night of ' + rp.night_date + '</a>'
+ '<span class="text-xs text-gray-400">' + when + ' UTC' + xlsx + '</span></div>';
}).join('');
})
.catch(function () { box.innerHTML = '<div class="px-3 py-2 text-xs text-red-500">Failed to load.</div>'; });