diff --git a/csharp/App/Backend/Controller.cs b/csharp/App/Backend/Controller.cs index 46b4e3ad8..9ddc14614 100644 --- a/csharp/App/Backend/Controller.cs +++ b/csharp/App/Backend/Controller.cs @@ -919,6 +919,10 @@ public class Controller : ControllerBase { var lang = language ?? user.Language ?? "en"; var report = await WeeklyReportService.GenerateReportAsync(installationId, installation.InstallationName, lang); + + // Persist weekly summary for future monthly aggregation (idempotent) + ReportAggregationService.SaveWeeklySummary(installationId, report); + return Ok(report); } catch (Exception ex) @@ -956,6 +960,199 @@ public class Controller : ControllerBase } } + // ── Monthly & Yearly Reports ───────────────────────────────────── + + [HttpGet(nameof(GetPendingMonthlyAggregations))] + public ActionResult> GetPendingMonthlyAggregations(Int64 installationId, Token authToken) + { + var user = Db.GetSession(authToken)?.User; + if (user == null) + return Unauthorized(); + + var installation = Db.GetInstallationById(installationId); + if (installation is null || !user.HasAccessTo(installation)) + return Unauthorized(); + + return Ok(ReportAggregationService.GetPendingMonthlyAggregations(installationId)); + } + + [HttpGet(nameof(GetPendingYearlyAggregations))] + public ActionResult> GetPendingYearlyAggregations(Int64 installationId, Token authToken) + { + var user = Db.GetSession(authToken)?.User; + if (user == null) + return Unauthorized(); + + var installation = Db.GetInstallationById(installationId); + if (installation is null || !user.HasAccessTo(installation)) + return Unauthorized(); + + return Ok(ReportAggregationService.GetPendingYearlyAggregations(installationId)); + } + + [HttpGet(nameof(GetMonthlyReports))] + public ActionResult> GetMonthlyReports(Int64 installationId, Token authToken) + { + var user = Db.GetSession(authToken)?.User; + if (user == null) + return Unauthorized(); + + var installation = Db.GetInstallationById(installationId); + if (installation is null || !user.HasAccessTo(installation)) + return Unauthorized(); + + return Ok(Db.GetMonthlyReports(installationId)); + } + + [HttpGet(nameof(GetYearlyReports))] + public ActionResult> GetYearlyReports(Int64 installationId, Token authToken) + { + var user = Db.GetSession(authToken)?.User; + if (user == null) + return Unauthorized(); + + var installation = Db.GetInstallationById(installationId); + if (installation is null || !user.HasAccessTo(installation)) + return Unauthorized(); + + return Ok(Db.GetYearlyReports(installationId)); + } + + /// + /// Manually trigger monthly aggregation for an installation. + /// Aggregates weekly reports for the specified year/month into a monthly report. + /// + [HttpPost(nameof(TriggerMonthlyAggregation))] + public async Task TriggerMonthlyAggregation(Int64 installationId, Int32 year, Int32 month, Token authToken) + { + var user = Db.GetSession(authToken)?.User; + if (user == null) + return Unauthorized(); + + var installation = Db.GetInstallationById(installationId); + if (installation is null || !user.HasAccessTo(installation)) + return Unauthorized(); + + if (month < 1 || month > 12) + return BadRequest("Month must be between 1 and 12."); + + try + { + var lang = user.Language ?? "en"; + var weekCount = await ReportAggregationService.TriggerMonthlyAggregationAsync(installationId, year, month, lang); + if (weekCount == 0) + return NotFound($"No weekly reports found for {year}-{month:D2}."); + + return Ok(new { message = $"Monthly report created from {weekCount} weekly reports for {year}-{month:D2}." }); + } + catch (Exception ex) + { + Console.Error.WriteLine($"[TriggerMonthlyAggregation] Error: {ex.Message}"); + return BadRequest($"Failed to aggregate: {ex.Message}"); + } + } + + /// + /// Manually trigger yearly aggregation for an installation. + /// Aggregates monthly reports for the specified year into a yearly report. + /// + [HttpPost(nameof(TriggerYearlyAggregation))] + public async Task TriggerYearlyAggregation(Int64 installationId, Int32 year, Token authToken) + { + var user = Db.GetSession(authToken)?.User; + if (user == null) + return Unauthorized(); + + var installation = Db.GetInstallationById(installationId); + if (installation is null || !user.HasAccessTo(installation)) + return Unauthorized(); + + try + { + var lang = user.Language ?? "en"; + var monthCount = await ReportAggregationService.TriggerYearlyAggregationAsync(installationId, year, lang); + if (monthCount == 0) + return NotFound($"No monthly reports found for {year}."); + + return Ok(new { message = $"Yearly report created from {monthCount} monthly reports for {year}." }); + } + catch (Exception ex) + { + Console.Error.WriteLine($"[TriggerYearlyAggregation] Error: {ex.Message}"); + return BadRequest($"Failed to aggregate: {ex.Message}"); + } + } + + [HttpPost(nameof(SendMonthlyReportEmail))] + public async Task SendMonthlyReportEmail(Int64 installationId, Int32 year, Int32 month, String emailAddress, Token authToken) + { + var user = Db.GetSession(authToken)?.User; + if (user == null) + return Unauthorized(); + + var installation = Db.GetInstallationById(installationId); + if (installation is null || !user.HasAccessTo(installation)) + return Unauthorized(); + + var report = Db.GetMonthlyReports(installationId).FirstOrDefault(r => r.Year == year && r.Month == month); + if (report == null) + return BadRequest($"No monthly report found for {year}-{month:D2}."); + + try + { + var lang = user.Language ?? "en"; + await ReportEmailService.SendMonthlyReportEmailAsync(report, installation.InstallationName, emailAddress, lang); + return Ok(new { message = $"Monthly report sent to {emailAddress}" }); + } + catch (Exception ex) + { + Console.Error.WriteLine($"[SendMonthlyReportEmail] Error: {ex.Message}"); + return BadRequest($"Failed to send report: {ex.Message}"); + } + } + + [HttpPost(nameof(SendYearlyReportEmail))] + public async Task SendYearlyReportEmail(Int64 installationId, Int32 year, String emailAddress, Token authToken) + { + var user = Db.GetSession(authToken)?.User; + if (user == null) + return Unauthorized(); + + var installation = Db.GetInstallationById(installationId); + if (installation is null || !user.HasAccessTo(installation)) + return Unauthorized(); + + var report = Db.GetYearlyReports(installationId).FirstOrDefault(r => r.Year == year); + if (report == null) + return BadRequest($"No yearly report found for {year}."); + + try + { + var lang = user.Language ?? "en"; + await ReportEmailService.SendYearlyReportEmailAsync(report, installation.InstallationName, emailAddress, lang); + return Ok(new { message = $"Yearly report sent to {emailAddress}" }); + } + catch (Exception ex) + { + Console.Error.WriteLine($"[SendYearlyReportEmail] Error: {ex.Message}"); + return BadRequest($"Failed to send report: {ex.Message}"); + } + } + + [HttpGet(nameof(GetWeeklyReportSummaries))] + public ActionResult> GetWeeklyReportSummaries(Int64 installationId, Int32 year, Int32 month, Token authToken) + { + var user = Db.GetSession(authToken)?.User; + if (user == null) + return Unauthorized(); + + var installation = Db.GetInstallationById(installationId); + if (installation is null || !user.HasAccessTo(installation)) + return Unauthorized(); + + return Ok(Db.GetWeeklyReportsForMonth(installationId, year, month)); + } + [HttpPut(nameof(UpdateFolder))] public ActionResult UpdateFolder([FromBody] Folder folder, Token authToken) { @@ -963,7 +1160,7 @@ public class Controller : ControllerBase if (!session.Update(folder)) return Unauthorized(); - + return folder.HideParentIfUserHasNoAccessToParent(session!.User); } diff --git a/csharp/App/Backend/DataTypes/ReportSummary.cs b/csharp/App/Backend/DataTypes/ReportSummary.cs new file mode 100644 index 000000000..04cc93c76 --- /dev/null +++ b/csharp/App/Backend/DataTypes/ReportSummary.cs @@ -0,0 +1,149 @@ +using SQLite; + +namespace InnovEnergy.App.Backend.DataTypes; + +/// +/// Stored summary for a weekly report period. +/// Created when GetWeeklyReport is called. Consumed and deleted by monthly aggregation. +/// +public class WeeklyReportSummary +{ + [PrimaryKey, AutoIncrement] + public Int64 Id { get; set; } + + [Indexed] + public Int64 InstallationId { get; set; } + + // Period boundaries (ISO date strings: "2026-02-10") + public String PeriodStart { get; set; } = ""; + public String PeriodEnd { get; set; } = ""; + + // Energy totals (kWh) + public Double TotalPvProduction { get; set; } + public Double TotalConsumption { get; set; } + public Double TotalGridImport { get; set; } + public Double TotalGridExport { get; set; } + public Double TotalBatteryCharged { get; set; } + public Double TotalBatteryDischarged { get; set; } + + // Derived metrics + public Double TotalEnergySaved { get; set; } + public Double TotalSavingsCHF { get; set; } + public Double SelfSufficiencyPercent { get; set; } + public Double SelfConsumptionPercent { get; set; } + public Double BatteryEfficiencyPercent { get; set; } + public Double GridDependencyPercent { get; set; } + + // Behavioral highlights + public Int32 PeakLoadHour { get; set; } + public Int32 PeakSolarHour { get; set; } + public Double WeekdayAvgDailyLoad { get; set; } + public Double WeekendAvgDailyLoad { get; set; } + + // AI insight for this week + public String AiInsight { get; set; } = ""; + + public String CreatedAt { get; set; } = ""; +} + +/// +/// Aggregated monthly report. Created from weekly summaries at month-end. +/// Consumed and deleted by yearly aggregation. +/// +public class MonthlyReportSummary +{ + [PrimaryKey, AutoIncrement] + public Int64 Id { get; set; } + + [Indexed] + public Int64 InstallationId { get; set; } + + public Int32 Year { get; set; } + public Int32 Month { get; set; } + public String PeriodStart { get; set; } = ""; + public String PeriodEnd { get; set; } = ""; + + // Aggregated energy totals + public Double TotalPvProduction { get; set; } + public Double TotalConsumption { get; set; } + public Double TotalGridImport { get; set; } + public Double TotalGridExport { get; set; } + public Double TotalBatteryCharged { get; set; } + public Double TotalBatteryDischarged { get; set; } + + // Re-derived from aggregated totals + public Double TotalEnergySaved { get; set; } + public Double TotalSavingsCHF { get; set; } + public Double SelfSufficiencyPercent { get; set; } + public Double SelfConsumptionPercent { get; set; } + public Double BatteryEfficiencyPercent { get; set; } + public Double GridDependencyPercent { get; set; } + + // Averaged behavioral highlights + public Int32 AvgPeakLoadHour { get; set; } + public Int32 AvgPeakSolarHour { get; set; } + public Double AvgWeekdayDailyLoad { get; set; } + public Double AvgWeekendDailyLoad { get; set; } + + public Int32 WeekCount { get; set; } + public String AiInsight { get; set; } = ""; + public String CreatedAt { get; set; } = ""; +} + +/// +/// Aggregated yearly report. Created from monthly summaries at year-end. +/// Kept indefinitely. +/// +public class YearlyReportSummary +{ + [PrimaryKey, AutoIncrement] + public Int64 Id { get; set; } + + [Indexed] + public Int64 InstallationId { get; set; } + + public Int32 Year { get; set; } + public String PeriodStart { get; set; } = ""; + public String PeriodEnd { get; set; } = ""; + + // Aggregated energy totals + public Double TotalPvProduction { get; set; } + public Double TotalConsumption { get; set; } + public Double TotalGridImport { get; set; } + public Double TotalGridExport { get; set; } + public Double TotalBatteryCharged { get; set; } + public Double TotalBatteryDischarged { get; set; } + + // Re-derived from aggregated totals + public Double TotalEnergySaved { get; set; } + public Double TotalSavingsCHF { get; set; } + public Double SelfSufficiencyPercent { get; set; } + public Double SelfConsumptionPercent { get; set; } + public Double BatteryEfficiencyPercent { get; set; } + public Double GridDependencyPercent { get; set; } + + // Averaged behavioral highlights + public Int32 AvgPeakLoadHour { get; set; } + public Int32 AvgPeakSolarHour { get; set; } + public Double AvgWeekdayDailyLoad { get; set; } + public Double AvgWeekendDailyLoad { get; set; } + + public Int32 MonthCount { get; set; } + public String AiInsight { get; set; } = ""; + public String CreatedAt { get; set; } = ""; +} + +// ── DTOs for pending aggregation queries (not stored in DB) ── + +public class PendingMonth +{ + public Int32 Year { get; set; } + public Int32 Month { get; set; } + public Int32 WeekCount { get; set; } +} + +public class PendingYear +{ + public Int32 Year { get; set; } + public Int32 MonthCount { get; set; } +} diff --git a/csharp/App/Backend/Database/Create.cs b/csharp/App/Backend/Database/Create.cs index 9263b0793..04fe7d631 100644 --- a/csharp/App/Backend/Database/Create.cs +++ b/csharp/App/Backend/Database/Create.cs @@ -67,6 +67,10 @@ public static partial class Db { return Insert(action); } + + public static Boolean Create(WeeklyReportSummary report) => Insert(report); + public static Boolean Create(MonthlyReportSummary report) => Insert(report); + public static Boolean Create(YearlyReportSummary report) => Insert(report); public static void HandleAction(UserAction newAction) { diff --git a/csharp/App/Backend/Database/Db.cs b/csharp/App/Backend/Database/Db.cs index 5f10d3091..52c82b53e 100644 --- a/csharp/App/Backend/Database/Db.cs +++ b/csharp/App/Backend/Database/Db.cs @@ -25,7 +25,10 @@ public static partial class Db public static TableQuery Errors => Connection.Table(); public static TableQuery Warnings => Connection.Table(); public static TableQuery UserActions => Connection.Table(); - + public static TableQuery WeeklyReports => Connection.Table(); + public static TableQuery MonthlyReports => Connection.Table(); + public static TableQuery YearlyReports => Connection.Table(); + public static void Init() { @@ -51,6 +54,9 @@ public static partial class Db Connection.CreateTable(); Connection.CreateTable(); Connection.CreateTable(); + Connection.CreateTable(); + Connection.CreateTable(); + Connection.CreateTable(); }); // One-time migration: normalize legacy long-form language values to ISO codes @@ -88,6 +94,9 @@ public static partial class Db fileConnection.CreateTable(); fileConnection.CreateTable(); fileConnection.CreateTable(); + fileConnection.CreateTable(); + fileConnection.CreateTable(); + fileConnection.CreateTable(); return fileConnection; //return CopyDbToMemory(fileConnection); diff --git a/csharp/App/Backend/Database/Delete.cs b/csharp/App/Backend/Database/Delete.cs index a4dd5a748..f39eb6dd0 100644 --- a/csharp/App/Backend/Database/Delete.cs +++ b/csharp/App/Backend/Database/Delete.cs @@ -141,4 +141,43 @@ public static partial class Db { OrderNumber2Installation.Delete(s => s.InstallationId == relation.InstallationId && s.OrderNumber == relation.OrderNumber); } + + public static void DeleteWeeklyReportsForMonth(Int64 installationId, Int32 year, Int32 month) + { + var monthStart = $"{year:D4}-{month:D2}-01"; + var monthEnd = month == 12 ? $"{year + 1:D4}-01-01" : $"{year:D4}-{month + 1:D2}-01"; + + // SQLite-net doesn't support string comparison in Delete lambda, + // so fetch matching IDs first, then delete by ID. + var ids = WeeklyReports + .Where(r => r.InstallationId == installationId) + .ToList() + .Where(r => String.Compare(r.PeriodStart, monthStart, StringComparison.Ordinal) >= 0 + && String.Compare(r.PeriodStart, monthEnd, StringComparison.Ordinal) < 0) + .Select(r => r.Id) + .ToList(); + + foreach (var id in ids) + WeeklyReports.Delete(r => r.Id == id); + + if (ids.Count > 0) Backup(); + } + + public static void DeleteMonthlyReportsForYear(Int64 installationId, Int32 year) + { + MonthlyReports.Delete(r => r.InstallationId == installationId && r.Year == year); + Backup(); + } + + public static void DeleteMonthlyReport(Int64 installationId, Int32 year, Int32 month) + { + var count = MonthlyReports.Delete(r => r.InstallationId == installationId && r.Year == year && r.Month == month); + if (count > 0) Backup(); + } + + public static void DeleteYearlyReport(Int64 installationId, Int32 year) + { + var count = YearlyReports.Delete(r => r.InstallationId == installationId && r.Year == year); + if (count > 0) Backup(); + } } \ No newline at end of file diff --git a/csharp/App/Backend/Database/Read.cs b/csharp/App/Backend/Database/Read.cs index f47298cb3..68bfa2015 100644 --- a/csharp/App/Backend/Database/Read.cs +++ b/csharp/App/Backend/Database/Read.cs @@ -56,4 +56,42 @@ public static partial class Db return session; } + + // ── Report Queries ──────────────────────────────────────────────── + + public static List GetWeeklyReports(Int64 installationId) + => WeeklyReports + .Where(r => r.InstallationId == installationId) + .OrderByDescending(r => r.PeriodStart) + .ToList(); + + public static List GetWeeklyReportsForMonth(Int64 installationId, Int32 year, Int32 month) + { + var monthStart = $"{year:D4}-{month:D2}-01"; + var monthEnd = month == 12 ? $"{year + 1:D4}-01-01" : $"{year:D4}-{month + 1:D2}-01"; + return WeeklyReports + .Where(r => r.InstallationId == installationId) + .ToList() + .Where(r => String.Compare(r.PeriodStart, monthStart, StringComparison.Ordinal) >= 0 + && String.Compare(r.PeriodStart, monthEnd, StringComparison.Ordinal) < 0) + .ToList(); + } + + public static List GetMonthlyReports(Int64 installationId) + => MonthlyReports + .Where(r => r.InstallationId == installationId) + .OrderByDescending(r => r.Year) + .ThenByDescending(r => r.Month) + .ToList(); + + public static List GetMonthlyReportsForYear(Int64 installationId, Int32 year) + => MonthlyReports + .Where(r => r.InstallationId == installationId && r.Year == year) + .ToList(); + + public static List GetYearlyReports(Int64 installationId) + => YearlyReports + .Where(r => r.InstallationId == installationId) + .OrderByDescending(r => r.Year) + .ToList(); } \ No newline at end of file diff --git a/csharp/App/Backend/Program.cs b/csharp/App/Backend/Program.cs index 0ae6dc02e..73350b51c 100644 --- a/csharp/App/Backend/Program.cs +++ b/csharp/App/Backend/Program.cs @@ -28,6 +28,7 @@ public static class Program LoadEnvFile(); DiagnosticService.Initialize(); AlarmReviewService.StartDailyScheduler(); + // ReportAggregationService.StartScheduler(); // TODO: uncomment to enable automatic monthly/yearly aggregation var builder = WebApplication.CreateBuilder(args); RabbitMqManager.InitializeEnvironment(); diff --git a/csharp/App/Backend/Services/ReportAggregationService.cs b/csharp/App/Backend/Services/ReportAggregationService.cs new file mode 100644 index 000000000..9100bcab0 --- /dev/null +++ b/csharp/App/Backend/Services/ReportAggregationService.cs @@ -0,0 +1,627 @@ +using Flurl.Http; +using InnovEnergy.App.Backend.Database; +using InnovEnergy.App.Backend.DataTypes; +using Newtonsoft.Json; + +namespace InnovEnergy.App.Backend.Services; + +public static class ReportAggregationService +{ + private static Timer? _monthEndTimer; + private static Timer? _yearEndTimer; + // private static Timer? _sundayReportTimer; + + private const Double ElectricityPriceCHF = 0.39; + private static readonly String MistralUrl = "https://api.mistral.ai/v1/chat/completions"; + + // ── Scheduler ───────────────────────────────────────────────────── + + public static void StartScheduler() + { + // ScheduleSundayWeeklyReport(); + ScheduleMonthEndJob(); + ScheduleYearEndJob(); + Console.WriteLine("[ReportAggregation] Scheduler started."); + } + + private static void ScheduleMonthEndJob() + { + // Run daily at 02:00, but only act on the 1st of the month + var now = DateTime.Now; + var next = now.Date.AddHours(2); + if (now >= next) next = next.AddDays(1); + + _monthEndTimer = new Timer( + _ => + { + try + { + if (DateTime.Now.Day == 1) + RunMonthEndAggregation().GetAwaiter().GetResult(); + } + catch (Exception ex) + { + Console.Error.WriteLine($"[ReportAggregation] Month-end error: {ex.Message}"); + } + }, + null, next - now, TimeSpan.FromDays(1)); + + Console.WriteLine($"[ReportAggregation] Month-end timer scheduled (daily 02:00, acts on 1st). Next check: {next:yyyy-MM-dd HH:mm}"); + } + + private static void ScheduleYearEndJob() + { + // Run daily at 03:00, but only act on Jan 2nd + var now = DateTime.Now; + var next = now.Date.AddHours(3); + if (now >= next) next = next.AddDays(1); + + _yearEndTimer = new Timer( + _ => + { + try + { + if (DateTime.Now.Month == 1 && DateTime.Now.Day == 2) + RunYearEndAggregation().GetAwaiter().GetResult(); + } + catch (Exception ex) + { + Console.Error.WriteLine($"[ReportAggregation] Year-end error: {ex.Message}"); + } + }, + null, next - now, TimeSpan.FromDays(1)); + + Console.WriteLine($"[ReportAggregation] Year-end timer scheduled (daily 03:00, acts on Jan 2). Next check: {next:yyyy-MM-dd HH:mm}"); + } + + // ── Sunday Weekly Report Automation ───────────────────────────── + // Generates weekly reports (Sunday–Saturday) for all SodiStoreHome + // installations every Sunday at 06:00, saves summary to DB, and + // emails the report to all users who have access to the installation. + // + // TODO: uncomment ScheduleSundayWeeklyReport() in StartScheduler() to enable. + + // private static void ScheduleSundayWeeklyReport() + // { + // // Calculate delay until next Sunday 06:00 + // var now = DateTime.Now; + // var daysUntil = ((int)DayOfWeek.Sunday - (int)now.DayOfWeek + 7) % 7; + // var nextSunday = now.Date.AddDays(daysUntil == 0 && now.Hour >= 6 ? 7 : daysUntil).AddHours(6); + // + // _sundayReportTimer = new Timer( + // _ => + // { + // try + // { + // if (DateTime.Now.DayOfWeek == DayOfWeek.Sunday) + // RunSundayWeeklyReports().GetAwaiter().GetResult(); + // } + // catch (Exception ex) + // { + // Console.Error.WriteLine($"[ReportAggregation] Sunday report error: {ex.Message}"); + // } + // }, + // null, nextSunday - now, TimeSpan.FromDays(7)); + // + // Console.WriteLine($"[ReportAggregation] Sunday weekly report scheduled. Next run: {nextSunday:yyyy-MM-dd HH:mm}"); + // } + // + // private static async Task RunSundayWeeklyReports() + // { + // Console.WriteLine("[ReportAggregation] Running Sunday weekly report generation..."); + // + // // Find all SodiStoreHome installations + // var installations = Db.Installations + // .Where(i => i.Product == (int)ProductType.SodioHome) + // .ToList(); + // + // foreach (var installation in installations) + // { + // try + // { + // // Generate the weekly report (covers last Sunday–Saturday) + // var report = await WeeklyReportService.GenerateReportAsync( + // installation.Id, installation.InstallationName, "en"); + // + // // Save summary to DB for future monthly aggregation + // SaveWeeklySummary(installation.Id, report); + // + // // Email the report to all users who have access to this installation + // var userIds = Db.InstallationAccess + // .Where(a => a.InstallationId == installation.Id) + // .Select(a => a.UserId) + // .ToList(); + // + // foreach (var userId in userIds) + // { + // var user = Db.GetUserById(userId); + // if (user == null || String.IsNullOrWhiteSpace(user.Email)) + // continue; + // + // try + // { + // var lang = user.Language ?? "en"; + // // Regenerate with user's language if different from "en" + // var localizedReport = lang == "en" + // ? report + // : await WeeklyReportService.GenerateReportAsync( + // installation.Id, installation.InstallationName, lang); + // + // await ReportEmailService.SendReportEmailAsync(localizedReport, user.Email, lang); + // Console.WriteLine($"[ReportAggregation] Weekly report emailed to {user.Email} for installation {installation.Id}"); + // } + // catch (Exception emailEx) + // { + // Console.Error.WriteLine($"[ReportAggregation] Failed to email {user.Email}: {emailEx.Message}"); + // } + // } + // } + // catch (Exception ex) + // { + // Console.Error.WriteLine($"[ReportAggregation] Failed weekly report for installation {installation.Id}: {ex.Message}"); + // } + // } + // + // Console.WriteLine("[ReportAggregation] Sunday weekly report generation complete."); + // } + + // ── Save Weekly Summary ─────────────────────────────────────────── + + public static void SaveWeeklySummary(Int64 installationId, WeeklyReportResponse report) + { + try + { + // Remove any existing weekly records whose date range overlaps with this report. + // Two periods overlap when: existingStart < newEnd AND newStart < existingEnd. + // This prevents double-counting when the same days appear in different report windows + // (e.g., report for days 1-7, then later 2-8 — the old 1-7 record is removed). + var overlapping = Db.WeeklyReports + .Where(r => r.InstallationId == installationId) + .ToList() + .Where(r => String.Compare(r.PeriodStart, report.PeriodEnd, StringComparison.Ordinal) < 0 + && String.Compare(report.PeriodStart, r.PeriodEnd, StringComparison.Ordinal) < 0) + .ToList(); + + foreach (var old in overlapping) + Db.WeeklyReports.Delete(r => r.Id == old.Id); + + var summary = new WeeklyReportSummary + { + InstallationId = installationId, + PeriodStart = report.PeriodStart, + PeriodEnd = report.PeriodEnd, + TotalPvProduction = report.CurrentWeek.TotalPvProduction, + TotalConsumption = report.CurrentWeek.TotalConsumption, + TotalGridImport = report.CurrentWeek.TotalGridImport, + TotalGridExport = report.CurrentWeek.TotalGridExport, + TotalBatteryCharged = report.CurrentWeek.TotalBatteryCharged, + TotalBatteryDischarged = report.CurrentWeek.TotalBatteryDischarged, + TotalEnergySaved = report.TotalEnergySaved, + TotalSavingsCHF = report.TotalSavingsCHF, + SelfSufficiencyPercent = report.SelfSufficiencyPercent, + SelfConsumptionPercent = report.SelfConsumptionPercent, + BatteryEfficiencyPercent = report.BatteryEfficiencyPercent, + GridDependencyPercent = report.GridDependencyPercent, + PeakLoadHour = report.Behavior?.PeakLoadHour ?? 0, + PeakSolarHour = report.Behavior?.PeakSolarHour ?? 0, + WeekdayAvgDailyLoad = report.Behavior?.WeekdayAvgDailyLoad ?? 0, + WeekendAvgDailyLoad = report.Behavior?.WeekendAvgDailyLoad ?? 0, + AiInsight = report.AiInsight, + CreatedAt = DateTime.UtcNow.ToString("o"), + }; + + Db.Create(summary); + Console.WriteLine($"[ReportAggregation] Saved weekly summary for installation {installationId}, period {report.PeriodStart}–{report.PeriodEnd} (replaced {overlapping.Count} overlapping record(s))"); + } + catch (Exception ex) + { + Console.Error.WriteLine($"[ReportAggregation] Failed to save weekly summary for installation {installationId}: {ex.Message}"); + } + } + + // ── Pending Aggregation Queries ───────────────────────────────── + + /// + /// Returns months that have weekly data but no monthly report yet. + /// Each entry: { Year, Month, WeekCount }. + /// + public static List GetPendingMonthlyAggregations(Int64 installationId) + { + var weeklies = Db.GetWeeklyReports(installationId); + var existingMonths = Db.GetMonthlyReports(installationId) + .Select(m => (m.Year, m.Month)) + .ToHashSet(); + + return weeklies + .GroupBy(w => + { + var date = DateTime.Parse(w.PeriodStart); + return (Year: date.Year, Month: date.Month); + }) + .Where(g => !existingMonths.Contains(g.Key)) + .Select(g => new PendingMonth { Year = g.Key.Year, Month = g.Key.Month, WeekCount = g.Count() }) + .OrderByDescending(p => p.Year).ThenByDescending(p => p.Month) + .ToList(); + } + + /// + /// Returns years that have monthly data but no yearly report yet. + /// Each entry: { Year, MonthCount }. + /// + public static List GetPendingYearlyAggregations(Int64 installationId) + { + var monthlies = Db.GetMonthlyReports(installationId); + var existingYears = Db.GetYearlyReports(installationId) + .Select(y => y.Year) + .ToHashSet(); + + return monthlies + .GroupBy(m => m.Year) + .Where(g => !existingYears.Contains(g.Key)) + .Select(g => new PendingYear { Year = g.Key, MonthCount = g.Count() }) + .OrderByDescending(p => p.Year) + .ToList(); + } + + // ── Month-End Aggregation ───────────────────────────────────────── + + /// + /// Trigger monthly aggregation for a specific installation and month. + /// Re-generates if a monthly report already exists. Weekly records are kept. + /// Returns the number of weekly records aggregated (0 = no data). + /// + public static async Task TriggerMonthlyAggregationAsync(Int64 installationId, Int32 year, Int32 month, String language = "en") + { + var weeklies = Db.GetWeeklyReportsForMonth(installationId, year, month); + if (weeklies.Count == 0) + return 0; + + await AggregateMonthForInstallation(installationId, year, month, language); + return weeklies.Count; + } + + /// + /// Trigger yearly aggregation for a specific installation and year. + /// Re-generates if a yearly report already exists. Monthly records are kept. + /// Returns the number of monthly records aggregated (0 = no data). + /// + public static async Task TriggerYearlyAggregationAsync(Int64 installationId, Int32 year, String language = "en") + { + var monthlies = Db.GetMonthlyReportsForYear(installationId, year); + if (monthlies.Count == 0) + return 0; + + await AggregateYearForInstallation(installationId, year, language); + return monthlies.Count; + } + + private static async Task RunMonthEndAggregation() + { + var previousMonth = DateTime.Now.AddMonths(-1); + var year = previousMonth.Year; + var month = previousMonth.Month; + + Console.WriteLine($"[ReportAggregation] Running month-end aggregation for {year}-{month:D2}..."); + + var installationIds = Db.WeeklyReports + .Select(r => r.InstallationId) + .ToList() + .Distinct() + .ToList(); + + foreach (var installationId in installationIds) + { + try + { + await AggregateMonthForInstallation(installationId, year, month); + + // Scheduler mode: clean up weekly records after successful aggregation. + // The monthly report is now the source of truth for this month. + Db.DeleteWeeklyReportsForMonth(installationId, year, month); + Console.WriteLine($"[ReportAggregation] Cleaned up weekly records for installation {installationId}, {year}-{month:D2}."); + } + catch (Exception ex) + { + Console.Error.WriteLine($"[ReportAggregation] Month aggregation failed for installation {installationId}: {ex.Message}"); + } + } + } + + private static async Task AggregateMonthForInstallation(Int64 installationId, Int32 year, Int32 month, String language = "en") + { + var weeklies = Db.GetWeeklyReportsForMonth(installationId, year, month); + if (weeklies.Count == 0) + return; + + // If monthly report already exists, delete it so we can re-generate with latest weekly data. + // This supports partial months and re-aggregation when new weekly data arrives. + Db.DeleteMonthlyReport(installationId, year, month); + + // Sum energy totals + var totalPv = Math.Round(weeklies.Sum(w => w.TotalPvProduction), 1); + var totalConsump = Math.Round(weeklies.Sum(w => w.TotalConsumption), 1); + var totalGridIn = Math.Round(weeklies.Sum(w => w.TotalGridImport), 1); + var totalGridOut = Math.Round(weeklies.Sum(w => w.TotalGridExport), 1); + var totalBattChg = Math.Round(weeklies.Sum(w => w.TotalBatteryCharged), 1); + var totalBattDis = Math.Round(weeklies.Sum(w => w.TotalBatteryDischarged), 1); + + // Re-derive ratios from aggregated totals + var energySaved = Math.Round(totalConsump - totalGridIn, 1); + var savingsCHF = Math.Round(energySaved * ElectricityPriceCHF, 0); + var selfSufficiency = totalConsump > 0 ? Math.Round((totalConsump - totalGridIn) / totalConsump * 100, 1) : 0; + var selfConsumption = totalPv > 0 ? Math.Round((totalPv - totalGridOut) / totalPv * 100, 1) : 0; + var batteryEff = totalBattChg > 0 ? Math.Round(totalBattDis / totalBattChg * 100, 1) : 0; + var gridDependency = totalConsump > 0 ? Math.Round(totalGridIn / totalConsump * 100, 1) : 0; + + // Average behavioral highlights + var avgPeakLoad = (Int32)Math.Round(weeklies.Average(w => w.PeakLoadHour)); + var avgPeakSolar = (Int32)Math.Round(weeklies.Average(w => w.PeakSolarHour)); + var avgWeekdayLoad = Math.Round(weeklies.Average(w => w.WeekdayAvgDailyLoad), 1); + var avgWeekendLoad = Math.Round(weeklies.Average(w => w.WeekendAvgDailyLoad), 1); + + // Get installation name for AI insight + var installation = Db.GetInstallationById(installationId); + var installationName = installation?.InstallationName ?? $"Installation {installationId}"; + var monthName = new DateTime(year, month, 1).ToString("MMMM yyyy"); + + var aiInsight = await GenerateMonthlyAiInsightAsync( + installationName, monthName, weeklies.Count, + totalPv, totalConsump, totalGridIn, totalGridOut, + totalBattChg, totalBattDis, energySaved, savingsCHF, + selfSufficiency, batteryEff, language); + + var monthlySummary = new MonthlyReportSummary + { + InstallationId = installationId, + Year = year, + Month = month, + PeriodStart = weeklies.Min(w => w.PeriodStart), + PeriodEnd = weeklies.Max(w => w.PeriodEnd), + TotalPvProduction = totalPv, + TotalConsumption = totalConsump, + TotalGridImport = totalGridIn, + TotalGridExport = totalGridOut, + TotalBatteryCharged = totalBattChg, + TotalBatteryDischarged = totalBattDis, + TotalEnergySaved = energySaved, + TotalSavingsCHF = savingsCHF, + SelfSufficiencyPercent = selfSufficiency, + SelfConsumptionPercent = selfConsumption, + BatteryEfficiencyPercent = batteryEff, + GridDependencyPercent = gridDependency, + AvgPeakLoadHour = avgPeakLoad, + AvgPeakSolarHour = avgPeakSolar, + AvgWeekdayDailyLoad = avgWeekdayLoad, + AvgWeekendDailyLoad = avgWeekendLoad, + WeekCount = weeklies.Count, + AiInsight = aiInsight, + CreatedAt = DateTime.UtcNow.ToString("o"), + }; + + Db.Create(monthlySummary); + // Weekly records are kept — allows re-generation if new weekly data arrives. + // Cleanup can be done later by the automated scheduler or manually. + + Console.WriteLine($"[ReportAggregation] Monthly report created for installation {installationId}, {year}-{month:D2} ({weeklies.Count} weeks aggregated)."); + } + + // ── Year-End Aggregation ────────────────────────────────────────── + + private static async Task RunYearEndAggregation() + { + var previousYear = DateTime.Now.Year - 1; + + Console.WriteLine($"[ReportAggregation] Running year-end aggregation for {previousYear}..."); + + var installationIds = Db.MonthlyReports + .Where(r => r.Year == previousYear) + .Select(r => r.InstallationId) + .ToList() + .Distinct() + .ToList(); + + foreach (var installationId in installationIds) + { + try + { + await AggregateYearForInstallation(installationId, previousYear); + + // Scheduler mode: clean up monthly records after successful aggregation. + // The yearly report is now the source of truth for this year. + Db.DeleteMonthlyReportsForYear(installationId, previousYear); + Console.WriteLine($"[ReportAggregation] Cleaned up monthly records for installation {installationId}, {previousYear}."); + } + catch (Exception ex) + { + Console.Error.WriteLine($"[ReportAggregation] Year aggregation failed for installation {installationId}: {ex.Message}"); + } + } + } + + private static async Task AggregateYearForInstallation(Int64 installationId, Int32 year, String language = "en") + { + var monthlies = Db.GetMonthlyReportsForYear(installationId, year); + if (monthlies.Count == 0) + return; + + // If yearly report already exists, delete it so we can re-generate with latest monthly data. + Db.DeleteYearlyReport(installationId, year); + + // Sum energy totals + var totalPv = Math.Round(monthlies.Sum(m => m.TotalPvProduction), 1); + var totalConsump = Math.Round(monthlies.Sum(m => m.TotalConsumption), 1); + var totalGridIn = Math.Round(monthlies.Sum(m => m.TotalGridImport), 1); + var totalGridOut = Math.Round(monthlies.Sum(m => m.TotalGridExport), 1); + var totalBattChg = Math.Round(monthlies.Sum(m => m.TotalBatteryCharged), 1); + var totalBattDis = Math.Round(monthlies.Sum(m => m.TotalBatteryDischarged), 1); + + // Re-derive ratios + var energySaved = Math.Round(totalConsump - totalGridIn, 1); + var savingsCHF = Math.Round(energySaved * ElectricityPriceCHF, 0); + var selfSufficiency = totalConsump > 0 ? Math.Round((totalConsump - totalGridIn) / totalConsump * 100, 1) : 0; + var selfConsumption = totalPv > 0 ? Math.Round((totalPv - totalGridOut) / totalPv * 100, 1) : 0; + var batteryEff = totalBattChg > 0 ? Math.Round(totalBattDis / totalBattChg * 100, 1) : 0; + var gridDependency = totalConsump > 0 ? Math.Round(totalGridIn / totalConsump * 100, 1) : 0; + + var avgPeakLoad = (Int32)Math.Round(monthlies.Average(m => m.AvgPeakLoadHour)); + var avgPeakSolar = (Int32)Math.Round(monthlies.Average(m => m.AvgPeakSolarHour)); + var avgWeekdayLoad = Math.Round(monthlies.Average(m => m.AvgWeekdayDailyLoad), 1); + var avgWeekendLoad = Math.Round(monthlies.Average(m => m.AvgWeekendDailyLoad), 1); + + var installation = Db.GetInstallationById(installationId); + var installationName = installation?.InstallationName ?? $"Installation {installationId}"; + + var aiInsight = await GenerateYearlyAiInsightAsync( + installationName, year, monthlies.Count, + totalPv, totalConsump, totalGridIn, totalGridOut, + totalBattChg, totalBattDis, energySaved, savingsCHF, + selfSufficiency, batteryEff, language); + + var yearlySummary = new YearlyReportSummary + { + InstallationId = installationId, + Year = year, + PeriodStart = monthlies.Min(m => m.PeriodStart), + PeriodEnd = monthlies.Max(m => m.PeriodEnd), + TotalPvProduction = totalPv, + TotalConsumption = totalConsump, + TotalGridImport = totalGridIn, + TotalGridExport = totalGridOut, + TotalBatteryCharged = totalBattChg, + TotalBatteryDischarged = totalBattDis, + TotalEnergySaved = energySaved, + TotalSavingsCHF = savingsCHF, + SelfSufficiencyPercent = selfSufficiency, + SelfConsumptionPercent = selfConsumption, + BatteryEfficiencyPercent = batteryEff, + GridDependencyPercent = gridDependency, + AvgPeakLoadHour = avgPeakLoad, + AvgPeakSolarHour = avgPeakSolar, + AvgWeekdayDailyLoad = avgWeekdayLoad, + AvgWeekendDailyLoad = avgWeekendLoad, + MonthCount = monthlies.Count, + AiInsight = aiInsight, + CreatedAt = DateTime.UtcNow.ToString("o"), + }; + + Db.Create(yearlySummary); + // Monthly records are kept — allows re-generation if new monthly data arrives. + + Console.WriteLine($"[ReportAggregation] Yearly report created for installation {installationId}, {year} ({monthlies.Count} months aggregated)."); + } + + // ── AI Insight Generation ───────────────────────────────────────── + + private static String GetLanguageName(String code) => code switch + { + "de" => "German", + "fr" => "French", + "it" => "Italian", + _ => "English" + }; + + private static async Task GenerateMonthlyAiInsightAsync( + String installationName, String monthName, Int32 weekCount, + Double totalPv, Double totalConsump, Double totalGridIn, Double totalGridOut, + Double totalBattChg, Double totalBattDis, + Double energySaved, Double savingsCHF, + Double selfSufficiency, Double batteryEff, + String language = "en") + { + var apiKey = Environment.GetEnvironmentVariable("MISTRAL_API_KEY"); + if (String.IsNullOrWhiteSpace(apiKey)) + return "AI insight unavailable (API key not configured)."; + + var langName = GetLanguageName(language); + var prompt = $@"You are an energy advisor for a sodistore home installation: ""{installationName}"". + +Write a concise monthly performance summary in {langName} (4 bullet points, plain text, no markdown). + +MONTHLY FACTS for {monthName} ({weekCount} weeks): +- Total PV production: {totalPv:F1} kWh +- Total consumption: {totalConsump:F1} kWh +- Total grid import: {totalGridIn:F1} kWh, export: {totalGridOut:F1} kWh +- Battery: {totalBattChg:F1} kWh charged, {totalBattDis:F1} kWh discharged +- Energy saved from grid: {energySaved:F1} kWh = ~{savingsCHF:F0} CHF (at {ElectricityPriceCHF} CHF/kWh) +- Self-sufficiency: {selfSufficiency:F1}% +- Battery efficiency: {batteryEff:F1}% + +INSTRUCTIONS: +1. Monthly savings summary: total energy saved and money saved this month. Use the exact numbers provided. +2. Solar & battery performance: comment on PV production and battery utilization. +3. Grid dependency: note grid import relative to total consumption. +4. Recommendation for next month: one actionable suggestion. + +Rules: Write in {langName}. Write for a homeowner. No asterisks or formatting marks. No closing remarks. Exactly 4 bullet points starting with ""- "". Each bullet must have a short title followed by colon then description."; + + return await CallMistralAsync(apiKey, prompt); + } + + private static async Task GenerateYearlyAiInsightAsync( + String installationName, Int32 year, Int32 monthCount, + Double totalPv, Double totalConsump, Double totalGridIn, Double totalGridOut, + Double totalBattChg, Double totalBattDis, + Double energySaved, Double savingsCHF, + Double selfSufficiency, Double batteryEff, + String language = "en") + { + var apiKey = Environment.GetEnvironmentVariable("MISTRAL_API_KEY"); + if (String.IsNullOrWhiteSpace(apiKey)) + return "AI insight unavailable (API key not configured)."; + + var langName = GetLanguageName(language); + var prompt = $@"You are an energy advisor for a sodistore home installation: ""{installationName}"". + +Write a concise annual performance summary in {langName} (4 bullet points, plain text, no markdown). + +ANNUAL FACTS for {year} ({monthCount} months of data): +- Total PV production: {totalPv:F1} kWh +- Total consumption: {totalConsump:F1} kWh +- Total grid import: {totalGridIn:F1} kWh, export: {totalGridOut:F1} kWh +- Battery: {totalBattChg:F1} kWh charged, {totalBattDis:F1} kWh discharged +- Energy saved from grid: {energySaved:F1} kWh = ~{savingsCHF:F0} CHF (at {ElectricityPriceCHF} CHF/kWh) +- Self-sufficiency: {selfSufficiency:F1}% +- Battery efficiency: {batteryEff:F1}% + +INSTRUCTIONS: +1. Annual savings highlight: total energy and money saved for the year. Use the exact numbers provided. +2. System performance: comment on PV production and battery health indicators. +3. Year-over-year readiness: note any trends or areas of improvement. +4. Looking ahead: one strategic recommendation for the coming year. + +Rules: Write in {langName}. Write for a homeowner. No asterisks or formatting marks. No closing remarks. Exactly 4 bullet points starting with ""- "". Each bullet must have a short title followed by colon then description."; + + return await CallMistralAsync(apiKey, prompt); + } + + private static async Task CallMistralAsync(String apiKey, String prompt) + { + try + { + var requestBody = new + { + model = "mistral-small-latest", + messages = new[] { new { role = "user", content = prompt } }, + max_tokens = 400, + temperature = 0.3 + }; + + var responseText = await MistralUrl + .WithHeader("Authorization", $"Bearer {apiKey}") + .PostJsonAsync(requestBody) + .ReceiveString(); + + var envelope = JsonConvert.DeserializeObject(responseText); + var content = (String?)envelope?.choices?[0]?.message?.content; + + if (!String.IsNullOrWhiteSpace(content)) + return content.Trim(); + } + catch (Exception ex) + { + Console.Error.WriteLine($"[ReportAggregation] Mistral error: {ex.Message}"); + } + + return "AI insight could not be generated at this time."; + } +} diff --git a/csharp/App/Backend/Services/ReportEmailService.cs b/csharp/App/Backend/Services/ReportEmailService.cs index 230f7c365..a00a74f6a 100644 --- a/csharp/App/Backend/Services/ReportEmailService.cs +++ b/csharp/App/Backend/Services/ReportEmailService.cs @@ -443,4 +443,230 @@ public static class ReportEmailService private static string ChangeColor(double pct) => pct > 0 ? "#27ae60" : pct < 0 ? "#e74c3c" : "#888"; + + // ── Monthly / Yearly Report Emails ──────────────────────────────────── + + private static readonly string[] MonthNamesEn = { "", "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December" }; + private static readonly string[] MonthNamesDe = { "", "Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember" }; + private static readonly string[] MonthNamesFr = { "", "Janvier", "Février", "Mars", "Avril", "Mai", "Juin", "Juillet", "Août", "Septembre", "Octobre", "Novembre", "Décembre" }; + private static readonly string[] MonthNamesIt = { "", "Gennaio", "Febbraio", "Marzo", "Aprile", "Maggio", "Giugno", "Luglio", "Agosto", "Settembre", "Ottobre", "Novembre", "Dicembre" }; + + public static async Task SendMonthlyReportEmailAsync( + MonthlyReportSummary report, + string installationName, + string recipientEmail, + string language = "en") + { + var monthNames = language switch { "de" => MonthNamesDe, "fr" => MonthNamesFr, "it" => MonthNamesIt, _ => MonthNamesEn }; + var monthName = report.Month >= 1 && report.Month <= 12 ? monthNames[report.Month] : report.Month.ToString(); + var s = GetAggregatedStrings(language, "monthly"); + var subject = $"{s.Title} — {installationName} ({monthName} {report.Year})"; + var html = BuildAggregatedHtmlEmail(report.PeriodStart, report.PeriodEnd, installationName, + report.TotalPvProduction, report.TotalConsumption, report.TotalGridImport, report.TotalGridExport, + report.TotalBatteryCharged, report.TotalBatteryDischarged, report.TotalEnergySaved, report.TotalSavingsCHF, + report.SelfSufficiencyPercent, report.BatteryEfficiencyPercent, report.AiInsight, + $"{report.WeekCount} {s.CountLabel}", s); + + await SendHtmlEmailAsync(subject, html, recipientEmail); + } + + public static async Task SendYearlyReportEmailAsync( + YearlyReportSummary report, + string installationName, + string recipientEmail, + string language = "en") + { + var s = GetAggregatedStrings(language, "yearly"); + var subject = $"{s.Title} — {installationName} ({report.Year})"; + var html = BuildAggregatedHtmlEmail(report.PeriodStart, report.PeriodEnd, installationName, + report.TotalPvProduction, report.TotalConsumption, report.TotalGridImport, report.TotalGridExport, + report.TotalBatteryCharged, report.TotalBatteryDischarged, report.TotalEnergySaved, report.TotalSavingsCHF, + report.SelfSufficiencyPercent, report.BatteryEfficiencyPercent, report.AiInsight, + $"{report.MonthCount} {s.CountLabel}", s); + + await SendHtmlEmailAsync(subject, html, recipientEmail); + } + + private static async Task SendHtmlEmailAsync(string subject, string html, string recipientEmail) + { + 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 } + }; + + 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}"); + } + + // ── Aggregated report translation strings ───────────────────────────── + + private record AggregatedEmailStrings( + string Title, string Insights, string Summary, string SavingsHeader, + string Metric, string Total, 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 CountLabel, string Footer + ); + + private static AggregatedEmailStrings GetAggregatedStrings(string language, string type) => (language, type) switch + { + ("de", "monthly") => new AggregatedEmailStrings( + "Monatlicher Leistungsbericht", "Monatliche Erkenntnisse", "Monatliche Zusammenfassung", "Ihre Ersparnisse diesen Monat", + "Kennzahl", "Gesamt", "PV-Produktion", "Verbrauch", "Netzbezug", "Netzeinspeisung", "Batterie Laden / Entladen", + "Energie gespart", "Solar + Batterie, nicht vom Netz", "Geschätzte Ersparnis", "bei 0.39 CHF/kWh", + "Eigenversorgung", "aus Solar + Batterie", "Batterie-Eff.", "Entladung vs. Ladung", + "Wochen aggregiert", "Erstellt von inesco Energy Monitor"), + ("de", "yearly") => new AggregatedEmailStrings( + "Jährlicher Leistungsbericht", "Jährliche Erkenntnisse", "Jährliche Zusammenfassung", "Ihre Ersparnisse dieses Jahr", + "Kennzahl", "Gesamt", "PV-Produktion", "Verbrauch", "Netzbezug", "Netzeinspeisung", "Batterie Laden / Entladen", + "Energie gespart", "Solar + Batterie, nicht vom Netz", "Geschätzte Ersparnis", "bei 0.39 CHF/kWh", + "Eigenversorgung", "aus Solar + Batterie", "Batterie-Eff.", "Entladung vs. Ladung", + "Monate aggregiert", "Erstellt von inesco Energy Monitor"), + ("fr", "monthly") => new AggregatedEmailStrings( + "Rapport de performance mensuel", "Aperçus du mois", "Résumé du mois", "Vos économies ce mois", + "Indicateur", "Total", "Production PV", "Consommation", "Import réseau", "Export réseau", "Batterie Charge / Décharge", + "Énergie économisée", "solaire + batterie, non achetée au réseau", "Économies estimées", "à 0.39 CHF/kWh", + "Autosuffisance", "du solaire + batterie", "Eff. batterie", "décharge vs charge", + "semaines agrégées", "Généré par inesco Energy Monitor"), + ("fr", "yearly") => new AggregatedEmailStrings( + "Rapport de performance annuel", "Aperçus de l'année", "Résumé de l'année", "Vos économies cette année", + "Indicateur", "Total", "Production PV", "Consommation", "Import réseau", "Export réseau", "Batterie Charge / Décharge", + "Énergie économisée", "solaire + batterie, non achetée au réseau", "Économies estimées", "à 0.39 CHF/kWh", + "Autosuffisance", "du solaire + batterie", "Eff. batterie", "décharge vs charge", + "mois agrégés", "Généré par inesco Energy Monitor"), + ("it", "monthly") => new AggregatedEmailStrings( + "Rapporto mensile delle prestazioni", "Approfondimenti mensili", "Riepilogo mensile", "I tuoi risparmi questo mese", + "Metrica", "Totale", "Produzione PV", "Consumo", "Import dalla rete", "Export nella rete", "Batteria Carica / Scarica", + "Energia risparmiata", "solare + batteria, non acquistata dalla rete", "Risparmio stimato", "a 0.39 CHF/kWh", + "Autosufficienza", "da solare + batteria", "Eff. batteria", "scarica vs carica", + "settimane aggregate", "Generato da inesco Energy Monitor"), + ("it", "yearly") => new AggregatedEmailStrings( + "Rapporto annuale delle prestazioni", "Approfondimenti annuali", "Riepilogo annuale", "I tuoi risparmi quest'anno", + "Metrica", "Totale", "Produzione PV", "Consumo", "Import dalla rete", "Export nella rete", "Batteria Carica / Scarica", + "Energia risparmiata", "solare + batteria, non acquistata dalla rete", "Risparmio stimato", "a 0.39 CHF/kWh", + "Autosufficienza", "da solare + batteria", "Eff. batteria", "scarica vs carica", + "mesi aggregati", "Generato da inesco Energy Monitor"), + (_, "monthly") => new AggregatedEmailStrings( + "Monthly Performance Report", "Monthly Insights", "Monthly Summary", "Your Savings This Month", + "Metric", "Total", "PV Production", "Consumption", "Grid Import", "Grid Export", "Battery Charge / Discharge", + "Energy Saved", "solar + battery, not bought from grid", "Est. Money Saved", "at 0.39 CHF/kWh", + "Self-Sufficiency", "from solar + battery", "Battery Eff.", "discharge vs charge", + "weeks aggregated", "Generated by inesco Energy Monitor"), + _ => new AggregatedEmailStrings( + "Annual Performance Report", "Annual Insights", "Annual Summary", "Your Savings This Year", + "Metric", "Total", "PV Production", "Consumption", "Grid Import", "Grid Export", "Battery Charge / Discharge", + "Energy Saved", "solar + battery, not bought from grid", "Est. Money Saved", "at 0.39 CHF/kWh", + "Self-Sufficiency", "from solar + battery", "Battery Eff.", "discharge vs charge", + "months aggregated", "Generated by inesco Energy Monitor") + }; + + // ── Aggregated HTML email template ──────────────────────────────────── + + private static string BuildAggregatedHtmlEmail( + string periodStart, string periodEnd, string installationName, + double pvProduction, double consumption, double gridImport, double gridExport, + double batteryCharged, double batteryDischarged, double energySaved, double savingsCHF, + double selfSufficiency, double batteryEfficiency, string aiInsight, + string countLabel, AggregatedEmailStrings s) + { + var insightLines = 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 + ? "
    " + + string.Join("", insightLines.Select(l => $"
  • {FormatInsightLine(l)}
  • ")) + + "
" + : $"

{FormatInsightLine(aiInsight)}

"; + + return $@" + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
{s.Title}
+
{installationName}
+
{periodStart} — {periodEnd}
+
{countLabel}
+
+
{s.Insights}
+
+ {insightHtml} +
+
+
{s.Summary}
+ + + + + + + + + + +
{s.Metric}{s.Total}
{s.PvProduction}{pvProduction:F1} kWh
{s.Consumption}{consumption:F1} kWh
{s.GridImport}{gridImport:F1} kWh
{s.GridExport}{gridExport:F1} kWh
{s.BatteryInOut}{batteryCharged:F1} / {batteryDischarged:F1} kWh
+
+
{s.SavingsHeader}
+ + + {SavingsBox(s.SolarEnergyUsed, $"{energySaved:F1} kWh", s.StayedAtHome, "#27ae60")} + {SavingsBox(s.EstMoneySaved, $"~{savingsCHF:F0} CHF", s.AtRate, "#2980b9")} + {SavingsBox(s.SolarCoverage, $"{selfSufficiency:F0}%", s.FromSolar, "#8e44ad")} + {SavingsBox(s.BatteryEff, $"{batteryEfficiency:F0}%", s.OutVsIn, "#e67e22")} + +
+
+ {s.Footer} +
+
+ +"; + } } diff --git a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/WeeklyReport.tsx b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/WeeklyReport.tsx index de375f997..aeddfcbc2 100644 --- a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/WeeklyReport.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/WeeklyReport.tsx @@ -1,17 +1,25 @@ import { useEffect, useState } from 'react'; import { useIntl, FormattedMessage } from 'react-intl'; import { + Accordion, + AccordionDetails, + AccordionSummary, + Alert, Box, Button, CircularProgress, Container, Grid, + MenuItem, Paper, + Select, + Tab, + Tabs, TextField, - Typography, - Alert + Typography } from '@mui/material'; import SendIcon from '@mui/icons-material/Send'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import axiosConfig from 'src/Resources/axiosConfig'; interface WeeklyReportProps { @@ -57,40 +65,259 @@ interface WeeklyReportResponse { aiInsight: string; } +interface ReportSummary { + installationId: number; + periodStart: string; + periodEnd: string; + totalPvProduction: number; + totalConsumption: number; + totalGridImport: number; + totalGridExport: number; + totalBatteryCharged: number; + totalBatteryDischarged: number; + totalEnergySaved: number; + totalSavingsCHF: number; + selfSufficiencyPercent: number; + selfConsumptionPercent: number; + batteryEfficiencyPercent: number; + gridDependencyPercent: number; + aiInsight: string; +} + +interface MonthlyReport extends ReportSummary { + year: number; + month: number; + weekCount: number; + avgPeakLoadHour: number; + avgPeakSolarHour: number; + avgWeekdayDailyLoad: number; + avgWeekendDailyLoad: number; +} + +interface YearlyReport extends ReportSummary { + year: number; + monthCount: number; + avgPeakLoadHour: number; + avgPeakSolarHour: number; + avgWeekdayDailyLoad: number; + avgWeekendDailyLoad: number; +} + +interface PendingMonth { + year: number; + month: number; + weekCount: number; +} + +interface PendingYear { + year: number; + monthCount: number; +} + +interface WeeklyReportSummaryRecord { + id: number; + installationId: number; + periodStart: string; + periodEnd: string; + totalPvProduction: number; + totalConsumption: number; + totalGridImport: number; + totalGridExport: number; + totalBatteryCharged: number; + totalBatteryDischarged: number; + totalEnergySaved: number; + totalSavingsCHF: number; + selfSufficiencyPercent: number; + selfConsumptionPercent: number; + batteryEfficiencyPercent: number; + gridDependencyPercent: number; + peakLoadHour: number; + peakSolarHour: number; + weekdayAvgDailyLoad: number; + weekendAvgDailyLoad: number; + aiInsight: string; + createdAt: string; +} + // Matches: time ranges (14:00–18:00), times (09:00), decimals (126.4 / 1,3), integers (34) -// Any number in any language gets bolded — no unit matching needed const BOLD_PATTERN = /(\d{1,2}:\d{2}(?:[–\-]\d{1,2}:\d{2})?|\d+[.,]\d+|\d+)/g; const isBold = (s: string) => /\d/.test(s); -// Renders a bullet line: bolds the "Title" part before the first colon, and numbers with units function FormattedBullet({ text }: { text: string }) { const colonIdx = text.indexOf(':'); if (colonIdx > 0) { const title = text.slice(0, colonIdx); - const rest = text.slice(colonIdx + 1); // e.g. " This week, your system saved 120.9 kWh..." + const rest = text.slice(colonIdx + 1); const restParts = rest.split(BOLD_PATTERN).map((p, i) => isBold(p) ? {p} : {p} ); return <>{title}:{restParts}; } - // No colon — just bold figures const parts = text.split(BOLD_PATTERN).map((p, i) => isBold(p) ? {p} : {p} ); return <>{parts}; } +const MONTH_NAMES = ['', 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; + +// ── Email Bar (shared) ────────────────────────────────────────── + +function EmailBar({ onSend, disabled }: { onSend: (email: string) => Promise; disabled?: boolean }) { + const intl = useIntl(); + const [email, setEmail] = useState(''); + const [sending, setSending] = useState(false); + const [sendStatus, setSendStatus] = useState<{ message: string; severity: 'success' | 'error' } | null>(null); + + const handleSend = async () => { + if (!email.trim()) return; + setSending(true); + try { + await onSend(email.trim()); + setSendStatus({ message: intl.formatMessage({ id: 'reportSentTo' }, { email }), severity: 'success' }); + } catch { + setSendStatus({ message: intl.formatMessage({ id: 'reportSendError' }), severity: 'error' }); + } finally { + setSending(false); + } + }; + + return ( + + + { setEmail(e.target.value); setSendStatus(null); }} + sx={{ width: 280 }} + onKeyDown={(e) => e.key === 'Enter' && handleSend()} + /> + + + {sendStatus && ( + + {sendStatus.message} + + )} + + ); +} + +// ── Main Component ───────────────────────────────────────────── + function WeeklyReport({ installationId }: WeeklyReportProps) { + const intl = useIntl(); + const [activeTab, setActiveTab] = useState(0); + const [monthlyReports, setMonthlyReports] = useState([]); + const [yearlyReports, setYearlyReports] = useState([]); + const [pendingMonths, setPendingMonths] = useState([]); + const [pendingYears, setPendingYears] = useState([]); + const [generating, setGenerating] = useState(null); + + const fetchReportData = () => { + axiosConfig.get('/GetMonthlyReports', { params: { installationId } }) + .then(res => setMonthlyReports(res.data)) + .catch(() => {}); + axiosConfig.get('/GetYearlyReports', { params: { installationId } }) + .then(res => setYearlyReports(res.data)) + .catch(() => {}); + axiosConfig.get('/GetPendingMonthlyAggregations', { params: { installationId } }) + .then(res => setPendingMonths(res.data)) + .catch(() => {}); + axiosConfig.get('/GetPendingYearlyAggregations', { params: { installationId } }) + .then(res => setPendingYears(res.data)) + .catch(() => {}); + }; + + useEffect(() => { fetchReportData(); }, [installationId]); + + const handleGenerateMonthly = async (year: number, month: number) => { + setGenerating(`monthly-${year}-${month}`); + try { + await axiosConfig.post('/TriggerMonthlyAggregation', null, { + params: { installationId, year, month } + }); + fetchReportData(); + } catch (err) { + console.error('Monthly aggregation failed', err); + } finally { + setGenerating(null); + } + }; + + const handleGenerateYearly = async (year: number) => { + setGenerating(`yearly-${year}`); + try { + await axiosConfig.post('/TriggerYearlyAggregation', null, { + params: { installationId, year } + }); + fetchReportData(); + } catch (err) { + console.error('Yearly aggregation failed', err); + } finally { + setGenerating(null); + } + }; + + const tabs = [ + { label: intl.formatMessage({ id: 'weeklyTab', defaultMessage: 'Weekly' }), key: 'weekly' }, + { label: intl.formatMessage({ id: 'monthlyTab', defaultMessage: 'Monthly' }), key: 'monthly' }, + { label: intl.formatMessage({ id: 'yearlyTab', defaultMessage: 'Yearly' }), key: 'yearly' } + ]; + + const safeTab = Math.min(activeTab, tabs.length - 1); + + return ( + + setActiveTab(v)} + sx={{ mb: 2, '& .MuiTab-root': { fontWeight: 'bold' } }} + > + {tabs.map(t => )} + + + {tabs[safeTab]?.key === 'weekly' && ( + + )} + {tabs[safeTab]?.key === 'monthly' && ( + + )} + {tabs[safeTab]?.key === 'yearly' && ( + + )} + + ); +} + +// ── Weekly Section (existing weekly report content) ──────────── + +function WeeklySection({ installationId }: { installationId: number }) { const intl = useIntl(); const [report, setReport] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [email, setEmail] = useState(''); - const [sending, setSending] = useState(false); - const [sendStatus, setSendStatus] = useState<{ - message: string; - severity: 'success' | 'error'; - } | null>(null); useEffect(() => { fetchReport(); @@ -115,19 +342,10 @@ function WeeklyReport({ installationId }: WeeklyReportProps) { } }; - const handleSendEmail = async () => { - if (!email.trim()) return; - setSending(true); - try { - await axiosConfig.post('/SendWeeklyReportEmail', null, { - params: { installationId, emailAddress: email.trim() } - }); - setSendStatus({ message: intl.formatMessage({ id: 'reportSentTo' }, { email }), severity: 'success' }); - } catch (err: any) { - setSendStatus({ message: intl.formatMessage({ id: 'reportSendError' }), severity: 'error' }); - } finally { - setSending(false); - } + const handleSendEmail = async (emailAddress: string) => { + await axiosConfig.post('/SendWeeklyReportEmail', null, { + params: { installationId, emailAddress } + }); }; if (loading) { @@ -162,7 +380,6 @@ function WeeklyReport({ installationId }: WeeklyReportProps) { const cur = report.currentWeek; const prev = report.previousWeek; - // Backend: currentWeek = last 7 days, previousWeek = everything before const currentWeekDayCount = Math.min(7, report.dailyData.length); const previousWeekDayCount = Math.max(1, report.dailyData.length - currentWeekDayCount); @@ -174,51 +391,23 @@ function WeeklyReport({ installationId }: WeeklyReportProps) { return effective > 0 ? '#27ae60' : effective < 0 ? '#e74c3c' : '#888'; }; - // Parse AI insight into bullet points const insightBullets = report.aiInsight .split(/\n+/) .map((line) => line.replace(/^[\d]+[.)]\s*/, '').replace(/^[-*]\s*/, '').trim()) .filter((line) => line.length > 0); - // Read pre-computed values from backend — no arithmetic in the frontend const totalEnergySavedKwh = report.totalEnergySaved; const totalSavingsCHF = report.totalSavingsCHF; - // Find max value for daily bar chart scaling const maxDailyValue = Math.max( ...report.dailyData.map((d) => Math.max(d.pvProduction, d.loadConsumption, d.gridImport)), 1 ); return ( - + <> {/* Email bar */} - - - { setEmail(e.target.value); setSendStatus(null); }} - sx={{ width: 280 }} - onKeyDown={(e) => e.key === 'Enter' && handleSendEmail()} - /> - - - {sendStatus && ( - - {sendStatus.message} - - )} - + {/* Report Header */} - {/* Weekly Insights (was AI Insights) */} + {/* Weekly Insights */} - - {insightBullets.length > 1 ? ( - - {insightBullets.map((bullet, i) => ( -
  • - ))} -
    - ) : ( - - - - )} -
    +
    {/* Your Savings This Week */} @@ -273,41 +443,14 @@ function WeeklyReport({ installationId }: WeeklyReportProps) { - - - 0 ? `≈ ${report.daysEquivalent} ${intl.formatMessage({ id: 'daysOfYourUsage' })}` : undefined} - /> - - - - - - - - - - - + 0 ? `≈ ${report.daysEquivalent} ${intl.formatMessage({ id: 'daysOfYourUsage' })}` : undefined} + /> {/* Weekly Summary Table */} @@ -371,13 +514,12 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
    - {/* Daily Breakdown - CSS bar chart */} + {/* Daily Breakdown */} {report.dailyData.length > 0 && ( - {/* Legend */} @@ -389,7 +531,6 @@ function WeeklyReport({ installationId }: WeeklyReportProps) { - {/* Bars */} {report.dailyData.map((d, i) => { const dt = new Date(d.date); const dayLabel = dt.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }); @@ -406,32 +547,9 @@ function WeeklyReport({ installationId }: WeeklyReportProps) { - 0 ? '2px' : 0, - transition: 'width 0.3s' - }} - /> - 0 ? '2px' : 0, - transition: 'width 0.3s' - }} - /> - 0 ? '2px' : 0, - transition: 'width 0.3s' - }} - /> + 0 ? '2px' : 0, transition: 'width 0.3s' }} /> + 0 ? '2px' : 0, transition: 'width 0.3s' }} /> + 0 ? '2px' : 0, transition: 'width 0.3s' }} /> ); @@ -439,10 +557,470 @@ function WeeklyReport({ installationId }: WeeklyReportProps) { )} + {/* Weekly History for current month */} + + + ); +} + +// ── Weekly History (saved weekly reports for current month) ───── + +function WeeklyHistory({ installationId }: { installationId: number }) { + const intl = useIntl(); + const [records, setRecords] = useState([]); + + useEffect(() => { + const now = new Date(); + axiosConfig.get('/GetWeeklyReportSummaries', { + params: { installationId, year: now.getFullYear(), month: now.getMonth() + 1 } + }) + .then(res => setRecords(res.data)) + .catch(() => {}); + }, [installationId]); + + if (records.length === 0) return null; + + return ( + + + + + {records.map((rec) => ( + + } sx={{ bgcolor: '#f8f9fa', borderRadius: 1 }}> + + + {rec.periodStart} — {rec.periodEnd} + + + PV {rec.totalPvProduction.toFixed(1)} kWh | {intl.formatMessage({ id: 'consumption', defaultMessage: 'Consumption' })} {rec.totalConsumption.toFixed(1)} kWh + + + + + + + + {rec.totalEnergySaved.toFixed(1)} kWh + + + + + + ~{rec.totalSavingsCHF.toFixed(0)} CHF + + + + + + {rec.selfSufficiencyPercent.toFixed(0)}% + + + + + + {rec.batteryEfficiencyPercent.toFixed(0)}% + + + + + + + + + {rec.totalGridImport.toFixed(1)} kWh + + + {rec.totalGridExport.toFixed(1)} kWh + + + + {rec.totalBatteryCharged.toFixed(1)} / {rec.totalBatteryDischarged.toFixed(1)} kWh + + + + {rec.aiInsight && rec.aiInsight.length > 10 && ( + + {rec.aiInsight.split(/\n+/).filter(l => l.trim()).slice(0, 2).map((line, i) => ( + + {line.replace(/^[-*]\s*/, '').replace(/^[\d]+[.)]\s*/, '')} + + ))} + + )} + + + ))} + + ); +} + +// ── Monthly Section ───────────────────────────────────────────── + +function MonthlySection({ + installationId, + reports, + pendingMonths, + generating, + onGenerate +}: { + installationId: number; + reports: MonthlyReport[]; + pendingMonths: PendingMonth[]; + generating: string | null; + onGenerate: (year: number, month: number) => void; +}) { + const intl = useIntl(); + + return ( + <> + {/* Generate buttons for pending months */} + {pendingMonths.length > 0 && ( + + + + + + {pendingMonths.map(p => { + const key = `monthly-${p.year}-${p.month}`; + const isGenerating = generating === key; + return ( + + ); + })} + + + )} + + {/* Existing monthly reports */} + {reports.length > 0 ? ( + `${MONTH_NAMES[r.month]} ${r.year}`} + countLabelId="weeksAggregated" + countFn={(r: MonthlyReport) => r.weekCount} + sendEndpoint="/SendMonthlyReportEmail" + sendParamsFn={(r: MonthlyReport) => ({ installationId, year: r.year, month: r.month })} + /> + ) : pendingMonths.length === 0 ? ( + + + + ) : null} + + ); +} + +// ── Yearly Section ────────────────────────────────────────────── + +function YearlySection({ + installationId, + reports, + pendingYears, + generating, + onGenerate +}: { + installationId: number; + reports: YearlyReport[]; + pendingYears: PendingYear[]; + generating: string | null; + onGenerate: (year: number) => void; +}) { + const intl = useIntl(); + + return ( + <> + {/* Generate buttons for pending years */} + {pendingYears.length > 0 && ( + + + + + + {pendingYears.map(p => { + const key = `yearly-${p.year}`; + const isGenerating = generating === key; + return ( + + ); + })} + + + )} + + {/* Existing yearly reports */} + {reports.length > 0 ? ( + `${r.year}`} + countLabelId="monthsAggregated" + countFn={(r: YearlyReport) => r.monthCount} + sendEndpoint="/SendYearlyReportEmail" + sendParamsFn={(r: YearlyReport) => ({ installationId, year: r.year })} + /> + ) : pendingYears.length === 0 ? ( + + + + ) : null} + + ); +} + +// ── Aggregated Section (Monthly / Yearly) ────────────────────── + +function AggregatedSection({ + reports, + type, + labelFn, + countLabelId, + countFn, + sendEndpoint, + sendParamsFn +}: { + reports: T[]; + type: 'monthly' | 'yearly'; + labelFn: (r: T) => string; + countLabelId: string; + countFn: (r: T) => number; + sendEndpoint: string; + sendParamsFn: (r: T) => object; +}) { + const intl = useIntl(); + const [selectedIdx, setSelectedIdx] = useState(0); + + if (reports.length === 0) { + return ( + + + + ); + } + + const r = reports[selectedIdx]; + const insightsId = type === 'monthly' ? 'monthlyInsights' : 'yearlyInsights'; + const savingsId = type === 'monthly' ? 'monthlySavings' : 'yearlySavings'; + const summaryId = type === 'monthly' ? 'monthlySummary' : 'yearlySummary'; + const titleId = type === 'monthly' ? 'monthlyReportTitle' : 'yearlyReportTitle'; + + const insightBullets = r.aiInsight + .split(/\n+/) + .map((line) => line.replace(/^[\d]+[.)]\s*/, '').replace(/^[-*]\s*/, '').trim()) + .filter((line) => line.length > 0); + + const handleSendEmail = async (emailAddress: string) => { + await axiosConfig.post(sendEndpoint, null, { + params: { ...sendParamsFn(r), emailAddress } + }); + }; + + return ( + <> + {/* Period selector + Email bar */} + + {reports.length > 1 && ( + + )} + + + + + + {/* Header */} + + + + + + {r.periodStart} — {r.periodEnd} + + + + + + + {/* AI Insights */} + + + + + + + + {/* Savings Cards */} + + + + + + + + {/* Summary Table */} + + + + + + + + + + + + + + + {r.totalPvProduction.toFixed(1)} kWh + + + + {r.totalConsumption.toFixed(1)} kWh + + + + {r.totalGridImport.toFixed(1)} kWh + + + + {r.totalGridExport.toFixed(1)} kWh + + + + {r.totalBatteryCharged.toFixed(1)} / {r.totalBatteryDischarged.toFixed(1)} kWh + + + + + + ); +} + +// ── Shared Components ────────────────────────────────────────── + +function InsightBox({ text, bullets }: { text: string; bullets: string[] }) { + return ( + + {bullets.length > 1 ? ( + + {bullets.map((bullet, i) => ( +
  • + ))} +
    + ) : ( + + + + )}
    ); } +function SavingsCards({ intl, energySaved, savingsCHF, selfSufficiency, batteryEfficiency, hint }: { + intl: any; + energySaved: number; + savingsCHF: number; + selfSufficiency: number; + batteryEfficiency: number; + hint?: string; +}) { + return ( + + + + + + + + + + + + + + + ); +} + function SavingsCard({ label, value, subtitle, color, hint }: { label: string; value: string; subtitle: string; color: string; hint?: string }) { return (