447 lines
23 KiB
C#
447 lines
23 KiB
C#
using System.Text.Json;
|
||
using InnovEnergy.App.Backend.DataTypes;
|
||
using InnovEnergy.Lib.Mailer;
|
||
using MailKit.Net.Smtp;
|
||
using MailKit.Security;
|
||
using MimeKit;
|
||
|
||
namespace InnovEnergy.App.Backend.Services;
|
||
|
||
public static class ReportEmailService
|
||
{
|
||
/// <summary>
|
||
/// Sends the weekly report as a nicely formatted HTML email in the user's language.
|
||
/// Uses MailKit directly (same config as existing Mailer library) but with HTML support.
|
||
/// </summary>
|
||
public static async Task SendReportEmailAsync(WeeklyReportResponse report, string recipientEmail, string language = "en")
|
||
{
|
||
var strings = GetStrings(language);
|
||
var subject = $"{strings.Title} — {report.InstallationName} ({report.PeriodStart} to {report.PeriodEnd})";
|
||
var html = BuildHtmlEmail(report, strings);
|
||
|
||
var config = await ReadMailerConfig();
|
||
|
||
var from = new MailboxAddress(config.SenderName, config.SenderAddress);
|
||
var to = new MailboxAddress(recipientEmail, recipientEmail);
|
||
|
||
var msg = new MimeMessage
|
||
{
|
||
From = { from },
|
||
To = { to },
|
||
Subject = subject,
|
||
Body = new TextPart("html") { Text = html }
|
||
};
|
||
|
||
Console.WriteLine($"[ReportEmailService] SMTP: {config.SmtpUsername}@{config.SmtpServerUrl}:{config.SmtpPort}");
|
||
|
||
using var smtp = new SmtpClient();
|
||
await smtp.ConnectAsync(config.SmtpServerUrl, config.SmtpPort, SecureSocketOptions.StartTls);
|
||
await smtp.AuthenticateAsync(config.SmtpUsername, config.SmtpPassword);
|
||
await smtp.SendAsync(msg);
|
||
await smtp.DisconnectAsync(true);
|
||
|
||
Console.WriteLine($"[ReportEmailService] Report sent to {recipientEmail}");
|
||
}
|
||
|
||
private static async Task<MailerConfig> ReadMailerConfig()
|
||
{
|
||
await using var fileStream = File.OpenRead(MailerConfig.DefaultFile);
|
||
var config = await JsonSerializer.DeserializeAsync<MailerConfig>(fileStream);
|
||
return config ?? throw new InvalidOperationException("Failed to read MailerConfig.json");
|
||
}
|
||
|
||
// ── Translation strings ─────────────────────────────────────────────────
|
||
|
||
private record EmailStrings(
|
||
string Title,
|
||
string Insights,
|
||
string Summary,
|
||
string SavingsHeader,
|
||
string DailyBreakdown,
|
||
string Metric,
|
||
string ThisWeek,
|
||
string LastWeek,
|
||
string Change,
|
||
string PvProduction,
|
||
string Consumption,
|
||
string GridImport,
|
||
string GridExport,
|
||
string BatteryInOut,
|
||
string SolarEnergyUsed,
|
||
string StayedAtHome,
|
||
string EstMoneySaved,
|
||
string AtRate,
|
||
string SolarCoverage,
|
||
string FromSolar,
|
||
string BatteryEff,
|
||
string OutVsIn,
|
||
string Day,
|
||
string Load,
|
||
string GridIn,
|
||
string GridOut,
|
||
string BattInOut,
|
||
string Footer
|
||
);
|
||
|
||
private static EmailStrings GetStrings(string language) => language switch
|
||
{
|
||
"de" => new EmailStrings(
|
||
Title: "Wöchentlicher Leistungsbericht",
|
||
Insights: "Wöchentliche Erkenntnisse",
|
||
Summary: "Wöchentliche Zusammenfassung",
|
||
SavingsHeader: "Ihre Ersparnisse diese Woche",
|
||
DailyBreakdown: "Tägliche Aufschlüsselung (kWh)",
|
||
Metric: "Kennzahl",
|
||
ThisWeek: "Diese Woche",
|
||
LastWeek: "Letzte Woche",
|
||
Change: "Änderung",
|
||
PvProduction: "PV-Produktion",
|
||
Consumption: "Verbrauch",
|
||
GridImport: "Netzbezug",
|
||
GridExport: "Netzeinspeisung",
|
||
BatteryInOut: "Batterie Laden / Entladen",
|
||
SolarEnergyUsed: "Energie gespart",
|
||
StayedAtHome: "Solar + Batterie, nicht vom Netz",
|
||
EstMoneySaved: "Geschätzte Ersparnis",
|
||
AtRate: "bei 0.39 CHF/kWh",
|
||
SolarCoverage: "Eigenversorgung",
|
||
FromSolar: "aus Solar + Batterie",
|
||
BatteryEff: "Batterie-Eff.",
|
||
OutVsIn: "Entladung vs. Ladung",
|
||
Day: "Tag",
|
||
Load: "Last",
|
||
GridIn: "Netz Ein",
|
||
GridOut: "Netz Aus",
|
||
BattInOut: "Batt. Laden/Entl.",
|
||
Footer: "Erstellt von <strong style=\"color:#666\">inesco Energy Monitor</strong>"
|
||
),
|
||
"fr" => new EmailStrings(
|
||
Title: "Rapport de performance hebdomadaire",
|
||
Insights: "Aperçus de la semaine",
|
||
Summary: "Résumé de la semaine",
|
||
SavingsHeader: "Vos économies cette semaine",
|
||
DailyBreakdown: "Détail quotidien (kWh)",
|
||
Metric: "Indicateur",
|
||
ThisWeek: "Cette semaine",
|
||
LastWeek: "Semaine dernière",
|
||
Change: "Variation",
|
||
PvProduction: "Production PV",
|
||
Consumption: "Consommation",
|
||
GridImport: "Import réseau",
|
||
GridExport: "Export réseau",
|
||
BatteryInOut: "Batterie Charge / Décharge",
|
||
SolarEnergyUsed: "Énergie économisée",
|
||
StayedAtHome: "solaire + batterie, non achetée au réseau",
|
||
EstMoneySaved: "Économies estimées",
|
||
AtRate: "à 0.39 CHF/kWh",
|
||
SolarCoverage: "Autosuffisance",
|
||
FromSolar: "du solaire + batterie",
|
||
BatteryEff: "Eff. batterie",
|
||
OutVsIn: "décharge vs charge",
|
||
Day: "Jour",
|
||
Load: "Charge",
|
||
GridIn: "Réseau Ent.",
|
||
GridOut: "Réseau Sor.",
|
||
BattInOut: "Batt. Ch./Déch.",
|
||
Footer: "Généré par <strong style=\"color:#666\">inesco Energy Monitor</strong>"
|
||
),
|
||
"it" => new EmailStrings(
|
||
Title: "Rapporto settimanale delle prestazioni",
|
||
Insights: "Approfondimenti settimanali",
|
||
Summary: "Riepilogo settimanale",
|
||
SavingsHeader: "I tuoi risparmi questa settimana",
|
||
DailyBreakdown: "Dettaglio giornaliero (kWh)",
|
||
Metric: "Metrica",
|
||
ThisWeek: "Questa settimana",
|
||
LastWeek: "La settimana scorsa",
|
||
Change: "Variazione",
|
||
PvProduction: "Produzione PV",
|
||
Consumption: "Consumo",
|
||
GridImport: "Import dalla rete",
|
||
GridExport: "Export nella rete",
|
||
BatteryInOut: "Batteria Carica / Scarica",
|
||
SolarEnergyUsed: "Energia risparmiata",
|
||
StayedAtHome: "solare + batteria, non acquistata dalla rete",
|
||
EstMoneySaved: "Risparmio stimato",
|
||
AtRate: "a 0.39 CHF/kWh",
|
||
SolarCoverage: "Autosufficienza",
|
||
FromSolar: "da solare + batteria",
|
||
BatteryEff: "Eff. batteria",
|
||
OutVsIn: "scarica vs carica",
|
||
Day: "Giorno",
|
||
Load: "Carico",
|
||
GridIn: "Rete Ent.",
|
||
GridOut: "Rete Usc.",
|
||
BattInOut: "Batt. Car./Sc.",
|
||
Footer: "Generato da <strong style=\"color:#666\">inesco Energy Monitor</strong>"
|
||
),
|
||
_ => new EmailStrings(
|
||
Title: "Weekly Performance Report",
|
||
Insights: "Weekly Insights",
|
||
Summary: "Weekly Summary",
|
||
SavingsHeader: "Your Savings This Week",
|
||
DailyBreakdown: "Daily Breakdown (kWh)",
|
||
Metric: "Metric",
|
||
ThisWeek: "This Week",
|
||
LastWeek: "Last Week",
|
||
Change: "Change",
|
||
PvProduction: "PV Production",
|
||
Consumption: "Consumption",
|
||
GridImport: "Grid Import",
|
||
GridExport: "Grid Export",
|
||
BatteryInOut: "Battery Charge / Discharge",
|
||
SolarEnergyUsed: "Energy Saved",
|
||
StayedAtHome: "solar + battery, not bought from grid",
|
||
EstMoneySaved: "Est. Money Saved",
|
||
AtRate: "at 0.39 CHF/kWh",
|
||
SolarCoverage: "Self-Sufficiency",
|
||
FromSolar: "from solar + battery",
|
||
BatteryEff: "Battery Eff.",
|
||
OutVsIn: "discharge vs charge",
|
||
Day: "Day",
|
||
Load: "Load",
|
||
GridIn: "Grid In",
|
||
GridOut: "Grid Out",
|
||
BattInOut: "Batt. Ch./Dis.",
|
||
Footer: "Generated by <strong style=\"color:#666\">inesco Energy Monitor</strong>"
|
||
)
|
||
};
|
||
|
||
// ── HTML email template ─────────────────────────────────────────────
|
||
|
||
public static string BuildHtmlEmail(WeeklyReportResponse r, string language = "en")
|
||
=> BuildHtmlEmail(r, GetStrings(language));
|
||
|
||
private static string BuildHtmlEmail(WeeklyReportResponse r, EmailStrings s)
|
||
{
|
||
var cur = r.CurrentWeek;
|
||
var prev = r.PreviousWeek;
|
||
|
||
// Parse AI insight into <li> bullet points (split on newlines, strip leading "- " or "1. ")
|
||
var insightLines = r.AiInsight
|
||
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
|
||
.Select(l => System.Text.RegularExpressions.Regex.Replace(l.Trim(), @"^[\d]+[.)]\s*|^[-*]\s*", ""))
|
||
.Where(l => l.Length > 0)
|
||
.ToList();
|
||
|
||
var insightHtml = insightLines.Count > 1
|
||
? "<ul style=\"margin:0;padding-left:20px\">" +
|
||
string.Join("", insightLines.Select(l => $"<li style=\"margin-bottom:8px;line-height:1.6\">{FormatInsightLine(l)}</li>")) +
|
||
"</ul>"
|
||
: $"<p style=\"margin:0;line-height:1.6\">{FormatInsightLine(r.AiInsight)}</p>";
|
||
|
||
// Detect which components are present across all daily data
|
||
var showPv = r.DailyData.Any(d => d.PvProduction > 0.1);
|
||
var showGrid = r.DailyData.Any(d => d.GridImport > 0.1);
|
||
|
||
// Daily rows — colorful bar chart (pixel widths, email-safe)
|
||
// Scale each day's bars so their combined total always fills maxBarPx (right-edge aligned).
|
||
// This replicates the web page's CSS flexbox flex-shrink:1 behaviour.
|
||
const int maxBarPx = 400;
|
||
|
||
var dailyRows = "";
|
||
foreach (var d in r.DailyData)
|
||
{
|
||
var dayName = DateTime.Parse(d.Date).ToString("ddd dd.MM");
|
||
var isCurrentWeek = string.Compare(d.Date, r.PeriodStart, StringComparison.Ordinal) >= 0;
|
||
var opacity = isCurrentWeek ? "1" : "0.55";
|
||
var fontWeight = isCurrentWeek ? "bold" : "normal";
|
||
var dayTotal = (showPv ? d.PvProduction : 0) + d.LoadConsumption + (showGrid ? d.GridImport : 0);
|
||
if (dayTotal < 0.1) dayTotal = 0.1;
|
||
var pvPx = showPv ? (int)(d.PvProduction / dayTotal * maxBarPx) : 0;
|
||
var ldPx = (int)(d.LoadConsumption / dayTotal * maxBarPx);
|
||
var giPx = showGrid ? (int)(d.GridImport / dayTotal * maxBarPx) : 0;
|
||
|
||
var pvSpan = showPv ? $@"<span style=""display:inline-block;height:14px;background:#f39c12;width:{pvPx}px;border-radius:2px 0 0 2px""></span>" : "";
|
||
var gridSpan = showGrid ? $@"<span style=""display:inline-block;height:14px;background:#e74c3c;width:{giPx}px;border-radius:0 2px 2px 0;margin-left:2px""></span>" : "";
|
||
var ldRadius = (!showPv ? "border-radius:2px 0 0 2px;" : "") + (!showGrid ? "border-radius:0 2px 2px 0;" : "");
|
||
|
||
var valueText = (showPv ? $"PV {d.PvProduction:F1} | " : "")
|
||
+ $"{s.Load} {d.LoadConsumption:F1}"
|
||
+ (showGrid ? $" | {s.GridIn} {d.GridImport:F1}" : "")
|
||
+ " kWh";
|
||
|
||
dailyRows += $@"
|
||
<tr style=""opacity:{opacity};border-bottom:1px solid #f0f0f0"">
|
||
<td style=""padding:6px 8px;font-size:12px;font-weight:{fontWeight};white-space:nowrap;width:80px;vertical-align:top;padding-top:10px"">{dayName}</td>
|
||
<td style=""padding:4px 8px"">
|
||
<div style=""font-size:10px;color:#888;margin-bottom:3px;text-align:right"">{valueText}</div>
|
||
<div style=""height:14px;line-height:14px;font-size:0;white-space:nowrap;width:{maxBarPx}px"">{pvSpan}<span style=""display:inline-block;height:14px;background:#3498db;width:{ldPx}px;{ldRadius}margin-left:{(showPv ? 2 : 0)}px""></span>{gridSpan}</div>
|
||
</td>
|
||
</tr>";
|
||
}
|
||
|
||
// Week-over-week comparison rows
|
||
var comparisonHtml = prev != null
|
||
? $@"
|
||
<tr>
|
||
<td style=""padding:8px 12px;border-bottom:1px solid #eee"">{s.PvProduction}</td>
|
||
<td style=""padding:8px 12px;border-bottom:1px solid #eee;text-align:right;font-weight:bold"">{cur.TotalPvProduction:F1} kWh</td>
|
||
<td style=""padding:8px 12px;border-bottom:1px solid #eee;text-align:right;color:#888"">{prev.TotalPvProduction:F1} kWh</td>
|
||
<td style=""padding:8px 12px;border-bottom:1px solid #eee;text-align:right;color:{ChangeColor(r.PvChangePercent)}"">{FormatChange(r.PvChangePercent)}</td>
|
||
</tr>
|
||
<tr>
|
||
<td style=""padding:8px 12px;border-bottom:1px solid #eee"">{s.Consumption}</td>
|
||
<td style=""padding:8px 12px;border-bottom:1px solid #eee;text-align:right;font-weight:bold"">{cur.TotalConsumption:F1} kWh</td>
|
||
<td style=""padding:8px 12px;border-bottom:1px solid #eee;text-align:right;color:#888"">{prev.TotalConsumption:F1} kWh</td>
|
||
<td style=""padding:8px 12px;border-bottom:1px solid #eee;text-align:right;color:{ChangeColor(-r.ConsumptionChangePercent)}"">{FormatChange(r.ConsumptionChangePercent)}</td>
|
||
</tr>
|
||
<tr>
|
||
<td style=""padding:8px 12px;border-bottom:1px solid #eee"">{s.GridImport}</td>
|
||
<td style=""padding:8px 12px;border-bottom:1px solid #eee;text-align:right;font-weight:bold"">{cur.TotalGridImport:F1} kWh</td>
|
||
<td style=""padding:8px 12px;border-bottom:1px solid #eee;text-align:right;color:#888"">{prev.TotalGridImport:F1} kWh</td>
|
||
<td style=""padding:8px 12px;border-bottom:1px solid #eee;text-align:right;color:{ChangeColor(-r.GridImportChangePercent)}"">{FormatChange(r.GridImportChangePercent)}</td>
|
||
</tr>
|
||
<tr>
|
||
<td style=""padding:8px 12px;border-bottom:1px solid #eee"">{s.GridExport}</td>
|
||
<td style=""padding:8px 12px;border-bottom:1px solid #eee;text-align:right;font-weight:bold"">{cur.TotalGridExport:F1} kWh</td>
|
||
<td style=""padding:8px 12px;border-bottom:1px solid #eee;text-align:right;color:#888"">{prev.TotalGridExport:F1} kWh</td>
|
||
<td style=""padding:8px 12px;border-bottom:1px solid #eee;text-align:right"">—</td>
|
||
</tr>
|
||
<tr>
|
||
<td style=""padding:8px 12px;border-bottom:1px solid #eee"">{s.BatteryInOut}</td>
|
||
<td style=""padding:8px 12px;border-bottom:1px solid #eee;text-align:right;font-weight:bold"">{cur.TotalBatteryCharged:F1}/{cur.TotalBatteryDischarged:F1} kWh</td>
|
||
<td style=""padding:8px 12px;border-bottom:1px solid #eee;text-align:right;color:#888"">{prev.TotalBatteryCharged:F1}/{prev.TotalBatteryDischarged:F1} kWh</td>
|
||
<td style=""padding:8px 12px;border-bottom:1px solid #eee;text-align:right"">—</td>
|
||
</tr>"
|
||
: $@"
|
||
<tr><td style=""padding:8px 12px"">{s.PvProduction}</td><td style=""padding:8px 12px;text-align:right;font-weight:bold"">{cur.TotalPvProduction:F1} kWh</td></tr>
|
||
<tr><td style=""padding:8px 12px"">{s.Consumption}</td><td style=""padding:8px 12px;text-align:right;font-weight:bold"">{cur.TotalConsumption:F1} kWh</td></tr>
|
||
<tr><td style=""padding:8px 12px"">{s.GridImport}</td><td style=""padding:8px 12px;text-align:right;font-weight:bold"">{cur.TotalGridImport:F1} kWh</td></tr>
|
||
<tr><td style=""padding:8px 12px"">{s.GridExport}</td><td style=""padding:8px 12px;text-align:right;font-weight:bold"">{cur.TotalGridExport:F1} kWh</td></tr>
|
||
<tr><td style=""padding:8px 12px"">{s.BatteryInOut}</td><td style=""padding:8px 12px;text-align:right;font-weight:bold"">{cur.TotalBatteryCharged:F1}/{cur.TotalBatteryDischarged:F1} kWh</td></tr>";
|
||
|
||
var comparisonHeaders = prev != null
|
||
? $@"<th style=""padding:8px 12px;text-align:right"">{s.ThisWeek}</th>
|
||
<th style=""padding:8px 12px;text-align:right"">{s.LastWeek}</th>
|
||
<th style=""padding:8px 12px;text-align:right"">{s.Change}</th>"
|
||
: $@"<th style=""padding:8px 12px;text-align:right"">{s.ThisWeek}</th>";
|
||
|
||
return $@"
|
||
<!DOCTYPE html>
|
||
<html>
|
||
<head><meta charset=""utf-8""></head>
|
||
<body style=""margin:0;padding:0;background:#f4f4f4;font-family:Arial,Helvetica,sans-serif;font-size:14px;color:#333"">
|
||
<table width=""100%"" cellpadding=""0"" cellspacing=""0"" style=""background:#f4f4f4;padding:20px 0"">
|
||
<tr><td align=""center"">
|
||
<table width=""600"" cellpadding=""0"" cellspacing=""0"" style=""background:#ffffff;border-radius:8px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,0.08)"">
|
||
|
||
<!-- Header -->
|
||
<tr>
|
||
<td style=""background:#2c3e50;padding:24px 30px;color:#ffffff"">
|
||
<div style=""font-size:20px;font-weight:bold"">{s.Title}</div>
|
||
<div style=""font-size:14px;margin-top:6px;opacity:0.9"">{r.InstallationName}</div>
|
||
<div style=""font-size:13px;margin-top:2px;opacity:0.7"">{r.PeriodStart} — {r.PeriodEnd}</div>
|
||
</td>
|
||
</tr>
|
||
|
||
<!-- Weekly Insights (top) -->
|
||
<tr>
|
||
<td style=""padding:24px 30px 0"">
|
||
<div style=""font-size:16px;font-weight:bold;margin-bottom:12px;color:#2c3e50"">{s.Insights}</div>
|
||
<div style=""background:#fef9e7;border-left:4px solid #f39c12;padding:14px 18px;border-radius:0 6px 6px 0;font-size:14px;color:#333"">
|
||
{insightHtml}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
|
||
<!-- Weekly Totals -->
|
||
<tr>
|
||
<td style=""padding:24px 30px"">
|
||
<div style=""font-size:16px;font-weight:bold;margin-bottom:12px;color:#2c3e50"">{s.Summary}</div>
|
||
<table width=""100%"" cellpadding=""0"" cellspacing=""0"" style=""border:1px solid #eee;border-radius:4px"">
|
||
<tr style=""background:#f8f9fa"">
|
||
<th style=""padding:8px 12px;text-align:left"">{s.Metric}</th>
|
||
{comparisonHeaders}
|
||
</tr>
|
||
{comparisonHtml}
|
||
</table>
|
||
</td>
|
||
</tr>
|
||
|
||
<!-- Key Ratios -->
|
||
<tr>
|
||
<td style=""padding:0 30px 24px"">
|
||
<div style=""font-size:16px;font-weight:bold;margin-bottom:12px;color:#2c3e50"">{s.SavingsHeader}</div>
|
||
<table width=""100%"" cellpadding=""0"" cellspacing=""8"">
|
||
<tr>
|
||
{SavingsBox(s.SolarEnergyUsed, $"{r.TotalEnergySaved:F1} kWh", s.StayedAtHome, "#27ae60")}
|
||
{SavingsBox(s.EstMoneySaved, $"~{r.TotalSavingsCHF:F0} CHF", s.AtRate, "#2980b9")}
|
||
{SavingsBox(s.SolarCoverage, $"{r.SelfSufficiencyPercent:F0}%", s.FromSolar, "#8e44ad")}
|
||
{SavingsBox(s.BatteryEff, $"{r.BatteryEfficiencyPercent:F0}%", s.OutVsIn, "#e67e22")}
|
||
</tr>
|
||
</table>
|
||
</td>
|
||
</tr>
|
||
|
||
<!-- Daily Breakdown (bar chart) -->
|
||
<tr>
|
||
<td style=""padding:0 30px 24px"">
|
||
<div style=""font-size:16px;font-weight:bold;margin-bottom:8px;color:#2c3e50"">{s.DailyBreakdown}</div>
|
||
<table width=""100%"" cellpadding=""0"" cellspacing=""0"" style=""border:1px solid #eee;border-radius:4px;font-size:13px"">
|
||
<!-- Legend -->
|
||
<tr style=""background:#f8f9fa"">
|
||
<td colspan=""2"" style=""padding:8px 10px;font-size:12px"">
|
||
{(showPv ? @$"<span style=""display:inline-block;width:10px;height:10px;background:#f39c12;border-radius:2px;margin-right:4px""></span>PV " : "")}
|
||
<span style=""display:inline-block;width:10px;height:10px;background:#3498db;border-radius:2px;margin-right:4px""></span>{s.Load}
|
||
{(showGrid ? @$"<span style=""display:inline-block;width:10px;height:10px;background:#e74c3c;border-radius:2px;margin-right:4px""></span>{s.GridIn}" : "")}
|
||
</td>
|
||
</tr>
|
||
{dailyRows}
|
||
</table>
|
||
</td>
|
||
</tr>
|
||
|
||
<!-- Footer -->
|
||
<tr>
|
||
<td style=""background:#f8f9fa;padding:16px 30px;text-align:center;font-size:12px;color:#999;border-top:1px solid #eee"">
|
||
{s.Footer}
|
||
</td>
|
||
</tr>
|
||
|
||
</table>
|
||
</td></tr>
|
||
</table>
|
||
</body>
|
||
</html>";
|
||
}
|
||
|
||
private static string SavingsBox(string label, string value, string subtitle, string color) =>
|
||
$@"<td width=""25%"" style=""text-align:center"">
|
||
<div style=""background:#f8f9fa;border-radius:6px;padding:12px 4px"">
|
||
<div style=""font-size:22px;font-weight:bold;color:{color}"">{value}</div>
|
||
<div style=""font-size:12px;font-weight:bold;color:#444;margin-top:4px"">{label}</div>
|
||
<div style=""font-size:10px;color:#888;margin-top:2px"">{subtitle}</div>
|
||
</div>
|
||
</td>";
|
||
|
||
// Bolds "Title" before first colon, and numbers+units in the rest
|
||
private static string FormatInsightLine(string line)
|
||
{
|
||
var colonIdx = line.IndexOf(':');
|
||
string result;
|
||
if (colonIdx > 0)
|
||
{
|
||
var title = line[..colonIdx];
|
||
var rest = line[colonIdx..]; // includes the colon
|
||
result = $"<strong>{title}</strong>{rest}";
|
||
}
|
||
else
|
||
{
|
||
result = line;
|
||
}
|
||
// Bold all numbers: time ranges (14:00–18:00), times (09:00), decimals, integers
|
||
result = System.Text.RegularExpressions.Regex.Replace(
|
||
result,
|
||
@"(\d{1,2}:\d{2}(?:[–\-]\d{1,2}:\d{2})?|\d+[.,]\d+|\d+)",
|
||
"<strong>$1</strong>");
|
||
return result;
|
||
}
|
||
|
||
private static string FormatChange(double pct) =>
|
||
pct == 0 ? "—" : pct > 0 ? $"+{pct:F1}%" : $"{pct:F1}%";
|
||
|
||
private static string ChangeColor(double pct) =>
|
||
pct > 0 ? "#27ae60" : pct < 0 ? "#e74c3c" : "#888";
|
||
}
|