generate monthly and yearly report based on xlsx files

This commit is contained in:
Yinyin Liu 2026-02-27 11:48:05 +01:00
parent 7476c939c3
commit 78b9c2dc72
14 changed files with 2099 additions and 143 deletions

View File

@ -919,6 +919,10 @@ public class Controller : ControllerBase
{ {
var lang = language ?? user.Language ?? "en"; var lang = language ?? user.Language ?? "en";
var report = await WeeklyReportService.GenerateReportAsync(installationId, installation.InstallationName, lang); var report = await WeeklyReportService.GenerateReportAsync(installationId, installation.InstallationName, lang);
// Persist weekly summary for future monthly aggregation (idempotent)
ReportAggregationService.SaveWeeklySummary(installationId, report);
return Ok(report); return Ok(report);
} }
catch (Exception ex) catch (Exception ex)
@ -956,6 +960,199 @@ public class Controller : ControllerBase
} }
} }
// ── Monthly & Yearly Reports ─────────────────────────────────────
[HttpGet(nameof(GetPendingMonthlyAggregations))]
public ActionResult<List<PendingMonth>> 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<List<PendingYear>> 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<List<MonthlyReportSummary>> 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<List<YearlyReportSummary>> 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));
}
/// <summary>
/// Manually trigger monthly aggregation for an installation.
/// Aggregates weekly reports for the specified year/month into a monthly report.
/// </summary>
[HttpPost(nameof(TriggerMonthlyAggregation))]
public async Task<ActionResult> 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}");
}
}
/// <summary>
/// Manually trigger yearly aggregation for an installation.
/// Aggregates monthly reports for the specified year into a yearly report.
/// </summary>
[HttpPost(nameof(TriggerYearlyAggregation))]
public async Task<ActionResult> 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<ActionResult> 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<ActionResult> 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<List<WeeklyReportSummary>> 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))] [HttpPut(nameof(UpdateFolder))]
public ActionResult<Folder> UpdateFolder([FromBody] Folder folder, Token authToken) public ActionResult<Folder> UpdateFolder([FromBody] Folder folder, Token authToken)
{ {

View File

@ -0,0 +1,149 @@
using SQLite;
namespace InnovEnergy.App.Backend.DataTypes;
/// <summary>
/// Stored summary for a weekly report period.
/// Created when GetWeeklyReport is called. Consumed and deleted by monthly aggregation.
/// </summary>
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; } = "";
}
/// <summary>
/// Aggregated monthly report. Created from weekly summaries at month-end.
/// Consumed and deleted by yearly aggregation.
/// </summary>
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; } = "";
}
/// <summary>
/// Aggregated yearly report. Created from monthly summaries at year-end.
/// Kept indefinitely.
/// </summary>
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; }
}

View File

@ -68,6 +68,10 @@ public static partial class Db
return Insert(action); 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) public static void HandleAction(UserAction newAction)
{ {
//Find the total number of actions for this installation //Find the total number of actions for this installation

View File

@ -25,6 +25,9 @@ public static partial class Db
public static TableQuery<Error> Errors => Connection.Table<Error>(); public static TableQuery<Error> Errors => Connection.Table<Error>();
public static TableQuery<Warning> Warnings => Connection.Table<Warning>(); public static TableQuery<Warning> Warnings => Connection.Table<Warning>();
public static TableQuery<UserAction> UserActions => Connection.Table<UserAction>(); public static TableQuery<UserAction> UserActions => Connection.Table<UserAction>();
public static TableQuery<WeeklyReportSummary> WeeklyReports => Connection.Table<WeeklyReportSummary>();
public static TableQuery<MonthlyReportSummary> MonthlyReports => Connection.Table<MonthlyReportSummary>();
public static TableQuery<YearlyReportSummary> YearlyReports => Connection.Table<YearlyReportSummary>();
public static void Init() public static void Init()
@ -51,6 +54,9 @@ public static partial class Db
Connection.CreateTable<Error>(); Connection.CreateTable<Error>();
Connection.CreateTable<Warning>(); Connection.CreateTable<Warning>();
Connection.CreateTable<UserAction>(); Connection.CreateTable<UserAction>();
Connection.CreateTable<WeeklyReportSummary>();
Connection.CreateTable<MonthlyReportSummary>();
Connection.CreateTable<YearlyReportSummary>();
}); });
// One-time migration: normalize legacy long-form language values to ISO codes // One-time migration: normalize legacy long-form language values to ISO codes
@ -88,6 +94,9 @@ public static partial class Db
fileConnection.CreateTable<Error>(); fileConnection.CreateTable<Error>();
fileConnection.CreateTable<Warning>(); fileConnection.CreateTable<Warning>();
fileConnection.CreateTable<UserAction>(); fileConnection.CreateTable<UserAction>();
fileConnection.CreateTable<WeeklyReportSummary>();
fileConnection.CreateTable<MonthlyReportSummary>();
fileConnection.CreateTable<YearlyReportSummary>();
return fileConnection; return fileConnection;
//return CopyDbToMemory(fileConnection); //return CopyDbToMemory(fileConnection);

View File

@ -141,4 +141,43 @@ public static partial class Db
{ {
OrderNumber2Installation.Delete(s => s.InstallationId == relation.InstallationId && s.OrderNumber == relation.OrderNumber); 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();
}
} }

View File

@ -56,4 +56,42 @@ public static partial class Db
return session; return session;
} }
// ── Report Queries ────────────────────────────────────────────────
public static List<WeeklyReportSummary> GetWeeklyReports(Int64 installationId)
=> WeeklyReports
.Where(r => r.InstallationId == installationId)
.OrderByDescending(r => r.PeriodStart)
.ToList();
public static List<WeeklyReportSummary> 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<MonthlyReportSummary> GetMonthlyReports(Int64 installationId)
=> MonthlyReports
.Where(r => r.InstallationId == installationId)
.OrderByDescending(r => r.Year)
.ThenByDescending(r => r.Month)
.ToList();
public static List<MonthlyReportSummary> GetMonthlyReportsForYear(Int64 installationId, Int32 year)
=> MonthlyReports
.Where(r => r.InstallationId == installationId && r.Year == year)
.ToList();
public static List<YearlyReportSummary> GetYearlyReports(Int64 installationId)
=> YearlyReports
.Where(r => r.InstallationId == installationId)
.OrderByDescending(r => r.Year)
.ToList();
} }

View File

@ -28,6 +28,7 @@ public static class Program
LoadEnvFile(); LoadEnvFile();
DiagnosticService.Initialize(); DiagnosticService.Initialize();
AlarmReviewService.StartDailyScheduler(); AlarmReviewService.StartDailyScheduler();
// ReportAggregationService.StartScheduler(); // TODO: uncomment to enable automatic monthly/yearly aggregation
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
RabbitMqManager.InitializeEnvironment(); RabbitMqManager.InitializeEnvironment();

View File

@ -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 (SundaySaturday) 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 SundaySaturday)
// 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 ─────────────────────────────────
/// <summary>
/// Returns months that have weekly data but no monthly report yet.
/// Each entry: { Year, Month, WeekCount }.
/// </summary>
public static List<PendingMonth> 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();
}
/// <summary>
/// Returns years that have monthly data but no yearly report yet.
/// Each entry: { Year, MonthCount }.
/// </summary>
public static List<PendingYear> 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 ─────────────────────────────────────────
/// <summary>
/// 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).
/// </summary>
public static async Task<Int32> 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;
}
/// <summary>
/// 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).
/// </summary>
public static async Task<Int32> 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<String> 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<String> 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<String> 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<dynamic>(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.";
}
}

View File

@ -443,4 +443,230 @@ public static class ReportEmailService
private static string ChangeColor(double pct) => private static string ChangeColor(double pct) =>
pct > 0 ? "#27ae60" : pct < 0 ? "#e74c3c" : "#888"; 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 <strong style=\"color:#666\">inesco Energy Monitor</strong>"),
("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 <strong style=\"color:#666\">inesco Energy Monitor</strong>"),
("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 <strong style=\"color:#666\">inesco Energy Monitor</strong>"),
("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 <strong style=\"color:#666\">inesco Energy Monitor</strong>"),
("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 <strong style=\"color:#666\">inesco Energy Monitor</strong>"),
("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 <strong style=\"color:#666\">inesco Energy Monitor</strong>"),
(_, "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 <strong style=\"color:#666\">inesco Energy Monitor</strong>"),
_ => 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 <strong style=\"color:#666\">inesco Energy Monitor</strong>")
};
// ── 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
? "<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(aiInsight)}</p>";
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"">{installationName}</div>
<div style=""font-size:13px;margin-top:2px;opacity:0.7"">{periodStart} {periodEnd}</div>
<div style=""font-size:12px;margin-top:2px;opacity:0.5"">{countLabel}</div>
</td>
</tr>
<!-- Insights -->
<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>
<!-- Summary Table -->
<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>
<th style=""padding:8px 12px;text-align:right"">{s.Total}</th>
</tr>
<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"">{pvProduction:F1} kWh</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"">{consumption:F1} kWh</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"">{gridImport:F1} kWh</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"">{gridExport:F1} kWh</td></tr>
<tr><td style=""padding:8px 12px"">{s.BatteryInOut}</td><td style=""padding:8px 12px;text-align:right;font-weight:bold"">{batteryCharged:F1} / {batteryDischarged:F1} kWh</td></tr>
</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, $"{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")}
</tr>
</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>";
}
} }

View File

@ -1,17 +1,25 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useIntl, FormattedMessage } from 'react-intl'; import { useIntl, FormattedMessage } from 'react-intl';
import { import {
Accordion,
AccordionDetails,
AccordionSummary,
Alert,
Box, Box,
Button, Button,
CircularProgress, CircularProgress,
Container, Container,
Grid, Grid,
MenuItem,
Paper, Paper,
Select,
Tab,
Tabs,
TextField, TextField,
Typography, Typography
Alert
} from '@mui/material'; } from '@mui/material';
import SendIcon from '@mui/icons-material/Send'; import SendIcon from '@mui/icons-material/Send';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import axiosConfig from 'src/Resources/axiosConfig'; import axiosConfig from 'src/Resources/axiosConfig';
interface WeeklyReportProps { interface WeeklyReportProps {
@ -57,40 +65,259 @@ interface WeeklyReportResponse {
aiInsight: string; 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:0018:00), times (09:00), decimals (126.4 / 1,3), integers (34) // Matches: time ranges (14:0018: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 BOLD_PATTERN = /(\d{1,2}:\d{2}(?:[\-]\d{1,2}:\d{2})?|\d+[.,]\d+|\d+)/g;
const isBold = (s: string) => /\d/.test(s); 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 }) { function FormattedBullet({ text }: { text: string }) {
const colonIdx = text.indexOf(':'); const colonIdx = text.indexOf(':');
if (colonIdx > 0) { if (colonIdx > 0) {
const title = text.slice(0, colonIdx); 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) => const restParts = rest.split(BOLD_PATTERN).map((p, i) =>
isBold(p) ? <strong key={i}>{p}</strong> : <span key={i}>{p}</span> isBold(p) ? <strong key={i}>{p}</strong> : <span key={i}>{p}</span>
); );
return <><strong>{title}</strong>:{restParts}</>; return <><strong>{title}</strong>:{restParts}</>;
} }
// No colon — just bold figures
const parts = text.split(BOLD_PATTERN).map((p, i) => const parts = text.split(BOLD_PATTERN).map((p, i) =>
isBold(p) ? <strong key={i}>{p}</strong> : <span key={i}>{p}</span> isBold(p) ? <strong key={i}>{p}</strong> : <span key={i}>{p}</span>
); );
return <>{parts}</>; 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<void>; 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 (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', mb: 3, gap: 0.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<TextField
size="small"
placeholder="recipient@example.com"
value={email}
onChange={(e) => { setEmail(e.target.value); setSendStatus(null); }}
sx={{ width: 280 }}
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
/>
<Button
variant="contained"
startIcon={sending ? <CircularProgress size={16} color="inherit" /> : <SendIcon />}
onClick={handleSend}
disabled={sending || !email.trim() || disabled}
sx={{ bgcolor: '#2c3e50', '&:hover': { bgcolor: '#34495e' } }}
>
<FormattedMessage id="sendReport" defaultMessage="Send Report" />
</Button>
</Box>
{sendStatus && (
<Typography variant="caption" sx={{ color: sendStatus.severity === 'success' ? '#27ae60' : '#e74c3c' }}>
{sendStatus.message}
</Typography>
)}
</Box>
);
}
// ── Main Component ─────────────────────────────────────────────
function WeeklyReport({ installationId }: WeeklyReportProps) { function WeeklyReport({ installationId }: WeeklyReportProps) {
const intl = useIntl();
const [activeTab, setActiveTab] = useState(0);
const [monthlyReports, setMonthlyReports] = useState<MonthlyReport[]>([]);
const [yearlyReports, setYearlyReports] = useState<YearlyReport[]>([]);
const [pendingMonths, setPendingMonths] = useState<PendingMonth[]>([]);
const [pendingYears, setPendingYears] = useState<PendingYear[]>([]);
const [generating, setGenerating] = useState<string | null>(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 (
<Box sx={{ p: 2 }}>
<Tabs
value={safeTab}
onChange={(_, v) => setActiveTab(v)}
sx={{ mb: 2, '& .MuiTab-root': { fontWeight: 'bold' } }}
>
{tabs.map(t => <Tab key={t.key} label={t.label} />)}
</Tabs>
{tabs[safeTab]?.key === 'weekly' && (
<WeeklySection installationId={installationId} />
)}
{tabs[safeTab]?.key === 'monthly' && (
<MonthlySection
installationId={installationId}
reports={monthlyReports}
pendingMonths={pendingMonths}
generating={generating}
onGenerate={handleGenerateMonthly}
/>
)}
{tabs[safeTab]?.key === 'yearly' && (
<YearlySection
installationId={installationId}
reports={yearlyReports}
pendingYears={pendingYears}
generating={generating}
onGenerate={handleGenerateYearly}
/>
)}
</Box>
);
}
// ── Weekly Section (existing weekly report content) ────────────
function WeeklySection({ installationId }: { installationId: number }) {
const intl = useIntl(); const intl = useIntl();
const [report, setReport] = useState<WeeklyReportResponse | null>(null); const [report, setReport] = useState<WeeklyReportResponse | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [email, setEmail] = useState('');
const [sending, setSending] = useState(false);
const [sendStatus, setSendStatus] = useState<{
message: string;
severity: 'success' | 'error';
} | null>(null);
useEffect(() => { useEffect(() => {
fetchReport(); fetchReport();
@ -115,19 +342,10 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
} }
}; };
const handleSendEmail = async () => { const handleSendEmail = async (emailAddress: string) => {
if (!email.trim()) return; await axiosConfig.post('/SendWeeklyReportEmail', null, {
setSending(true); params: { installationId, emailAddress }
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);
}
}; };
if (loading) { if (loading) {
@ -162,7 +380,6 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
const cur = report.currentWeek; const cur = report.currentWeek;
const prev = report.previousWeek; const prev = report.previousWeek;
// Backend: currentWeek = last 7 days, previousWeek = everything before
const currentWeekDayCount = Math.min(7, report.dailyData.length); const currentWeekDayCount = Math.min(7, report.dailyData.length);
const previousWeekDayCount = Math.max(1, report.dailyData.length - currentWeekDayCount); 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'; return effective > 0 ? '#27ae60' : effective < 0 ? '#e74c3c' : '#888';
}; };
// Parse AI insight into bullet points
const insightBullets = report.aiInsight const insightBullets = report.aiInsight
.split(/\n+/) .split(/\n+/)
.map((line) => line.replace(/^[\d]+[.)]\s*/, '').replace(/^[-*]\s*/, '').trim()) .map((line) => line.replace(/^[\d]+[.)]\s*/, '').replace(/^[-*]\s*/, '').trim())
.filter((line) => line.length > 0); .filter((line) => line.length > 0);
// Read pre-computed values from backend — no arithmetic in the frontend
const totalEnergySavedKwh = report.totalEnergySaved; const totalEnergySavedKwh = report.totalEnergySaved;
const totalSavingsCHF = report.totalSavingsCHF; const totalSavingsCHF = report.totalSavingsCHF;
// Find max value for daily bar chart scaling
const maxDailyValue = Math.max( const maxDailyValue = Math.max(
...report.dailyData.map((d) => Math.max(d.pvProduction, d.loadConsumption, d.gridImport)), ...report.dailyData.map((d) => Math.max(d.pvProduction, d.loadConsumption, d.gridImport)),
1 1
); );
return ( return (
<Box sx={{ p: 2 }}> <>
{/* Email bar */} {/* Email bar */}
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', mb: 3, gap: 0.5 }}> <EmailBar onSend={handleSendEmail} />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<TextField
size="small"
placeholder="recipient@example.com"
value={email}
onChange={(e) => { setEmail(e.target.value); setSendStatus(null); }}
sx={{ width: 280 }}
onKeyDown={(e) => e.key === 'Enter' && handleSendEmail()}
/>
<Button
variant="contained"
startIcon={sending ? <CircularProgress size={16} color="inherit" /> : <SendIcon />}
onClick={handleSendEmail}
disabled={sending || !email.trim()}
sx={{ bgcolor: '#2c3e50', '&:hover': { bgcolor: '#34495e' } }}
>
<FormattedMessage id="sendReport" defaultMessage="Send Report" />
</Button>
</Box>
{sendStatus && (
<Typography variant="caption" sx={{ color: sendStatus.severity === 'success' ? '#27ae60' : '#e74c3c' }}>
{sendStatus.message}
</Typography>
)}
</Box>
{/* Report Header */} {/* Report Header */}
<Paper <Paper
@ -241,31 +430,12 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
</Typography> </Typography>
</Paper> </Paper>
{/* Weekly Insights (was AI Insights) */} {/* Weekly Insights */}
<Paper sx={{ p: 3, mb: 3 }}> <Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="h6" fontWeight="bold" sx={{ mb: 2, color: '#2c3e50' }}> <Typography variant="h6" fontWeight="bold" sx={{ mb: 2, color: '#2c3e50' }}>
<FormattedMessage id="weeklyInsights" defaultMessage="Weekly Insights" /> <FormattedMessage id="weeklyInsights" defaultMessage="Weekly Insights" />
</Typography> </Typography>
<Box <InsightBox text={report.aiInsight} bullets={insightBullets} />
sx={{
bgcolor: '#fef9e7',
borderLeft: '4px solid #f39c12',
p: 2.5,
borderRadius: '0 8px 8px 0'
}}
>
{insightBullets.length > 1 ? (
<Box component="ul" sx={{ m: 0, pl: 2.5, '& li': { mb: 1.2, lineHeight: 1.7, fontSize: '14px', color: '#333' } }}>
{insightBullets.map((bullet, i) => (
<li key={i}><FormattedBullet text={bullet} /></li>
))}
</Box>
) : (
<Typography sx={{ lineHeight: 1.7, fontSize: '14px', color: '#333' }}>
<FormattedBullet text={report.aiInsight} />
</Typography>
)}
</Box>
</Paper> </Paper>
{/* Your Savings This Week */} {/* Your Savings This Week */}
@ -273,41 +443,14 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
<Typography variant="h6" fontWeight="bold" sx={{ mb: 2, color: '#2c3e50' }}> <Typography variant="h6" fontWeight="bold" sx={{ mb: 2, color: '#2c3e50' }}>
<FormattedMessage id="weeklySavings" defaultMessage="Your Savings This Week" /> <FormattedMessage id="weeklySavings" defaultMessage="Your Savings This Week" />
</Typography> </Typography>
<Grid container spacing={2}> <SavingsCards
<Grid item xs={6} sm={3}> intl={intl}
<SavingsCard energySaved={totalEnergySavedKwh}
label={intl.formatMessage({ id: 'solarEnergyUsed' })} savingsCHF={totalSavingsCHF}
value={`${totalEnergySavedKwh} kWh`} selfSufficiency={report.selfSufficiencyPercent}
subtitle={intl.formatMessage({ id: 'solarStayedHome' })} batteryEfficiency={report.batteryEfficiencyPercent}
color="#27ae60" hint={report.daysEquivalent > 0 ? `${report.daysEquivalent} ${intl.formatMessage({ id: 'daysOfYourUsage' })}` : undefined}
hint={report.daysEquivalent > 0 ? `${report.daysEquivalent} ${intl.formatMessage({ id: 'daysOfYourUsage' })}` : undefined} />
/>
</Grid>
<Grid item xs={6} sm={3}>
<SavingsCard
label={intl.formatMessage({ id: 'estMoneySaved' })}
value={`~${totalSavingsCHF} CHF`}
subtitle={intl.formatMessage({ id: 'atCHFRate' })}
color="#2980b9"
/>
</Grid>
<Grid item xs={6} sm={3}>
<SavingsCard
label={intl.formatMessage({ id: 'solarCoverage' })}
value={`${report.selfSufficiencyPercent.toFixed(0)}%`}
subtitle={intl.formatMessage({ id: 'fromSolarSub' })}
color="#8e44ad"
/>
</Grid>
<Grid item xs={6} sm={3}>
<SavingsCard
label={intl.formatMessage({ id: 'batteryEfficiency' })}
value={`${report.batteryEfficiencyPercent.toFixed(0)}%`}
subtitle={intl.formatMessage({ id: 'batteryEffSub' })}
color="#e67e22"
/>
</Grid>
</Grid>
</Paper> </Paper>
{/* Weekly Summary Table */} {/* Weekly Summary Table */}
@ -371,13 +514,12 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
</Box> </Box>
</Paper> </Paper>
{/* Daily Breakdown - CSS bar chart */} {/* Daily Breakdown */}
{report.dailyData.length > 0 && ( {report.dailyData.length > 0 && (
<Paper sx={{ p: 3, mb: 3 }}> <Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="h6" fontWeight="bold" sx={{ mb: 1, color: '#2c3e50' }}> <Typography variant="h6" fontWeight="bold" sx={{ mb: 1, color: '#2c3e50' }}>
<FormattedMessage id="dailyBreakdown" defaultMessage="Daily Breakdown" /> <FormattedMessage id="dailyBreakdown" defaultMessage="Daily Breakdown" />
</Typography> </Typography>
{/* Legend */}
<Box sx={{ display: 'flex', gap: 3, mb: 2, fontSize: '12px', color: '#666' }}> <Box sx={{ display: 'flex', gap: 3, mb: 2, fontSize: '12px', color: '#666' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Box sx={{ width: 12, height: 12, bgcolor: '#f39c12', borderRadius: '2px' }} /> <FormattedMessage id="pvProduction" defaultMessage="PV Production" /> <Box sx={{ width: 12, height: 12, bgcolor: '#f39c12', borderRadius: '2px' }} /> <FormattedMessage id="pvProduction" defaultMessage="PV Production" />
@ -389,7 +531,6 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
<Box sx={{ width: 12, height: 12, bgcolor: '#e74c3c', borderRadius: '2px' }} /> <FormattedMessage id="gridImport" defaultMessage="Grid Import" /> <Box sx={{ width: 12, height: 12, bgcolor: '#e74c3c', borderRadius: '2px' }} /> <FormattedMessage id="gridImport" defaultMessage="Grid Import" />
</Box> </Box>
</Box> </Box>
{/* Bars */}
{report.dailyData.map((d, i) => { {report.dailyData.map((d, i) => {
const dt = new Date(d.date); const dt = new Date(d.date);
const dayLabel = dt.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }); const dayLabel = dt.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' });
@ -406,32 +547,9 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
</Typography> </Typography>
</Box> </Box>
<Box sx={{ display: 'flex', gap: '2px', height: 18 }}> <Box sx={{ display: 'flex', gap: '2px', height: 18 }}>
<Box <Box sx={{ width: `${(d.pvProduction / maxDailyValue) * 100}%`, bgcolor: '#f39c12', borderRadius: '2px 0 0 2px', minWidth: d.pvProduction > 0 ? '2px' : 0, transition: 'width 0.3s' }} />
sx={{ <Box sx={{ width: `${(d.loadConsumption / maxDailyValue) * 100}%`, bgcolor: '#3498db', minWidth: d.loadConsumption > 0 ? '2px' : 0, transition: 'width 0.3s' }} />
width: `${(d.pvProduction / maxDailyValue) * 100}%`, <Box sx={{ width: `${(d.gridImport / maxDailyValue) * 100}%`, bgcolor: '#e74c3c', borderRadius: '0 2px 2px 0', minWidth: d.gridImport > 0 ? '2px' : 0, transition: 'width 0.3s' }} />
bgcolor: '#f39c12',
borderRadius: '2px 0 0 2px',
minWidth: d.pvProduction > 0 ? '2px' : 0,
transition: 'width 0.3s'
}}
/>
<Box
sx={{
width: `${(d.loadConsumption / maxDailyValue) * 100}%`,
bgcolor: '#3498db',
minWidth: d.loadConsumption > 0 ? '2px' : 0,
transition: 'width 0.3s'
}}
/>
<Box
sx={{
width: `${(d.gridImport / maxDailyValue) * 100}%`,
bgcolor: '#e74c3c',
borderRadius: '0 2px 2px 0',
minWidth: d.gridImport > 0 ? '2px' : 0,
transition: 'width 0.3s'
}}
/>
</Box> </Box>
</Box> </Box>
); );
@ -439,10 +557,470 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
</Paper> </Paper>
)} )}
{/* Weekly History for current month */}
<WeeklyHistory installationId={installationId} />
</>
);
}
// ── Weekly History (saved weekly reports for current month) ─────
function WeeklyHistory({ installationId }: { installationId: number }) {
const intl = useIntl();
const [records, setRecords] = useState<WeeklyReportSummaryRecord[]>([]);
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 (
<Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="h6" fontWeight="bold" sx={{ mb: 2, color: '#2c3e50' }}>
<FormattedMessage id="thisMonthWeeklyReports" defaultMessage="This Month's Weekly Reports" />
</Typography>
{records.map((rec) => (
<Accordion key={rec.id} sx={{ mb: 1, '&:before': { display: 'none' } }} disableGutters>
<AccordionSummary expandIcon={<ExpandMoreIcon />} sx={{ bgcolor: '#f8f9fa', borderRadius: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', width: '100%', pr: 1 }}>
<Typography variant="body2" fontWeight="bold">
{rec.periodStart} {rec.periodEnd}
</Typography>
<Typography variant="caption" sx={{ color: '#888' }}>
PV {rec.totalPvProduction.toFixed(1)} kWh | {intl.formatMessage({ id: 'consumption', defaultMessage: 'Consumption' })} {rec.totalConsumption.toFixed(1)} kWh
</Typography>
</Box>
</AccordionSummary>
<AccordionDetails>
<Grid container spacing={2} sx={{ mb: 1 }}>
<Grid item xs={6} sm={3}>
<Box sx={{ textAlign: 'center' }}>
<Typography variant="body1" fontWeight="bold" color="#27ae60">{rec.totalEnergySaved.toFixed(1)} kWh</Typography>
<Typography variant="caption" color="#888"><FormattedMessage id="solarEnergyUsed" defaultMessage="Energy Saved" /></Typography>
</Box>
</Grid>
<Grid item xs={6} sm={3}>
<Box sx={{ textAlign: 'center' }}>
<Typography variant="body1" fontWeight="bold" color="#2980b9">~{rec.totalSavingsCHF.toFixed(0)} CHF</Typography>
<Typography variant="caption" color="#888"><FormattedMessage id="estMoneySaved" defaultMessage="Est. Money Saved" /></Typography>
</Box>
</Grid>
<Grid item xs={6} sm={3}>
<Box sx={{ textAlign: 'center' }}>
<Typography variant="body1" fontWeight="bold" color="#8e44ad">{rec.selfSufficiencyPercent.toFixed(0)}%</Typography>
<Typography variant="caption" color="#888"><FormattedMessage id="solarCoverage" defaultMessage="Self-Sufficiency" /></Typography>
</Box>
</Grid>
<Grid item xs={6} sm={3}>
<Box sx={{ textAlign: 'center' }}>
<Typography variant="body1" fontWeight="bold" color="#e67e22">{rec.batteryEfficiencyPercent.toFixed(0)}%</Typography>
<Typography variant="caption" color="#888"><FormattedMessage id="batteryEfficiency" defaultMessage="Battery Eff." /></Typography>
</Box>
</Grid>
</Grid>
<Box component="table" sx={{ width: '100%', borderCollapse: 'collapse', fontSize: '13px', '& td': { p: 0.8, borderBottom: '1px solid #f0f0f0' } }}>
<tbody>
<tr>
<td><FormattedMessage id="gridImport" defaultMessage="Grid Import" /></td>
<td style={{ textAlign: 'right' }}>{rec.totalGridImport.toFixed(1)} kWh</td>
<td style={{ width: 20 }} />
<td><FormattedMessage id="gridExport" defaultMessage="Grid Export" /></td>
<td style={{ textAlign: 'right' }}>{rec.totalGridExport.toFixed(1)} kWh</td>
</tr>
<tr>
<td><FormattedMessage id="batteryInOut" defaultMessage="Battery In / Out" /></td>
<td style={{ textAlign: 'right' }} colSpan={4}>{rec.totalBatteryCharged.toFixed(1)} / {rec.totalBatteryDischarged.toFixed(1)} kWh</td>
</tr>
</tbody>
</Box>
{rec.aiInsight && rec.aiInsight.length > 10 && (
<Box sx={{ mt: 1.5, bgcolor: '#fef9e7', borderLeft: '3px solid #f39c12', p: 1.5, borderRadius: '0 6px 6px 0', fontSize: '12px', color: '#555' }}>
{rec.aiInsight.split(/\n+/).filter(l => l.trim()).slice(0, 2).map((line, i) => (
<Typography key={i} variant="caption" display="block" sx={{ lineHeight: 1.5 }}>
{line.replace(/^[-*]\s*/, '').replace(/^[\d]+[.)]\s*/, '')}
</Typography>
))}
</Box>
)}
</AccordionDetails>
</Accordion>
))}
</Paper>
);
}
// ── 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 && (
<Paper sx={{ p: 2, mb: 3, bgcolor: '#f0f7ff', border: '1px solid #bbdefb' }}>
<Typography variant="subtitle2" sx={{ mb: 1.5, color: '#1565c0', fontWeight: 'bold' }}>
<FormattedMessage id="availableForGeneration" defaultMessage="Available for Generation" />
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{pendingMonths.map(p => {
const key = `monthly-${p.year}-${p.month}`;
const isGenerating = generating === key;
return (
<Button
key={key}
variant="outlined"
size="small"
disabled={generating !== null}
onClick={() => onGenerate(p.year, p.month)}
startIcon={isGenerating ? <CircularProgress size={14} /> : undefined}
sx={{ textTransform: 'none' }}
>
{isGenerating
? intl.formatMessage({ id: 'generatingMonthly', defaultMessage: 'Generating...' })
: intl.formatMessage(
{ id: 'generateMonth', defaultMessage: 'Generate {month} {year} ({count} weeks)' },
{ month: MONTH_NAMES[p.month], year: p.year, count: p.weekCount }
)
}
</Button>
);
})}
</Box>
</Paper>
)}
{/* Existing monthly reports */}
{reports.length > 0 ? (
<AggregatedSection
reports={reports}
type="monthly"
labelFn={(r: MonthlyReport) => `${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 ? (
<Alert severity="info">
<FormattedMessage id="noMonthlyData" defaultMessage="No monthly reports available yet. Weekly reports will appear here for aggregation once generated." />
</Alert>
) : 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 && (
<Paper sx={{ p: 2, mb: 3, bgcolor: '#f0f7ff', border: '1px solid #bbdefb' }}>
<Typography variant="subtitle2" sx={{ mb: 1.5, color: '#1565c0', fontWeight: 'bold' }}>
<FormattedMessage id="availableForGeneration" defaultMessage="Available for Generation" />
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{pendingYears.map(p => {
const key = `yearly-${p.year}`;
const isGenerating = generating === key;
return (
<Button
key={key}
variant="outlined"
size="small"
disabled={generating !== null}
onClick={() => onGenerate(p.year)}
startIcon={isGenerating ? <CircularProgress size={14} /> : undefined}
sx={{ textTransform: 'none' }}
>
{isGenerating
? intl.formatMessage({ id: 'generatingYearly', defaultMessage: 'Generating...' })
: intl.formatMessage(
{ id: 'generateYear', defaultMessage: 'Generate {year} ({count} months)' },
{ year: p.year, count: p.monthCount }
)
}
</Button>
);
})}
</Box>
</Paper>
)}
{/* Existing yearly reports */}
{reports.length > 0 ? (
<AggregatedSection
reports={reports}
type="yearly"
labelFn={(r: YearlyReport) => `${r.year}`}
countLabelId="monthsAggregated"
countFn={(r: YearlyReport) => r.monthCount}
sendEndpoint="/SendYearlyReportEmail"
sendParamsFn={(r: YearlyReport) => ({ installationId, year: r.year })}
/>
) : pendingYears.length === 0 ? (
<Alert severity="info">
<FormattedMessage id="noYearlyData" defaultMessage="No yearly reports available yet. Monthly reports will appear here for aggregation once generated." />
</Alert>
) : null}
</>
);
}
// ── Aggregated Section (Monthly / Yearly) ──────────────────────
function AggregatedSection<T extends ReportSummary>({
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 (
<Alert severity="info">
<FormattedMessage
id={type === 'monthly' ? 'noMonthlyData' : 'noYearlyData'}
defaultMessage={type === 'monthly' ? 'No monthly reports available yet.' : 'No yearly reports available yet.'}
/>
</Alert>
);
}
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 */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexWrap: 'wrap', gap: 2, mb: 2 }}>
{reports.length > 1 && (
<Select
value={selectedIdx}
onChange={(e) => setSelectedIdx(Number(e.target.value))}
size="small"
sx={{ minWidth: 200 }}
>
{reports.map((rep, i) => (
<MenuItem key={i} value={i}>{labelFn(rep)}</MenuItem>
))}
</Select>
)}
<Box sx={{ ml: 'auto' }}>
<EmailBar onSend={handleSendEmail} />
</Box>
</Box>
{/* Header */}
<Paper sx={{ bgcolor: '#2c3e50', color: '#fff', p: 3, mb: 3, borderRadius: 2 }}>
<Typography variant="h5" fontWeight="bold">
<FormattedMessage id={titleId} defaultMessage={type === 'monthly' ? 'Monthly Performance Report' : 'Annual Performance Report'} />
</Typography>
<Typography variant="body2" sx={{ opacity: 0.7 }}>
{r.periodStart} {r.periodEnd}
</Typography>
<Typography variant="caption" sx={{ opacity: 0.6 }}>
<FormattedMessage id={countLabelId} defaultMessage="{count} periods aggregated" values={{ count: countFn(r) }} />
</Typography>
</Paper>
{/* AI Insights */}
<Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="h6" fontWeight="bold" sx={{ mb: 2, color: '#2c3e50' }}>
<FormattedMessage id={insightsId} defaultMessage={type === 'monthly' ? 'Monthly Insights' : 'Annual Insights'} />
</Typography>
<InsightBox text={r.aiInsight} bullets={insightBullets} />
</Paper>
{/* Savings Cards */}
<Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="h6" fontWeight="bold" sx={{ mb: 2, color: '#2c3e50' }}>
<FormattedMessage id={savingsId} defaultMessage={type === 'monthly' ? 'Your Savings This Month' : 'Your Savings This Year'} />
</Typography>
<SavingsCards
intl={intl}
energySaved={r.totalEnergySaved}
savingsCHF={r.totalSavingsCHF}
selfSufficiency={r.selfSufficiencyPercent}
batteryEfficiency={r.batteryEfficiencyPercent}
/>
</Paper>
{/* Summary Table */}
<Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="h6" fontWeight="bold" sx={{ mb: 2, color: '#2c3e50' }}>
<FormattedMessage id={summaryId} defaultMessage={type === 'monthly' ? 'Monthly Summary' : 'Annual Summary'} />
</Typography>
<Box component="table" sx={{ width: '100%', borderCollapse: 'collapse', '& td, & th': { p: 1.2, borderBottom: '1px solid #eee' } }}>
<thead>
<tr style={{ background: '#f8f9fa' }}>
<th style={{ textAlign: 'left' }}><FormattedMessage id="metric" defaultMessage="Metric" /></th>
<th style={{ textAlign: 'right' }}><FormattedMessage id="total" defaultMessage="Total" /></th>
</tr>
</thead>
<tbody>
<tr>
<td><FormattedMessage id="pvProduction" defaultMessage="PV Production" /></td>
<td style={{ textAlign: 'right', fontWeight: 'bold' }}>{r.totalPvProduction.toFixed(1)} kWh</td>
</tr>
<tr>
<td><FormattedMessage id="consumption" defaultMessage="Consumption" /></td>
<td style={{ textAlign: 'right', fontWeight: 'bold' }}>{r.totalConsumption.toFixed(1)} kWh</td>
</tr>
<tr>
<td><FormattedMessage id="gridImport" defaultMessage="Grid Import" /></td>
<td style={{ textAlign: 'right', fontWeight: 'bold' }}>{r.totalGridImport.toFixed(1)} kWh</td>
</tr>
<tr>
<td><FormattedMessage id="gridExport" defaultMessage="Grid Export" /></td>
<td style={{ textAlign: 'right', fontWeight: 'bold' }}>{r.totalGridExport.toFixed(1)} kWh</td>
</tr>
<tr>
<td><FormattedMessage id="batteryInOut" defaultMessage="Battery Charge / Discharge" /></td>
<td style={{ textAlign: 'right', fontWeight: 'bold' }}>{r.totalBatteryCharged.toFixed(1)} / {r.totalBatteryDischarged.toFixed(1)} kWh</td>
</tr>
</tbody>
</Box>
</Paper>
</>
);
}
// ── Shared Components ──────────────────────────────────────────
function InsightBox({ text, bullets }: { text: string; bullets: string[] }) {
return (
<Box
sx={{
bgcolor: '#fef9e7',
borderLeft: '4px solid #f39c12',
p: 2.5,
borderRadius: '0 8px 8px 0'
}}
>
{bullets.length > 1 ? (
<Box component="ul" sx={{ m: 0, pl: 2.5, '& li': { mb: 1.2, lineHeight: 1.7, fontSize: '14px', color: '#333' } }}>
{bullets.map((bullet, i) => (
<li key={i}><FormattedBullet text={bullet} /></li>
))}
</Box>
) : (
<Typography sx={{ lineHeight: 1.7, fontSize: '14px', color: '#333' }}>
<FormattedBullet text={text} />
</Typography>
)}
</Box> </Box>
); );
} }
function SavingsCards({ intl, energySaved, savingsCHF, selfSufficiency, batteryEfficiency, hint }: {
intl: any;
energySaved: number;
savingsCHF: number;
selfSufficiency: number;
batteryEfficiency: number;
hint?: string;
}) {
return (
<Grid container spacing={2}>
<Grid item xs={6} sm={3}>
<SavingsCard
label={intl.formatMessage({ id: 'solarEnergyUsed' })}
value={`${energySaved} kWh`}
subtitle={intl.formatMessage({ id: 'solarStayedHome' })}
color="#27ae60"
hint={hint}
/>
</Grid>
<Grid item xs={6} sm={3}>
<SavingsCard
label={intl.formatMessage({ id: 'estMoneySaved' })}
value={`~${savingsCHF} CHF`}
subtitle={intl.formatMessage({ id: 'atCHFRate' })}
color="#2980b9"
/>
</Grid>
<Grid item xs={6} sm={3}>
<SavingsCard
label={intl.formatMessage({ id: 'solarCoverage' })}
value={`${selfSufficiency.toFixed(0)}%`}
subtitle={intl.formatMessage({ id: 'fromSolarSub' })}
color="#8e44ad"
/>
</Grid>
<Grid item xs={6} sm={3}>
<SavingsCard
label={intl.formatMessage({ id: 'batteryEfficiency' })}
value={`${batteryEfficiency.toFixed(0)}%`}
subtitle={intl.formatMessage({ id: 'batteryEffSub' })}
color="#e67e22"
/>
</Grid>
</Grid>
);
}
function SavingsCard({ label, value, subtitle, color, hint }: { label: string; value: string; subtitle: string; color: string; hint?: string }) { function SavingsCard({ label, value, subtitle, color, hint }: { label: string; value: string; subtitle: string; color: string; hint?: string }) {
return ( return (
<Box <Box

View File

@ -133,6 +133,28 @@
"confirmBatteryLogDownload": "Möchten Sie das Batterieprotokoll wirklich herunterladen?", "confirmBatteryLogDownload": "Möchten Sie das Batterieprotokoll wirklich herunterladen?",
"downloadBatteryLogFailed": "Herunterladen des Batterieprotokolls fehlgeschlagen, bitte versuchen Sie es erneut.", "downloadBatteryLogFailed": "Herunterladen des Batterieprotokolls fehlgeschlagen, bitte versuchen Sie es erneut.",
"noReportData": "Keine Berichtsdaten gefunden.", "noReportData": "Keine Berichtsdaten gefunden.",
"weeklyTab": "Wöchentlich",
"monthlyTab": "Monatlich",
"yearlyTab": "Jährlich",
"monthlyReportTitle": "Monatlicher Leistungsbericht",
"yearlyReportTitle": "Jährlicher Leistungsbericht",
"monthlyInsights": "Monatliche Einblicke",
"yearlyInsights": "Jährliche Einblicke",
"monthlySavings": "Ihre Einsparungen diesen Monat",
"yearlySavings": "Ihre Einsparungen dieses Jahr",
"monthlySummary": "Monatliche Zusammenfassung",
"yearlySummary": "Jährliche Zusammenfassung",
"total": "Gesamt",
"weeksAggregated": "{count} Wochen aggregiert",
"monthsAggregated": "{count} Monate aggregiert",
"noMonthlyData": "Noch keine monatlichen Berichte verfügbar. Wöchentliche Berichte erscheinen hier zur Aggregation.",
"noYearlyData": "Noch keine jährlichen Berichte verfügbar. Monatliche Berichte erscheinen hier zur Aggregation.",
"availableForGeneration": "Zur Generierung verfügbar",
"generateMonth": "{month} {year} generieren ({count} Wochen)",
"generateYear": "{year} generieren ({count} Monate)",
"generatingMonthly": "Wird generiert...",
"generatingYearly": "Wird generiert...",
"thisMonthWeeklyReports": "Wöchentliche Berichte dieses Monats",
"ai_analyzing": "KI analysiert...", "ai_analyzing": "KI analysiert...",
"ai_show_details": "Details anzeigen", "ai_show_details": "Details anzeigen",
"ai_show_less": "Weniger anzeigen", "ai_show_less": "Weniger anzeigen",

View File

@ -115,6 +115,28 @@
"confirmBatteryLogDownload": "Do you really want to download battery log?", "confirmBatteryLogDownload": "Do you really want to download battery log?",
"downloadBatteryLogFailed": "Download battery log failed, please try again.", "downloadBatteryLogFailed": "Download battery log failed, please try again.",
"noReportData": "No report data found.", "noReportData": "No report data found.",
"weeklyTab": "Weekly",
"monthlyTab": "Monthly",
"yearlyTab": "Yearly",
"monthlyReportTitle": "Monthly Performance Report",
"yearlyReportTitle": "Annual Performance Report",
"monthlyInsights": "Monthly Insights",
"yearlyInsights": "Annual Insights",
"monthlySavings": "Your Savings This Month",
"yearlySavings": "Your Savings This Year",
"monthlySummary": "Monthly Summary",
"yearlySummary": "Annual Summary",
"total": "Total",
"weeksAggregated": "{count} weeks aggregated",
"monthsAggregated": "{count} months aggregated",
"noMonthlyData": "No monthly reports available yet. Weekly reports will appear here for aggregation once generated.",
"noYearlyData": "No yearly reports available yet. Monthly reports will appear here for aggregation once generated.",
"availableForGeneration": "Available for Generation",
"generateMonth": "Generate {month} {year} ({count} weeks)",
"generateYear": "Generate {year} ({count} months)",
"generatingMonthly": "Generating...",
"generatingYearly": "Generating...",
"thisMonthWeeklyReports": "This Month's Weekly Reports",
"ai_analyzing": "AI is analyzing...", "ai_analyzing": "AI is analyzing...",
"ai_show_details": "Show details", "ai_show_details": "Show details",
"ai_show_less": "Show less", "ai_show_less": "Show less",

View File

@ -127,6 +127,28 @@
"confirmBatteryLogDownload": "Voulez-vous vraiment télécharger le journal de la batterie?", "confirmBatteryLogDownload": "Voulez-vous vraiment télécharger le journal de la batterie?",
"downloadBatteryLogFailed": "Échec du téléchargement du journal de la batterie, veuillez réessayer.", "downloadBatteryLogFailed": "Échec du téléchargement du journal de la batterie, veuillez réessayer.",
"noReportData": "Aucune donnée de rapport trouvée.", "noReportData": "Aucune donnée de rapport trouvée.",
"weeklyTab": "Hebdomadaire",
"monthlyTab": "Mensuel",
"yearlyTab": "Annuel",
"monthlyReportTitle": "Rapport de performance mensuel",
"yearlyReportTitle": "Rapport de performance annuel",
"monthlyInsights": "Aperçus mensuels",
"yearlyInsights": "Aperçus annuels",
"monthlySavings": "Vos économies ce mois",
"yearlySavings": "Vos économies cette année",
"monthlySummary": "Résumé mensuel",
"yearlySummary": "Résumé annuel",
"total": "Total",
"weeksAggregated": "{count} semaines agrégées",
"monthsAggregated": "{count} mois agrégés",
"noMonthlyData": "Aucun rapport mensuel disponible. Les rapports hebdomadaires apparaîtront ici pour l'agrégation.",
"noYearlyData": "Aucun rapport annuel disponible. Les rapports mensuels apparaîtront ici pour l'agrégation.",
"availableForGeneration": "Disponible pour génération",
"generateMonth": "Générer {month} {year} ({count} semaines)",
"generateYear": "Générer {year} ({count} mois)",
"generatingMonthly": "Génération en cours...",
"generatingYearly": "Génération en cours...",
"thisMonthWeeklyReports": "Rapports hebdomadaires de ce mois",
"ai_analyzing": "L'IA analyse...", "ai_analyzing": "L'IA analyse...",
"ai_show_details": "Afficher les détails", "ai_show_details": "Afficher les détails",
"ai_show_less": "Afficher moins", "ai_show_less": "Afficher moins",

View File

@ -138,6 +138,28 @@
"confirmBatteryLogDownload": "Vuoi davvero scaricare il registro della batteria?", "confirmBatteryLogDownload": "Vuoi davvero scaricare il registro della batteria?",
"downloadBatteryLogFailed": "Download del registro della batteria fallito, riprovare.", "downloadBatteryLogFailed": "Download del registro della batteria fallito, riprovare.",
"noReportData": "Nessun dato del rapporto trovato.", "noReportData": "Nessun dato del rapporto trovato.",
"weeklyTab": "Settimanale",
"monthlyTab": "Mensile",
"yearlyTab": "Annuale",
"monthlyReportTitle": "Rapporto mensile sulle prestazioni",
"yearlyReportTitle": "Rapporto annuale sulle prestazioni",
"monthlyInsights": "Approfondimenti mensili",
"yearlyInsights": "Approfondimenti annuali",
"monthlySavings": "I tuoi risparmi questo mese",
"yearlySavings": "I tuoi risparmi quest'anno",
"monthlySummary": "Riepilogo mensile",
"yearlySummary": "Riepilogo annuale",
"total": "Totale",
"weeksAggregated": "{count} settimane aggregate",
"monthsAggregated": "{count} mesi aggregati",
"noMonthlyData": "Nessun rapporto mensile ancora disponibile. I rapporti settimanali appariranno qui per l'aggregazione.",
"noYearlyData": "Nessun rapporto annuale ancora disponibile. I rapporti mensili appariranno qui per l'aggregazione.",
"availableForGeneration": "Disponibile per la generazione",
"generateMonth": "Genera {month} {year} ({count} settimane)",
"generateYear": "Genera {year} ({count} mesi)",
"generatingMonthly": "Generazione in corso...",
"generatingYearly": "Generazione in corso...",
"thisMonthWeeklyReports": "Rapporti settimanali di questo mese",
"ai_analyzing": "L'IA sta analizzando...", "ai_analyzing": "L'IA sta analizzando...",
"ai_show_details": "Mostra dettagli", "ai_show_details": "Mostra dettagli",
"ai_show_less": "Mostra meno", "ai_show_less": "Mostra meno",