a lot of fixes to report system

This commit is contained in:
Yinyin Liu 2026-03-30 14:36:50 +02:00
parent dc5b09d1f2
commit 706e0674fb
13 changed files with 827 additions and 627 deletions

View File

@ -961,6 +961,46 @@ public class Controller : ControllerBase
// ── Weekly Performance Report ──────────────────────────────────────
private async Task<WeeklyReportResponse?> FetchWeeklyReportAsync(
Int64 installationId, String installationName, String lang,
DateOnly? weekStartDate = null, Boolean forceRegenerate = false)
{
DateOnly periodStart, periodEnd;
if (weekStartDate.HasValue)
{
periodStart = weekStartDate.Value;
periodEnd = weekStartDate.Value.AddDays(6);
}
else
{
(periodStart, periodEnd) = WeeklyReportService.LastCalendarWeek();
}
var periodStartStr = periodStart.ToString("yyyy-MM-dd");
var periodEndStr = periodEnd.ToString("yyyy-MM-dd");
if (!forceRegenerate)
{
var cached = Db.GetWeeklyReportForWeek(installationId, periodStartStr, periodEndStr);
if (cached != null)
{
var cachedResponse = await ReportAggregationService.ToWeeklyReportResponseAsync(cached, lang);
if (cachedResponse != null)
{
Console.WriteLine($"[WeeklyReport] Serving cached report for installation {installationId}, period {periodStartStr}{periodEndStr}, language={lang}");
return cachedResponse;
}
}
}
Console.WriteLine($"[WeeklyReport] Generating fresh report for installation {installationId}, period {periodStartStr}{periodEndStr}");
var report = await WeeklyReportService.GenerateReportAsync(
installationId, installationName, lang, weekStartDate);
ReportAggregationService.SaveWeeklySummary(installationId, report, lang);
return report;
}
/// <summary>
/// Returns a weekly performance report. Serves from cache if available;
/// generates fresh on first request or when forceRegenerate is true.
@ -991,43 +1031,9 @@ public class Controller : ControllerBase
{
var lang = language ?? user.Language ?? "en";
// Compute target week dates for cache lookup
DateOnly periodStart, periodEnd;
if (weekStartDate.HasValue)
{
periodStart = weekStartDate.Value;
periodEnd = weekStartDate.Value.AddDays(6);
}
else
{
(periodStart, periodEnd) = WeeklyReportService.LastCalendarWeek();
}
var periodStartStr = periodStart.ToString("yyyy-MM-dd");
var periodEndStr = periodEnd.ToString("yyyy-MM-dd");
// Cache-first: check if a cached report exists for this week
if (!forceRegenerate)
{
var cached = Db.GetWeeklyReportForWeek(installationId, periodStartStr, periodEndStr);
if (cached != null)
{
var cachedResponse = await ReportAggregationService.ToWeeklyReportResponseAsync(cached, lang);
if (cachedResponse != null)
{
Console.WriteLine($"[GetWeeklyReport] Serving cached report for installation {installationId}, period {periodStartStr}{periodEndStr}, language={lang}");
return Ok(cachedResponse);
}
}
}
// Cache miss or forceRegenerate: generate fresh
Console.WriteLine($"[GetWeeklyReport] Generating fresh report for installation {installationId}, period {periodStartStr}{periodEndStr}");
var report = await WeeklyReportService.GenerateReportAsync(
installationId, installation.Name, lang, weekStartDate);
// Persist weekly summary and seed AiInsightCache for this language
ReportAggregationService.SaveWeeklySummary(installationId, report, lang);
var report = await FetchWeeklyReportAsync(installationId, installation.Name, lang, weekStartDate, forceRegenerate);
if (report == null)
return BadRequest("Failed to generate report.");
return Ok(report);
}
@ -1134,6 +1140,46 @@ public class Controller : ControllerBase
return Ok(reports);
}
[HttpGet(nameof(GetCurrentMonthPreview))]
public async Task<ActionResult<MonthlyReportSummary>> GetCurrentMonthPreview(
Int64 installationId, Token authToken, String? language = null)
{
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 lang = language ?? user.Language ?? "en";
var preview = await ReportAggregationService.GetCurrentMonthPreviewAsync(installationId, lang);
if (preview == null)
return NotFound("No daily data for the current month.");
return Ok(preview);
}
[HttpGet(nameof(GetCurrentYearPreview))]
public async Task<ActionResult<YearlyReportSummary>> GetCurrentYearPreview(
Int64 installationId, Token authToken, String? language = null)
{
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 lang = language ?? user.Language ?? "en";
var preview = await ReportAggregationService.GetCurrentYearPreviewAsync(installationId, lang);
if (preview == null)
return NotFound("No monthly reports for the current year.");
return Ok(preview);
}
/// <summary>
/// Manually trigger monthly aggregation for an installation.
/// Computes monthly report from daily records for the specified year/month.
@ -1514,7 +1560,9 @@ public class Controller : ControllerBase
// ── Report HTML (for PDF download) ─────────────────────────────
[HttpGet(nameof(GetWeeklyReportHtml))]
public async Task<ActionResult> GetWeeklyReportHtml(Int64 installationId, Token authToken, String? language = null)
public async Task<ActionResult> GetWeeklyReportHtml(
Int64 installationId, Token authToken,
String? language = null, String? weekStart = null, String source = "email")
{
var user = Db.GetSession(authToken)?.User;
if (user == null) return Unauthorized();
@ -1522,14 +1570,26 @@ public class Controller : ControllerBase
var installation = Db.GetInstallationById(installationId);
if (installation is null || !user.HasAccessTo(installation)) return Unauthorized();
var lang = language ?? user.Language ?? "en";
var report = await WeeklyReportService.GenerateReportAsync(installationId, installation.Name, lang);
var html = ReportEmailService.BuildHtmlEmail(report, lang);
var lang = language ?? user.Language ?? "en";
DateOnly? weekStartDate = null;
if (!String.IsNullOrEmpty(weekStart))
{
if (!DateOnly.TryParseExact(weekStart, "yyyy-MM-dd", out var parsed))
return BadRequest("weekStart must be in yyyy-MM-dd format.");
weekStartDate = parsed;
}
var report = await FetchWeeklyReportAsync(installationId, installation.Name, lang, weekStartDate);
if (report == null)
return BadRequest("Failed to generate report.");
var html = ReportEmailService.BuildHtmlEmail(report, lang, source: source);
return Content(html, "text/html");
}
[HttpGet(nameof(GetMonthlyReportHtml))]
public async Task<ActionResult> GetMonthlyReportHtml(Int64 installationId, Int32 year, Int32 month, Token authToken, String? language = null)
public async Task<ActionResult> GetMonthlyReportHtml(Int64 installationId, Int32 year, Int32 month, Token authToken, String? language = null, String source = "email")
{
var user = Db.GetSession(authToken)?.User;
if (user == null) return Unauthorized();
@ -1548,12 +1608,12 @@ public class Controller : ControllerBase
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);
$"{report.WeekCount} {s.CountLabel}", s, source: source);
return Content(html, "text/html");
}
[HttpGet(nameof(GetYearlyReportHtml))]
public async Task<ActionResult> GetYearlyReportHtml(Int64 installationId, Int32 year, Token authToken, String? language = null)
public async Task<ActionResult> GetYearlyReportHtml(Int64 installationId, Int32 year, Token authToken, String? language = null, String source = "email")
{
var user = Db.GetSession(authToken)?.User;
if (user == null) return Unauthorized();
@ -1572,7 +1632,7 @@ public class Controller : ControllerBase
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);
$"{report.MonthCount} {s.CountLabel}", s, source: source);
return Content(html, "text/html");
}

View File

@ -94,6 +94,11 @@ public class MonthlyReportSummary
public Int32 WeekCount { get; set; }
public String AiInsight { get; set; } = "";
public String CreatedAt { get; set; } = "";
// Preview-only fields (not stored in DB)
[Ignore] public Boolean IsPreview { get; set; }
[Ignore] public Int32 DaysAvailable { get; set; }
[Ignore] public Int32 DaysInMonth { get; set; }
}
/// <summary>
@ -137,19 +142,22 @@ public class YearlyReportSummary
public Int32 MonthCount { get; set; }
public String AiInsight { get; set; } = "";
public String CreatedAt { get; set; } = "";
// Preview-only fields (not stored in DB)
[Ignore] public Boolean IsPreview { 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 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; }
public Int32 Year { get; set; }
public Int32 MonthCount { get; set; }
}

View File

@ -382,8 +382,8 @@ public static class ReportAggregationService
InstallationId = installationId,
Year = year,
Month = month,
PeriodStart = first.ToString("yyyy-MM-dd"),
PeriodEnd = last.ToString("yyyy-MM-dd"),
PeriodStart = days.Min(d => d.Date), // actual first data day, not calendar month start
PeriodEnd = days.Max(d => d.Date), // actual last data day, not calendar month end
TotalPvProduction = totalPv,
TotalConsumption = totalConsump,
TotalGridImport = totalGridIn,
@ -881,4 +881,132 @@ Rules: Write in {langName}. Write for a homeowner. No asterisks or formatting ma
return "AI insight could not be generated at this time.";
}
// ── Current-Period Previews (not saved to DB) ─────────────────────
public static async Task<MonthlyReportSummary?> GetCurrentMonthPreviewAsync(Int64 installationId, String language = "en")
{
var now = DateTime.UtcNow;
var year = now.Year;
var month = now.Month;
var first = new DateOnly(year, month, 1);
var last = first.AddMonths(1).AddDays(-1);
var days = Db.GetDailyRecords(installationId, first, last);
if (days.Count == 0)
return null;
var totalPv = Math.Round(days.Sum(d => d.PvProduction), 1);
var totalConsump = Math.Round(days.Sum(d => d.LoadConsumption), 1);
var totalGridIn = Math.Round(days.Sum(d => d.GridImport), 1);
var totalGridOut = Math.Round(days.Sum(d => d.GridExport), 1);
var totalBattChg = Math.Round(days.Sum(d => d.BatteryCharged), 1);
var totalBattDis = Math.Round(days.Sum(d => d.BatteryDischarged), 1);
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 installation = Db.GetInstallationById(installationId);
var installationName = installation?.Name ?? $"Installation {installationId}";
var monthName = new DateTime(year, month, 1).ToString("MMMM yyyy");
var weatherCity = !string.IsNullOrWhiteSpace(installation?.City) ? installation.City : installation?.Location;
var weatherRegion = !string.IsNullOrWhiteSpace(installation?.Canton) ? installation.Canton : installation?.Region;
var aiInsight = await GenerateMonthlyAiInsightAsync(
installationName, monthName, days.Count,
totalPv, totalConsump, totalGridIn, totalGridOut,
totalBattChg, totalBattDis, energySaved, savingsCHF,
selfSufficiency, batteryEff, language,
weatherCity, installation?.Country, weatherRegion);
var firstDataDay = days.Min(d => d.Date);
var lastDataDay = days.Max(d => d.Date);
return new MonthlyReportSummary
{
InstallationId = installationId,
Year = year,
Month = month,
PeriodStart = firstDataDay,
PeriodEnd = lastDataDay,
TotalPvProduction = totalPv,
TotalConsumption = totalConsump,
TotalGridImport = totalGridIn,
TotalGridExport = totalGridOut,
TotalBatteryCharged = totalBattChg,
TotalBatteryDischarged = totalBattDis,
TotalEnergySaved = energySaved,
TotalSavingsCHF = savingsCHF,
SelfSufficiencyPercent = selfSufficiency,
SelfConsumptionPercent = selfConsumption,
BatteryEfficiencyPercent = batteryEff,
GridDependencyPercent = gridDependency,
WeekCount = days.Count,
AiInsight = aiInsight,
CreatedAt = DateTime.UtcNow.ToString("o"),
IsPreview = true,
DaysAvailable = days.Count,
DaysInMonth = last.Day,
};
}
public static async Task<YearlyReportSummary?> GetCurrentYearPreviewAsync(Int64 installationId, String language = "en")
{
var year = DateTime.UtcNow.Year;
var monthlies = Db.GetMonthlyReportsForYear(installationId, year);
if (monthlies.Count == 0)
return null;
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);
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 installation = Db.GetInstallationById(installationId);
var installationName = installation?.Name ?? $"Installation {installationId}";
var aiInsight = await GenerateYearlyAiInsightAsync(
installationName, year, monthlies.Count,
totalPv, totalConsump, totalGridIn, totalGridOut,
totalBattChg, totalBattDis, energySaved, savingsCHF,
selfSufficiency, batteryEff, language);
return 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,
MonthCount = monthlies.Count,
AiInsight = aiInsight,
CreatedAt = DateTime.UtcNow.ToString("o"),
IsPreview = true,
};
}
}

File diff suppressed because one or more lines are too long

View File

@ -40,6 +40,33 @@ public static class WeatherService
}
}
/// <summary>
/// Returns historical weather for a date range, or null on any failure.
/// Uses Open-Meteo's archive API for past weather data.
/// </summary>
public static async Task<List<DailyWeather>?> GetHistoricalAsync(
string? city, string? country, string? region,
DateOnly startDate, DateOnly endDate)
{
if (string.IsNullOrWhiteSpace(city))
return null;
try
{
var coords = await GeocodeAsync(city, region);
if (coords == null)
return null;
var (lat, lon) = coords.Value;
return await FetchHistoricalAsync(lat, lon, startDate, endDate);
}
catch (Exception ex)
{
Console.Error.WriteLine($"[WeatherService] Error fetching historical weather for '{city}': {ex.Message}");
return null;
}
}
/// <summary>
/// Formats a forecast list into a compact text block for AI prompt injection.
/// </summary>
@ -52,7 +79,22 @@ public static class WeatherService
return $"- {dayName}: {d.TempMin:F0}{d.TempMax:F0}°C, {d.Description}, {d.SunshineHours:F1}h sunshine, {d.PrecipitationMm:F0}mm rain";
});
return "WEATHER FORECAST (next 7 days):\n" + string.Join("\n", lines);
return "WEATHER FORECAST (coming 7 days):\n" + string.Join("\n", lines);
}
/// <summary>
/// Formats historical weather into a compact text block for AI prompt injection.
/// </summary>
public static string FormatHistoricalForPrompt(List<DailyWeather> historical)
{
var lines = historical.Select(d =>
{
var date = DateTime.Parse(d.Date);
var dayName = date.ToString("ddd dd MMM");
return $"- {dayName}: {d.TempMin:F0}{d.TempMax:F0}°C, {d.Description}, {d.SunshineHours:F1}h sunshine, {d.PrecipitationMm:F0}mm rain";
});
return "ACTUAL WEATHER (during reporting week):\n" + string.Join("\n", lines);
}
/// <summary>
@ -145,6 +187,44 @@ public static class WeatherService
return forecast;
}
private static async Task<List<DailyWeather>?> FetchHistoricalAsync(double lat, double lon, DateOnly startDate, DateOnly endDate)
{
var url = $"https://archive-api.open-meteo.com/v1/archive"
+ $"?latitude={lat}&longitude={lon}"
+ $"&start_date={startDate:yyyy-MM-dd}&end_date={endDate:yyyy-MM-dd}"
+ "&daily=temperature_2m_max,temperature_2m_min,sunshine_duration,precipitation_sum,weathercode"
+ "&timezone=Europe/Zurich";
var json = await url.GetStringAsync();
var data = JsonConvert.DeserializeObject<dynamic>(json);
if (data?.daily == null)
return null;
var dates = data.daily.time;
var tempMax = data.daily.temperature_2m_max;
var tempMin = data.daily.temperature_2m_min;
var sun = data.daily.sunshine_duration;
var precip = data.daily.precipitation_sum;
var codes = data.daily.weathercode;
var historical = new List<DailyWeather>();
for (int i = 0; i < dates.Count; i++)
{
historical.Add(new DailyWeather(
Date: (string)dates[i],
TempMin: (double)tempMin[i],
TempMax: (double)tempMax[i],
SunshineHours: Math.Round((double)sun[i] / 3600.0, 1),
PrecipitationMm: (double)precip[i],
Description: WeatherCodeToDescription((int)codes[i])
));
}
Console.WriteLine($"[WeatherService] Fetched {historical.Count}-day historical weather ({startDate:yyyy-MM-dd} to {endDate:yyyy-MM-dd}).");
return historical;
}
private static string WeatherCodeToDescription(int code) => code switch
{
0 => "Clear sky",

View File

@ -274,7 +274,8 @@ public static class WeeklyReportService
var aiInsight = await GetAiInsightAsync(
currentWeekDays, currentSummary, previousSummary,
selfSufficiency, totalEnergySaved, totalSavingsCHF,
behavior, installationName, language, location, country, region);
behavior, installationName, language,
weekStart, weekEnd, location, country, region);
// Compute data availability — which days of the week are missing
var availableDates = currentWeekDays.Select(d => d.Date).ToHashSet();
@ -356,6 +357,8 @@ public static class WeeklyReportService
BehavioralPattern behavior,
string installationName,
string language = "en",
DateOnly? periodStart = null,
DateOnly? periodEnd = null,
string? location = null,
string? country = null,
string? region = null)
@ -367,7 +370,23 @@ public static class WeeklyReportService
return "AI insight unavailable (API key not configured).";
}
// Fetch weather forecast for the installation's location
// Date labels for prompt clarity
var today = DateOnly.FromDateTime(DateTime.UtcNow);
var periodLabel = periodStart.HasValue && periodEnd.HasValue
? $"{periodStart.Value:MMM dd}{periodEnd.Value:MMM dd}"
: "the reporting week";
// Fetch historical weather for the report week (actual conditions)
List<WeatherService.DailyWeather>? historical = null;
var historicalBlock = "";
if (periodStart.HasValue && periodEnd.HasValue)
{
historical = await WeatherService.GetHistoricalAsync(location, country, region, periodStart.Value, periodEnd.Value);
historicalBlock = historical != null ? "\n" + WeatherService.FormatHistoricalForPrompt(historical) + "\n" : "";
Console.WriteLine($"[WeeklyReportService] Historical weather: {(historical != null ? $"{historical.Count} days fetched" : "SKIPPED (no location or API error)")}");
}
// Fetch weather forecast for the coming week
var forecast = await WeatherService.GetForecastAsync(location, country, region);
var weatherBlock = forecast != null ? "\n" + WeatherService.FormatForPrompt(forecast) + "\n" : "";
Console.WriteLine($"[WeeklyReportService] Weather forecast: {(forecast != null ? $"{forecast.Count} days fetched" : "SKIPPED (no location or API error)")}");
@ -399,22 +418,29 @@ public static class WeeklyReportService
var battDepleteLine = hasBattery
? (behavior.AvgBatteryDepletedHour >= 0
? $"Battery typically depletes below 20% during {FormatHourSlot(behavior.AvgBatteryDepletedHour)}."
: "Battery stayed above 20% SoC every night this week.")
: $"Battery stayed above 20% SoC every night during {periodLabel}.")
: "";
var weekdayWeekendLine = behavior.WeekendAvgDailyLoad > 0
? $"Weekday avg load: {behavior.WeekdayAvgDailyLoad} kWh/day. Weekend avg: {behavior.WeekendAvgDailyLoad} kWh/day."
: $"Weekday avg load: {behavior.WeekdayAvgDailyLoad} kWh/day.";
// Look up actual weather for best/worst solar days (if historical data available)
var bestDayWeather = historical?.FirstOrDefault(w => w.Date == bestDay.Date);
var worstDayWeather = historical?.FirstOrDefault(w => w.Date == worstDay.Date);
var bestDayWeatherNote = bestDayWeather != null ? $" (actual weather: {bestDayWeather.Description}, {bestDayWeather.SunshineHours:F1}h sunshine)" : "";
var worstDayWeatherNote = worstDayWeather != null ? $" (actual weather: {worstDayWeather.Description}, {worstDayWeather.SunshineHours:F1}h sunshine)" : "";
// Build conditional fact lines
var pvDailyFact = hasPv
? $"- PV: total {current.TotalPvProduction:F1} kWh this week. Best day: {bestDayName} ({bestDay.PvProduction:F1} kWh), worst: {worstDayName} ({worstDay.PvProduction:F1} kWh). Solar covered {selfSufficiency}% of consumption."
? $"- PV: total {current.TotalPvProduction:F1} kWh for {periodLabel}. Best day: {bestDayName} ({bestDay.PvProduction:F1} kWh){bestDayWeatherNote}, worst: {worstDayName} ({worstDay.PvProduction:F1} kWh){worstDayWeatherNote}. Solar covered {selfSufficiency}% of consumption."
: "";
var battDailyFact = hasBattery
? $"- Battery: {current.TotalBatteryCharged:F1} kWh charged, {current.TotalBatteryDischarged:F1} kWh discharged. Most active day: {topBattDayName} ({topBattDay.BatteryCharged:F1} kWh charged)."
: "";
var gridDailyFact = hasGrid
? $"- Grid import: {current.TotalGridImport:F1} kWh total this week."
? $"- Grid import: {current.TotalGridImport:F1} kWh total for {periodLabel}."
: "";
// Behavioral section — only include when hourly data exists
@ -432,7 +458,7 @@ public static class WeeklyReportService
var battBehaviorLine = !string.IsNullOrEmpty(battDepleteLine) ? $"- {battDepleteLine}" : "";
behavioralSection = $@"
BEHAVIORAL PATTERN (from hourly data this week):
BEHAVIORAL PATTERN (from hourly data for {periodLabel}):
- Peak household load: {FormatHourSlot(behavior.PeakLoadHour)}, avg {behavior.AvgPeakLoadKwh} kWh during that hour
- {weekdayWeekendLine}{pvBehaviorLines}
{gridBehaviorLine}
@ -440,12 +466,15 @@ BEHAVIORAL PATTERN (from hourly data this week):
}
// Build conditional instructions
var instruction1 = $"1. Energy savings: Write 12 sentences. Say that this week, thanks to sodistore home, the customer avoided buying {totalEnergySaved} kWh from the grid, saving {totalSavingsCHF} CHF (at {ElectricityPriceCHF} CHF/kWh). Use these exact numbers — do not recalculate or change them.";
var instruction1 = $"1. Energy savings: Write 12 sentences. Say that during {periodLabel}, thanks to sodistore home, the customer avoided buying {totalEnergySaved} kWh from the grid, saving {totalSavingsCHF} CHF (at {ElectricityPriceCHF} CHF/kWh). Use these exact numbers — do not recalculate or change them.";
var hasHistorical = historical != null && historical.Count > 0;
var instruction2 = hasPv
? $"2. Solar performance: Comment on the best and worst solar day this week and the likely weather reason."
? hasHistorical
? $"2. Solar performance: Comment on the best and worst solar day during {periodLabel}. Use the ACTUAL WEATHER data provided above to explain why — do NOT guess the weather. Reference the real conditions (sunshine hours, weather description) from the historical weather data."
: $"2. Solar performance: Comment on the best and worst solar day during {periodLabel}. Only state the production numbers — do NOT speculate about weather reasons if no weather data is provided."
: hasGrid
? $"2. Grid usage: Comment on the {current.TotalGridImport:F1} kWh drawn from the grid this week."
? $"2. Grid usage: Comment on the {current.TotalGridImport:F1} kWh drawn from the grid during {periodLabel}."
: "2. Consumption pattern: Comment on the weekday vs weekend load pattern.";
var instruction3 = hasBattery
@ -455,19 +484,24 @@ BEHAVIORAL PATTERN (from hourly data this week):
// Instruction 4 — adapts based on whether we have behavioral data
string instruction4;
if (hasBehavior && hasPv)
instruction4 = $"4. Smart action for next week: Write exactly 2 sentences. Sentence 1: point out the timing mismatch using exact numbers — peak household load is during {FormatHourSlot(behavior.PeakLoadHour)} ({behavior.AvgPeakLoadKwh} kWh) but solar peaks during {FormatHourSlot(behavior.PeakSolarHour)} ({behavior.AvgPeakSolarKwh} kWh), with solar active from {peakSolarWindow}. Sentence 2: suggest shifting energy-intensive appliances (such as washing machine, dishwasher, heat pump, or EV charger if applicable) to run during the solar window {peakSolarWindow} — do not assume which specific device the customer has.";
instruction4 = $"4. Smart action for the coming week: Write exactly 2 sentences. Sentence 1: point out the timing mismatch using exact numbers — peak household load is during {FormatHourSlot(behavior.PeakLoadHour)} ({behavior.AvgPeakLoadKwh} kWh) but solar peaks during {FormatHourSlot(behavior.PeakSolarHour)} ({behavior.AvgPeakSolarKwh} kWh), with solar active from {peakSolarWindow}. Sentence 2: suggest shifting energy-intensive appliances (such as washing machine, dishwasher, heat pump, or EV charger if applicable) to run during the solar window {peakSolarWindow} — do not assume which specific device the customer has.";
else if (hasBehavior && hasGrid)
instruction4 = $"4. Smart action for next week: Write exactly 2 sentences. Sentence 1: state that the peak grid-import hour is {FormatHourSlot(behavior.HighestGridImportHour)} ({behavior.AvgGridImportAtPeakHour} kWh avg). Sentence 2: suggest one action to reduce grid use during that hour — shifting energy-intensive appliances (washing machine, dishwasher, heat pump, EV charger) away from that time.";
instruction4 = $"4. Smart action for the coming week: Write exactly 2 sentences. Sentence 1: state that the peak grid-import hour is {FormatHourSlot(behavior.HighestGridImportHour)} ({behavior.AvgGridImportAtPeakHour} kWh avg). Sentence 2: suggest one action to reduce grid use during that hour — shifting energy-intensive appliances (washing machine, dishwasher, heat pump, EV charger) away from that time.";
else
instruction4 = "4. Smart action for next week: Based on this week's daily energy patterns, suggest one practical tip to maximize self-consumption and reduce grid dependency.";
instruction4 = $"4. Smart action for the coming week: Based on the energy patterns from {periodLabel}, suggest one practical tip to maximize self-consumption and reduce grid dependency.";
// Instruction 5 — weather outlook with pattern-based predictions
var hasWeather = forecast != null;
var bulletCount = hasWeather ? 5 : 4;
// Forecast date range label for prompt
var forecastLabel = forecast != null && forecast.Count > 0
? $"{DateTime.Parse(forecast.First().Date):MMM dd}{DateTime.Parse(forecast.Last().Date):MMM dd}"
: "the coming days";
var instruction5 = "";
if (hasWeather && hasPv)
{
// Compute avg daily PV production this week for reference
// Compute avg daily PV production for the reporting week as reference
var avgDailyPv = currentWeek.Count > 0 ? Math.Round(currentWeek.Average(d => d.PvProduction), 1) : 0;
var bestDayPv = Math.Round(bestDay.PvProduction, 1);
var worstDayPv = Math.Round(worstDay.PvProduction, 1);
@ -477,36 +511,39 @@ BEHAVIORAL PATTERN (from hourly data this week):
var cloudyDays = forecast.Where(d => d.SunshineHours < 3).Select(d => DateTime.Parse(d.Date).ToString("dddd")).ToList();
var totalForecastSunshine = Math.Round(forecast.Sum(d => d.SunshineHours), 1);
var patternContext = $"This week the installation averaged {avgDailyPv} kWh/day PV (best: {bestDayPv} kWh on {bestDayName}, worst: {worstDayPv} kWh on {worstDayName}). ";
var patternContext = $"During {periodLabel} the installation averaged {avgDailyPv} kWh/day PV (best: {bestDayPv} kWh on {bestDayName}, worst: {worstDayPv} kWh on {worstDayName}). ";
if (sunnyDays.Count > 0)
patternContext += $"Next week, sunny days ({string.Join(", ", sunnyDays)}) should produce above average (~{avgDailyPv}+ kWh/day). ";
patternContext += $"In the coming days ({forecastLabel}), sunny days ({string.Join(", ", sunnyDays)}) should produce above average (~{avgDailyPv}+ kWh/day). ";
if (cloudyDays.Count > 0)
patternContext += $"Cloudy/rainy days ({string.Join(", ", cloudyDays)}) will likely produce below average (~{worstDayPv} kWh/day or less). ";
patternContext += $"Total forecast sunshine next week: {totalForecastSunshine}h.";
patternContext += $"Total forecast sunshine for {forecastLabel}: {totalForecastSunshine}h.";
instruction5 = $@"5. Weather outlook: {patternContext} Write 2-3 concise sentences. (1) Name the best solar days next week and estimate production based on this week's pattern. (2) Recommend which specific days are best for running energy-heavy appliances (washing machine, dishwasher, EV charging) to maximize free solar energy. (3) If rainy days follow sunny days, suggest front-loading heavy usage onto the sunny days before the rain arrives. IMPORTANT: Do NOT suggest ""reducing consumption"" in the evening — people need electricity for cooking, lighting, and daily life. Instead, focus on SHIFTING discretionary loads (laundry, dishwasher, EV) to the best solar days. The battery system handles grid charging automatically — do not tell the customer to manage battery charging.";
instruction5 = $@"5. Weather outlook: {patternContext} Write 2-3 concise sentences. (1) Name the best solar days in the coming days ({forecastLabel}) and estimate production based on the reporting week's pattern. (2) Recommend which specific days are best for running energy-heavy appliances (washing machine, dishwasher, EV charging) to maximize free solar energy. (3) If rainy days follow sunny days, suggest front-loading heavy usage onto the sunny days before the rain arrives. IMPORTANT: Do NOT suggest ""reducing consumption"" in the evening — people need electricity for cooking, lighting, and daily life. Instead, focus on SHIFTING discretionary loads (laundry, dishwasher, EV) to the best solar days. The battery system handles grid charging automatically — do not tell the customer to manage battery charging.";
}
else if (hasWeather)
{
instruction5 = @"5. Weather outlook: Summarize the coming week's weather in 1-2 sentences. Mention which days have the most sunshine and suggest those as the best days to run energy-heavy appliances (laundry, dishwasher). Do NOT suggest reducing evening consumption — focus on shifting discretionary loads to sunny days.";
instruction5 = $@"5. Weather outlook: Summarize the weather for the coming days ({forecastLabel}) in 1-2 sentences. Mention which days have the most sunshine and suggest those as the best days to run energy-heavy appliances (laundry, dishwasher). Do NOT suggest reducing evening consumption — focus on shifting discretionary loads to sunny days.";
}
var prompt = $@"You are an energy advisor for a sodistore home installation: ""{installationName}"".
Today is {today:yyyy-MM-dd} ({today:dddd}). This report covers the week of {periodLabel}.
Write {bulletCount} bullet points (each on its own line starting with ""- ""). No bold markers, no asterisks, no markdown plain text only.
IMPORTANT FORMAT RULE: Each bullet MUST start with a short title followed by a colon, then the description. Example: ""- Title label: Description text here."" Translate the title label into {LanguageName(language)} but always keep the ""Title: description"" structure.
CRITICAL: All numbers below are pre-calculated. Use these values as-is do not recalculate, round differently, or change any number.
CRITICAL: Use explicit date references. Say ""during {periodLabel}"" for the reporting week. Say ""the coming days ({forecastLabel})"" for the forecast period. NEVER use ambiguous terms like ""this week"" or ""next week"".
SYSTEM COMPONENTS: PV={hasPv}, Battery={hasBattery}, Grid={hasGrid}
DAILY FACTS:
- Total consumption: {current.TotalConsumption:F1} kWh this week. Self-sufficiency: {selfSufficiency}%.
DAILY FACTS (for {periodLabel}):
- Total consumption: {current.TotalConsumption:F1} kWh. Self-sufficiency: {selfSufficiency}%.
{pvDailyFact}
{battDailyFact}
{gridDailyFact}
{behavioralSection}
{historicalBlock}
{weatherBlock}
INSTRUCTIONS:
{instruction1}

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

View File

@ -56,19 +56,6 @@ interface HourlyEnergyRecord {
// ── Date Helpers ─────────────────────────────────────────────
/**
* Returns the Monday of the current week.
*/
function getCurrentMonday(): Date {
const today = new Date();
today.setHours(0, 0, 0, 0);
const dow = today.getDay(); // 0=Sun
const offset = dow === 0 ? 6 : dow - 1; // Mon=0 offset
const monday = new Date(today);
monday.setDate(today.getDate() - offset);
return monday;
}
function formatDateISO(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
@ -77,19 +64,20 @@ function formatDateISO(d: Date): string {
}
/**
* Returns current week Monyesterday. Today excluded because
* S3 aggregated file is not available until end of day.
* Returns the last 7 days ending yesterday.
* Today is excluded because S3 aggregated file is not available until ~01:00 UTC the next day.
*/
function getCurrentWeekDays(currentMonday: Date): Date[] {
function getLast7Days(): Date[] {
const yesterday = new Date();
yesterday.setHours(0, 0, 0, 0);
yesterday.setDate(yesterday.getDate() - 1);
const days: Date[] = [];
for (let d = new Date(currentMonday); d <= yesterday; d.setDate(d.getDate() + 1)) {
days.push(new Date(d));
for (let i = 6; i >= 0; i--) {
const d = new Date(yesterday);
d.setDate(yesterday.getDate() - i);
days.push(d);
}
return days;
}
@ -105,7 +93,6 @@ export default function DailySection({
onPeriodChange?: (date: string) => void;
}) {
const intl = useIntl();
const currentMonday = useMemo(() => getCurrentMonday(), []);
const yesterday = useMemo(() => {
const d = new Date();
d.setHours(0, 0, 0, 0);
@ -125,11 +112,8 @@ export default function DailySection({
const [loadingWeek, setLoadingWeek] = useState(false);
const [noData, setNoData] = useState(false);
// Current week Mon→yesterday only
const weekDays = useMemo(
() => getCurrentWeekDays(currentMonday),
[currentMonday]
);
// Rolling 7-day window ending yesterday
const weekDays = useMemo(() => getLast7Days(), []);
// Fetch data for current week days
useEffect(() => {
@ -193,7 +177,7 @@ export default function DailySection({
return (
<>
{/* Day Strip — current week Mon→yesterday */}
{/* Day Strip — last 7 days ending yesterday */}
<DayStrip
weekDays={weekDays}
selectedDate={selectedDate}
@ -344,7 +328,7 @@ function DayStrip({
<Typography variant="caption" sx={{ color: '#888' }}>
<FormattedMessage
id="currentWeekHint"
defaultMessage="Current week (Monyesterday)"
defaultMessage="Last 7 days"
/>
</Typography>
</Box>
@ -388,13 +372,14 @@ function IntradayChart({
const hourMap = new Map(hourlyData.map((h) => [h.hour, h]));
const pvData = HOUR_LABELS.map((_, i) => hourMap.get(i)?.pvKwh ?? null);
const loadData = HOUR_LABELS.map((_, i) => hourMap.get(i)?.loadKwh ?? null);
const getHour = (i: number) => hourMap.get(i === 24 ? 23 : i);
const pvData = HOUR_LABELS.map((_, i) => getHour(i)?.pvKwh ?? null);
const loadData = HOUR_LABELS.map((_, i) => getHour(i)?.loadKwh ?? null);
const batteryData = HOUR_LABELS.map((_, i) => {
const h = hourMap.get(i);
const h = getHour(i);
return h ? h.batteryDischargedKwh - h.batteryChargedKwh : null;
});
const socData = HOUR_LABELS.map((_, i) => hourMap.get(i)?.battSoC ?? null);
const socData = HOUR_LABELS.map((_, i) => getHour(i)?.battSoC ?? null);
const chartData = {
labels: HOUR_LABELS,

View File

@ -26,6 +26,7 @@ import SendIcon from '@mui/icons-material/Send';
import DownloadIcon from '@mui/icons-material/Download';
import SaveIcon from '@mui/icons-material/Save';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import RefreshIcon from '@mui/icons-material/Refresh';
import axiosConfig from 'src/Resources/axiosConfig';
import DailySection from './DailySection';
@ -104,6 +105,9 @@ interface MonthlyReport extends ReportSummary {
avgPeakSolarHour: number;
avgWeekdayDailyLoad: number;
avgWeekendDailyLoad: number;
isPreview?: boolean;
daysAvailable?: number;
daysInMonth?: number;
}
interface YearlyReport extends ReportSummary {
@ -113,17 +117,7 @@ interface YearlyReport extends ReportSummary {
avgPeakSolarHour: number;
avgWeekdayDailyLoad: number;
avgWeekendDailyLoad: number;
}
interface PendingMonth {
year: number;
month: number;
weekCount: number;
}
interface PendingYear {
year: number;
monthCount: number;
isPreview?: boolean;
}
interface WeeklyReportSummaryRecord {
@ -151,6 +145,49 @@ interface WeeklyReportSummaryRecord {
createdAt: string;
}
function ReportHtmlFrame({ html }: { html: string }) {
const iframeRef = useRef<HTMLIFrameElement>(null);
const [height, setHeight] = useState(600);
useEffect(() => {
const iframe = iframeRef.current;
if (!iframe) return;
const updateHeight = () => {
try {
const doc = iframe.contentDocument || iframe.contentWindow?.document;
if (doc?.body) {
const newHeight = doc.body.scrollHeight + 20;
if (newHeight > 50) setHeight(newHeight);
}
} catch { /* cross-origin safety */ }
};
iframe.addEventListener('load', updateHeight);
const timers = [300, 800, 1500].map(ms => setTimeout(updateHeight, ms));
return () => {
iframe.removeEventListener('load', updateHeight);
timers.forEach(clearTimeout);
};
}, [html]);
return (
<iframe
ref={iframeRef}
srcDoc={html}
style={{
width: '100%',
height,
border: 'none',
overflow: 'hidden',
}}
sandbox="allow-same-origin"
title="report"
/>
);
}
// Matches: time ranges (14:0018:00), times (09:00), decimals (126.4 / 1,3), integers (34)
const BOLD_PATTERN = /(\d{1,2}:\d{2}(?:[\-]\d{1,2}:\d{2})?|\d+[.,]\d+|\d+)/g;
const isBold = (s: string) => /\d/.test(s);
@ -234,15 +271,15 @@ function WeeklyReport({ installationId, installationName, installationEmail }: W
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 [monthlyPreview, setMonthlyPreview] = useState<MonthlyReport | null>(null);
const [yearlyPreview, setYearlyPreview] = useState<YearlyReport | null>(null);
const [selectedMonthlyIdx, setSelectedMonthlyIdx] = useState(0);
const [selectedYearlyIdx, setSelectedYearlyIdx] = useState(0);
const [dailyHasData, setDailyHasData] = useState(false);
const [weeklyHasData, setWeeklyHasData] = useState(false);
const [downloadingPdf, setDownloadingPdf] = useState(false);
const [reportPeriod, setReportPeriod] = useState<{ start: string; end: string; year?: number; month?: number } | null>(null);
const [refreshing, setRefreshing] = useState(false);
const [reportPeriods, setReportPeriods] = useState<Record<string, { start: string; end: string; year?: number; month?: number }>>({});
const weeklyRef = useRef<WeeklySectionHandle>(null);
// Auto-send email preferences
@ -290,44 +327,16 @@ function WeeklyReport({ installationId, installationName, installationEmail }: W
axiosConfig.get('/GetYearlyReports', { params: { installationId, language: lang } })
.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(() => {});
axiosConfig.get('/GetCurrentMonthPreview', { params: { installationId, language: lang } })
.then(res => setMonthlyPreview(res.data))
.catch(() => setMonthlyPreview(null));
axiosConfig.get('/GetCurrentYearPreview', { params: { installationId, language: lang } })
.then(res => setYearlyPreview(res.data))
.catch(() => setYearlyPreview(null));
};
useEffect(() => { fetchReportData(); }, [installationId, intl.locale]);
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: 'dailyTab', defaultMessage: 'Daily' }), key: 'daily' },
{ label: intl.formatMessage({ id: 'weeklyTab', defaultMessage: 'Weekly' }), key: 'weekly' },
@ -341,8 +350,8 @@ function WeeklyReport({ installationId, installationName, installationEmail }: W
const key = tabs[safeTab]?.key;
if (key === 'daily') return dailyHasData;
if (key === 'weekly') return weeklyHasData;
if (key === 'monthly') return monthlyReports.length > 0;
if (key === 'yearly') return yearlyReports.length > 0;
if (key === 'monthly') return monthlyReports.length > 0 || monthlyPreview !== null;
if (key === 'yearly') return yearlyReports.length > 0 || yearlyPreview !== null;
return false;
})();
@ -354,19 +363,19 @@ function WeeklyReport({ installationId, installationName, installationEmail }: W
switch (reportType) {
case 'daily':
endpoint = '/GetDailyReportHtml';
if (reportPeriod?.start) params.date = reportPeriod.start;
if (reportPeriods.daily?.start) params.date = reportPeriods.daily.start;
break;
case 'weekly':
endpoint = '/GetWeeklyReportHtml';
break;
case 'monthly':
endpoint = '/GetMonthlyReportHtml';
if (reportPeriod?.year) params.year = reportPeriod.year;
if (reportPeriod?.month) params.month = reportPeriod.month;
if (reportPeriods.monthly?.year) params.year = reportPeriods.monthly.year;
if (reportPeriods.monthly?.month) params.month = reportPeriods.monthly.month;
break;
case 'yearly':
endpoint = '/GetYearlyReportHtml';
if (reportPeriod?.year) params.year = reportPeriod.year;
if (reportPeriods.yearly?.year) params.year = reportPeriods.yearly.year;
break;
}
@ -378,17 +387,26 @@ function WeeklyReport({ installationId, installationName, installationEmail }: W
const printWindow = window.open('', '_blank');
if (!printWindow) return;
const dateRange = reportPeriod
? `${reportPeriod.start.replace(/-/g, '')}-${reportPeriod.end.replace(/-/g, '')}`
const activePeriod = reportPeriods[reportType] ?? null;
const dateRange = activePeriod
? `${activePeriod.start.replace(/-/g, '')}-${activePeriod.end.replace(/-/g, '')}`
: new Date().toISOString().split('T')[0].replace(/-/g, '');
const safeName = (installationName || String(installationId))
.replace(/\s+/g, '-')
.replace(/[^a-zA-Z0-9\-_]/g, '');
printWindow.document.write(res.data);
const pdfTitle = `${safeName}-${reportType}-${dateRange}`;
let html = res.data as string;
if (html.includes('<head>')) {
html = html.replace('<head>', `<head><title>${pdfTitle}</title>`);
} else if (html.includes('<html>')) {
html = html.replace('<html>', `<html><head><title>${pdfTitle}</title></head>`);
}
printWindow.document.write(html);
printWindow.document.close();
printWindow.document.title = `${safeName}-${reportType}-${dateRange}`;
printWindow.document.title = pdfTitle;
printWindow.onafterprint = () => printWindow.close();
const imgs = printWindow.document.querySelectorAll('img');
const allLoaded = Array.from(imgs).map(img =>
@ -402,6 +420,36 @@ function WeeklyReport({ installationId, installationName, installationEmail }: W
}
};
const handleRefreshReport = async () => {
const reportType = tabs[safeTab]?.key ?? '';
setRefreshing(true);
try {
if (reportType === 'weekly') {
await weeklyRef.current?.regenerate();
} else if (reportType === 'monthly') {
const period = reportPeriods.monthly;
if (period?.year && period?.month) {
await axiosConfig.post('/TriggerMonthlyAggregation', null, {
params: { installationId, year: period.year, month: period.month }
});
fetchReportData();
}
} else if (reportType === 'yearly') {
const period = reportPeriods.yearly;
if (period?.year) {
await axiosConfig.post('/TriggerYearlyAggregation', null, {
params: { installationId, year: period.year }
});
fetchReportData();
}
}
} catch (err) {
console.error('Report refresh failed', err);
} finally {
setRefreshing(false);
}
};
return (
<Box sx={{ p: 2, width: '100%', maxWidth: 900, mx: 'auto' }} className="report-container">
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }} className="no-print">
@ -412,12 +460,23 @@ function WeeklyReport({ installationId, installationName, installationEmail }: W
>
{tabs.map(t => <Tab key={t.key} label={t.label} />)}
</Tabs>
{tabs[safeTab]?.key !== 'daily' && (
<Button
variant="outlined"
startIcon={refreshing ? <CircularProgress size={16} /> : <RefreshIcon />}
onClick={handleRefreshReport}
disabled={refreshing || downloadingPdf}
sx={{ ml: 2, whiteSpace: 'nowrap' }}
>
<FormattedMessage id={refreshing ? 'refreshing' : 'refreshReport'} defaultMessage={refreshing ? 'Refreshing...' : 'Refresh Report'} />
</Button>
)}
{activeTabHasData && (
<Button
variant="outlined"
startIcon={downloadingPdf ? <CircularProgress size={16} /> : <DownloadIcon />}
onClick={handleDownloadPdf}
disabled={downloadingPdf}
disabled={downloadingPdf || refreshing}
sx={{ ml: 2, whiteSpace: 'nowrap' }}
>
<FormattedMessage id="downloadPdf" defaultMessage="Download PDF" />
@ -478,7 +537,7 @@ function WeeklyReport({ installationId, installationName, installationEmail }: W
/>
<Box sx={{ display: tabs[safeTab]?.key === 'daily' ? 'block' : 'none', minHeight: '50vh' }}>
<DailySection installationId={installationId} onHasData={setDailyHasData} onPeriodChange={(date: string) => setReportPeriod({ start: date, end: date })} />
<DailySection installationId={installationId} onHasData={setDailyHasData} onPeriodChange={(date: string) => setReportPeriods(prev => ({ ...prev, daily: { start: date, end: date } }))} />
</Box>
<Box sx={{ display: tabs[safeTab]?.key === 'weekly' ? 'block' : 'none', minHeight: '50vh' }}>
<WeeklySection
@ -490,31 +549,27 @@ function WeeklyReport({ installationId, installationName, installationEmail }: W
: null
}
onHasData={setWeeklyHasData}
onPeriodChange={(start, end) => setReportPeriod({ start, end })}
onPeriodChange={(start, end) => setReportPeriods(prev => ({ ...prev, weekly: { start, end } }))}
/>
</Box>
<Box sx={{ display: tabs[safeTab]?.key === 'monthly' ? 'block' : 'none', minHeight: '50vh' }}>
<MonthlySection
installationId={installationId}
reports={monthlyReports}
pendingMonths={pendingMonths}
generating={generating}
onGenerate={handleGenerateMonthly}
preview={monthlyPreview}
selectedIdx={selectedMonthlyIdx}
onSelectedIdxChange={setSelectedMonthlyIdx}
onPeriodChange={(r: MonthlyReport) => setReportPeriod({ start: r.periodStart, end: r.periodEnd, year: r.year, month: r.month })}
onPeriodChange={(r: MonthlyReport) => setReportPeriods(prev => ({ ...prev, monthly: { start: r.periodStart, end: r.periodEnd, year: r.year, month: r.month } }))}
/>
</Box>
<Box sx={{ display: tabs[safeTab]?.key === 'yearly' ? 'block' : 'none', minHeight: '50vh' }}>
<YearlySection
installationId={installationId}
reports={yearlyReports}
pendingYears={pendingYears}
generating={generating}
onGenerate={handleGenerateYearly}
preview={yearlyPreview}
selectedIdx={selectedYearlyIdx}
onSelectedIdxChange={setSelectedYearlyIdx}
onPeriodChange={(r: YearlyReport) => setReportPeriod({ start: r.periodStart, end: r.periodEnd, year: r.year })}
onPeriodChange={(r: YearlyReport) => setReportPeriods(prev => ({ ...prev, yearly: { start: r.periodStart, end: r.periodEnd, year: r.year } }))}
/>
</Box>
</Box>
@ -524,7 +579,7 @@ function WeeklyReport({ installationId, installationName, installationEmail }: W
// ── Weekly Section (existing weekly report content) ────────────
interface WeeklySectionHandle {
regenerate: () => void;
regenerate: () => Promise<void>;
}
const WeeklySection = forwardRef<WeeklySectionHandle, { installationId: number; latestMonthlyPeriodEnd: string | null; onHasData?: (hasData: boolean) => void; onPeriodChange?: (start: string, end: string) => void }>(
@ -533,6 +588,7 @@ const WeeklySection = forwardRef<WeeklySectionHandle, { installationId: number;
const [report, setReport] = useState<WeeklyReportResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [reportHtml, setReportHtml] = useState<string | null>(null);
useEffect(() => {
fetchReport();
@ -542,12 +598,35 @@ const WeeklySection = forwardRef<WeeklySectionHandle, { installationId: number;
setLoading(true);
setError(null);
try {
const res = await axiosConfig.get('/GetWeeklyReport', {
params: { installationId, language: intl.locale, forceRegenerate }
});
setReport(res.data);
onHasData?.(true);
onPeriodChange?.(res.data.periodStart, res.data.periodEnd);
// When regenerating, fetch JSON first (populates cache), then HTML (reads fresh cache)
// When not regenerating, fetch both in parallel (both read from cache)
if (forceRegenerate) {
const jsonRes = await axiosConfig.get('/GetWeeklyReport', {
params: { installationId, language: intl.locale, forceRegenerate }
});
const htmlRes = await axiosConfig.get('/GetWeeklyReportHtml', {
params: { installationId, language: intl.locale, source: 'web' },
responseType: 'text'
});
setReport(jsonRes.data);
setReportHtml(htmlRes.data);
onHasData?.(true);
onPeriodChange?.(jsonRes.data.periodStart, jsonRes.data.periodEnd);
} else {
const [jsonRes, htmlRes] = await Promise.all([
axiosConfig.get('/GetWeeklyReport', {
params: { installationId, language: intl.locale }
}),
axiosConfig.get('/GetWeeklyReportHtml', {
params: { installationId, language: intl.locale, source: 'web' },
responseType: 'text'
})
]);
setReport(jsonRes.data);
setReportHtml(htmlRes.data);
onHasData?.(true);
onPeriodChange?.(jsonRes.data.periodStart, jsonRes.data.periodEnd);
}
} catch (err: any) {
const msg =
err.response?.data ||
@ -599,206 +678,15 @@ const WeeklySection = forwardRef<WeeklySectionHandle, { installationId: number;
if (!report) return null;
const cur = report.currentWeek;
const prev = report.previousWeek;
const currentWeekDayCount = Math.min(7, report.dailyData.length);
const previousWeekDayCount = 7;
const formatChange = (pct: number) =>
pct === 0 ? '—' : pct > 0 ? `+${pct.toFixed(1)}%` : `${pct.toFixed(1)}%`;
const changeColor = (pct: number, invert = false) => {
const effective = invert ? -pct : pct;
return effective > 0 ? '#27ae60' : effective < 0 ? '#e74c3c' : '#888';
};
// Compute change % from two raw values; returns 0 (shown as —) if prev is 0
const calcChange = (curVal: number, prevVal: number) =>
prevVal === 0 ? 0 : ((curVal - prevVal) / prevVal) * 100;
const insightBullets = report.aiInsight
.split(/\n+/)
.map((line) => line.replace(/\*\*/g, '').replace(/^[\d]+[.)]\s*/, '').replace(/^[-*]\s*/, '').trim())
.filter((line) => line.length > 0);
const totalEnergySavedKwh = report.totalEnergySaved;
const totalSavingsCHF = report.totalSavingsCHF;
const maxDailyValue = Math.max(
...report.dailyData.map((d) => Math.max(d.pvProduction, d.loadConsumption, d.gridImport)),
1
);
return (
<>
{/* Email bar */}
<EmailBar onSend={handleSendEmail} />
{/* Report Header */}
<Paper
sx={{
bgcolor: '#2c3e50',
color: '#fff',
p: 3,
mb: 3,
borderRadius: 2
}}
>
<Typography variant="h5" fontWeight="bold">
<FormattedMessage id="reportTitle" defaultMessage="Weekly Performance Report" />
</Typography>
<Typography variant="body1" sx={{ opacity: 0.9, mt: 0.5 }}>
{report.installationName}
</Typography>
<Typography variant="body2" sx={{ opacity: 0.7 }}>
{report.periodStart} {report.periodEnd}
</Typography>
</Paper>
{/* Report content — rendered from backend HTML (single source of truth) */}
{reportHtml && <ReportHtmlFrame html={reportHtml} />}
{/* Missing days warning */}
{report.missingDates && report.missingDates.length > 0 && (
<Alert severity="warning" sx={{ mb: 3 }}>
<FormattedMessage
id="missingDaysWarning"
defaultMessage="Data available for {available}/{expected} days. Missing: {dates}"
values={{
available: report.daysAvailable,
expected: report.daysExpected,
dates: report.missingDates.join(', ')
}}
/>
</Alert>
)}
{/* Weekly Insights */}
<Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="h6" fontWeight="bold" sx={{ mb: 2, color: '#2c3e50' }}>
<FormattedMessage id="weeklyInsights" defaultMessage="Weekly Insights" />
</Typography>
<InsightBox text={report.aiInsight} bullets={insightBullets} />
</Paper>
{/* Your Savings This Week */}
<Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="h6" fontWeight="bold" sx={{ mb: 2, color: '#2c3e50' }}>
<FormattedMessage id="weeklySavings" defaultMessage="Your Savings This Week" />
</Typography>
<SavingsCards
intl={intl}
energySaved={totalEnergySavedKwh}
savingsCHF={totalSavingsCHF}
selfSufficiency={report.selfSufficiencyPercent}
batteryEfficiency={report.batteryEfficiencyPercent}
hint={report.daysEquivalent > 0 ? `${report.daysEquivalent} ${intl.formatMessage({ id: 'daysOfYourUsage' })}` : undefined}
/>
</Paper>
{/* Weekly Summary Table */}
<Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="h6" fontWeight="bold" sx={{ mb: 2, color: '#2c3e50' }}>
<FormattedMessage id="weeklySummary" defaultMessage="Weekly 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="thisWeek" defaultMessage="This Week" /></th>
{prev && <th style={{ textAlign: 'right' }}><FormattedMessage id="lastWeek" defaultMessage="Last Week" /></th>}
{prev && <th style={{ textAlign: 'right' }}><FormattedMessage id="change" defaultMessage="Change" /></th>}
</tr>
</thead>
<tbody>
<tr>
<td><FormattedMessage id="pvProduction" defaultMessage="PV Production" /></td>
<td style={{ textAlign: 'right', fontWeight: 'bold' }}>{cur.totalPvProduction.toFixed(1)} kWh</td>
{prev && <td style={{ textAlign: 'right', color: '#888' }}>{prev.totalPvProduction.toFixed(1)} kWh</td>}
{prev && <td style={{ textAlign: 'right', color: changeColor(report.pvChangePercent) }}>{formatChange(report.pvChangePercent)}</td>}
</tr>
<tr>
<td><FormattedMessage id="consumption" defaultMessage="Consumption" /></td>
<td style={{ textAlign: 'right', fontWeight: 'bold' }}>{cur.totalConsumption.toFixed(1)} kWh</td>
{prev && <td style={{ textAlign: 'right', color: '#888' }}>{prev.totalConsumption.toFixed(1)} kWh</td>}
{prev && <td style={{ textAlign: 'right', color: changeColor(report.consumptionChangePercent, true) }}>{formatChange(report.consumptionChangePercent)}</td>}
</tr>
<tr style={{ background: '#fafafa' }}>
<td style={{ color: '#888', paddingLeft: '20px', fontSize: '13px' }}>
<FormattedMessage id="avgDailyConsumption" defaultMessage="Avg Daily Consumption" />
</td>
<td style={{ textAlign: 'right', color: '#888', fontSize: '13px' }}>
{(cur.totalConsumption / currentWeekDayCount).toFixed(1)} kWh
</td>
{prev && <td style={{ textAlign: 'right', color: '#bbb', fontSize: '13px' }}>
{(prev.totalConsumption / previousWeekDayCount).toFixed(1)} kWh
</td>}
{prev && <td />}
</tr>
<tr>
<td><FormattedMessage id="gridImport" defaultMessage="Grid Import" /></td>
<td style={{ textAlign: 'right', fontWeight: 'bold' }}>{cur.totalGridImport.toFixed(1)} kWh</td>
{prev && <td style={{ textAlign: 'right', color: '#888' }}>{prev.totalGridImport.toFixed(1)} kWh</td>}
{prev && <td style={{ textAlign: 'right', color: changeColor(report.gridImportChangePercent, true) }}>{formatChange(report.gridImportChangePercent)}</td>}
</tr>
<tr>
<td><FormattedMessage id="gridExport" defaultMessage="Grid Export" /></td>
<td style={{ textAlign: 'right', fontWeight: 'bold' }}>{cur.totalGridExport.toFixed(1)} kWh</td>
{prev && <td style={{ textAlign: 'right', color: '#888' }}>{prev.totalGridExport.toFixed(1)} kWh</td>}
{prev && <td style={{ textAlign: 'right', color: '#888' }}>{formatChange(calcChange(cur.totalGridExport, prev.totalGridExport))}</td>}
</tr>
<tr>
<td><FormattedMessage id="batteryInOut" defaultMessage="Battery In / Out" /></td>
<td style={{ textAlign: 'right', fontWeight: 'bold' }}>{cur.totalBatteryCharged.toFixed(1)} / {cur.totalBatteryDischarged.toFixed(1)} kWh</td>
{prev && <td style={{ textAlign: 'right', color: '#888' }}>{prev.totalBatteryCharged.toFixed(1)} / {prev.totalBatteryDischarged.toFixed(1)} kWh</td>}
{prev && <td style={{ textAlign: 'right', color: changeColor(calcChange(cur.totalBatteryCharged, prev.totalBatteryCharged)) }}>{formatChange(calcChange(cur.totalBatteryCharged, prev.totalBatteryCharged))}</td>}
</tr>
</tbody>
</Box>
</Paper>
{/* Daily Breakdown */}
{report.dailyData.length > 0 && (
<Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="h6" fontWeight="bold" sx={{ mb: 1, color: '#2c3e50' }}>
<FormattedMessage id="dailyBreakdown" defaultMessage="Daily Breakdown" />
</Typography>
<Box sx={{ display: 'flex', gap: 3, mb: 2, fontSize: '12px', color: '#666' }}>
<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>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Box sx={{ width: 12, height: 12, bgcolor: '#3498db', borderRadius: '2px' }} /> <FormattedMessage id="consumption" defaultMessage="Consumption" />
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Box sx={{ width: 12, height: 12, bgcolor: '#e74c3c', borderRadius: '2px' }} /> <FormattedMessage id="gridImport" defaultMessage="Grid Import" />
</Box>
</Box>
{report.dailyData.map((d, i) => {
const dt = new Date(d.date);
const dayLabel = dt.toLocaleDateString(intl.locale, { weekday: 'short', month: 'short', day: 'numeric' });
const isCurrentWeek = report.dailyData.length > 7 ? i >= report.dailyData.length - 7 : true;
return (
<Box key={d.date} sx={{ mb: 1.5, opacity: isCurrentWeek ? 1 : 0.6 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.3 }}>
<Typography variant="caption" sx={{ fontWeight: isCurrentWeek ? 'bold' : 'normal', color: '#444', fontSize: '12px' }}>
{dayLabel}
{!isCurrentWeek && <span style={{ color: '#999', marginLeft: 4 }}><FormattedMessage id="prevWeek" defaultMessage="(prev week)" /></span>}
</Typography>
<Typography variant="caption" sx={{ color: '#888', fontSize: '11px' }}>
{intl.formatMessage({ id: 'pvProduction' })} {d.pvProduction.toFixed(1)} | {intl.formatMessage({ id: 'consumption' })} {d.loadConsumption.toFixed(1)} | {intl.formatMessage({ id: 'gridImport' })} {d.gridImport.toFixed(1)} kWh
</Typography>
</Box>
<Box sx={{ display: 'flex', gap: '2px', height: 18 }}>
<Box sx={{ width: `${(d.pvProduction / maxDailyValue) * 100}%`, 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>
);
})}
</Paper>
)}
{/* Weekly History — weeks not yet captured in a monthly report, excluding the current week shown above */}
{/* Weekly History — weeks not yet captured in a monthly report */}
<WeeklyHistory
installationId={installationId}
latestMonthlyPeriodEnd={latestMonthlyPeriodEnd}
@ -928,57 +816,85 @@ function WeeklyHistory({ installationId, latestMonthlyPeriodEnd, currentReportPe
function MonthlySection({
installationId,
reports,
pendingMonths,
generating,
onGenerate,
preview,
selectedIdx,
onSelectedIdxChange,
onPeriodChange
}: {
installationId: number;
reports: MonthlyReport[];
pendingMonths: PendingMonth[];
generating: string | null;
onGenerate: (year: number, month: number) => void;
preview: MonthlyReport | null;
selectedIdx: number;
onSelectedIdxChange: (idx: number) => void;
onPeriodChange?: (report: MonthlyReport) => void;
}) {
const intl = useIntl();
// Check if current month already has a finalized report
const now = new Date();
const currentYear = now.getFullYear();
const currentMonth = now.getMonth() + 1;
const hasFinalizedCurrentMonth = reports.some(r => r.year === currentYear && r.month === currentMonth);
// Show preview only if no finalized report for current month exists
const showPreview = preview && !hasFinalizedCurrentMonth;
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: getMonthName(p.month, intl.locale), year: p.year, count: p.weekCount }
)
}
</Button>
);
})}
{/* Current month preview */}
{showPreview && (
<Paper sx={{ mb: 3, border: '1px solid #f0ad4e', borderRadius: 2, overflow: 'hidden' }}>
<Box sx={{ bgcolor: '#2c3e50', color: '#fff', p: 3, borderRadius: '8px 8px 0 0' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Typography variant="h5" fontWeight="bold">
<FormattedMessage id="monthlyReportTitle" defaultMessage="Monthly Performance Report" />
</Typography>
<Typography variant="caption" sx={{ bgcolor: '#f0ad4e', color: '#000', px: 1, py: 0.3, borderRadius: 1, fontWeight: 'bold' }}>
<FormattedMessage id="reportInProgress" defaultMessage="{month} (in progress)" values={{ month: '' }} />
</Typography>
</Box>
<Typography variant="body2" sx={{ opacity: 0.7 }}>
<FormattedMessage
id="reportInProgress"
defaultMessage="{month} (in progress)"
values={{ month: `${getMonthName(preview.month, intl.locale)} ${preview.year}` }}
/>
</Typography>
{(preview.daysAvailable ?? 0) < (preview.daysInMonth ?? 0) && (
<Typography variant="caption" sx={{ opacity: 0.6 }}>
<FormattedMessage
id="daysOfTotal"
defaultMessage="{available} of {total} days"
values={{ available: preview.daysAvailable ?? 0, total: preview.daysInMonth ?? 0 }}
/>
</Typography>
)}
</Box>
{/* Preview content — reuse InsightBox + SavingsCards */}
<Box sx={{ p: 3 }}>
<Typography variant="h6" fontWeight="bold" sx={{ mb: 2, color: '#2c3e50' }}>
<FormattedMessage id="monthlyInsights" defaultMessage="Monthly Insights" />
</Typography>
<InsightBox
text={preview.aiInsight || ''}
bullets={(preview.aiInsight || '').split(/\n+/).map(l => l.replace(/\*\*/g, '').replace(/^[\d]+[.)]\s*/, '').replace(/^[-*]\s*/, '').trim()).filter(l => l.length > 0)}
/>
</Box>
<Box sx={{ px: 3, pb: 2 }}>
<SavingsCards
intl={intl}
energySaved={preview.totalEnergySaved}
savingsCHF={preview.totalSavingsCHF}
selfSufficiency={preview.selfSufficiencyPercent}
batteryEfficiency={preview.batteryEfficiencyPercent}
/>
</Box>
<Alert severity="info" sx={{ mx: 3, mb: 3 }}>
<FormattedMessage id="monthlyAutoNote" defaultMessage="Final report will be automatically generated on the 1st of next month." />
</Alert>
</Paper>
)}
@ -992,11 +908,14 @@ function MonthlySection({
countFn={(r: MonthlyReport) => r.weekCount}
sendEndpoint="/SendMonthlyReportEmail"
sendParamsFn={(r: MonthlyReport) => ({ installationId, year: r.year, month: r.month })}
htmlEndpoint="/GetMonthlyReportHtml"
htmlParamsFn={(r: MonthlyReport) => ({ year: r.year, month: r.month })}
installationId={installationId}
controlledIdx={selectedIdx}
onIdxChange={onSelectedIdxChange}
onPeriodChange={onPeriodChange}
/>
) : pendingMonths.length === 0 ? (
) : !showPreview ? (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 6 }}>
<Alert severity="warning">
<FormattedMessage id="noReportData" defaultMessage="No report data found." />
@ -1012,57 +931,79 @@ function MonthlySection({
function YearlySection({
installationId,
reports,
pendingYears,
generating,
onGenerate,
preview,
selectedIdx,
onSelectedIdxChange,
onPeriodChange
}: {
installationId: number;
reports: YearlyReport[];
pendingYears: PendingYear[];
generating: string | null;
onGenerate: (year: number) => void;
preview: YearlyReport | null;
selectedIdx: number;
onSelectedIdxChange: (idx: number) => void;
onPeriodChange?: (report: YearlyReport) => void;
}) {
const intl = useIntl();
const currentYear = new Date().getFullYear();
const hasFinalizedCurrentYear = reports.some(r => r.year === currentYear);
const showPreview = preview && !hasFinalizedCurrentYear;
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>
);
})}
{/* Current year preview */}
{showPreview && (
<Paper sx={{ mb: 3, border: '1px solid #f0ad4e', borderRadius: 2, overflow: 'hidden' }}>
<Box sx={{ bgcolor: '#2c3e50', color: '#fff', p: 3, borderRadius: '8px 8px 0 0' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Typography variant="h5" fontWeight="bold">
<FormattedMessage id="yearlyReportTitle" defaultMessage="Annual Performance Report" />
</Typography>
<Typography variant="caption" sx={{ bgcolor: '#f0ad4e', color: '#000', px: 1, py: 0.3, borderRadius: 1, fontWeight: 'bold' }}>
<FormattedMessage id="reportInProgress" defaultMessage="{month} (in progress)" values={{ month: '' }} />
</Typography>
</Box>
<Typography variant="body2" sx={{ opacity: 0.7 }}>
<FormattedMessage
id="reportInProgress"
defaultMessage="{month} (in progress)"
values={{ month: `${preview.year}` }}
/>
</Typography>
{preview.monthCount < 12 && (
<Typography variant="caption" sx={{ opacity: 0.6 }}>
<FormattedMessage
id="monthsOfTotal"
defaultMessage="{available} of {total} months"
values={{ available: preview.monthCount, total: 12 }}
/>
</Typography>
)}
</Box>
<Box sx={{ p: 3 }}>
<Typography variant="h6" fontWeight="bold" sx={{ mb: 2, color: '#2c3e50' }}>
<FormattedMessage id="yearlyInsights" defaultMessage="Annual Insights" />
</Typography>
<InsightBox
text={preview.aiInsight || ''}
bullets={(preview.aiInsight || '').split(/\n+/).map(l => l.replace(/\*\*/g, '').replace(/^[\d]+[.)]\s*/, '').replace(/^[-*]\s*/, '').trim()).filter(l => l.length > 0)}
/>
</Box>
<Box sx={{ px: 3, pb: 2 }}>
<SavingsCards
intl={intl}
energySaved={preview.totalEnergySaved}
savingsCHF={preview.totalSavingsCHF}
selfSufficiency={preview.selfSufficiencyPercent}
batteryEfficiency={preview.batteryEfficiencyPercent}
/>
</Box>
<Alert severity="info" sx={{ mx: 3, mb: 3 }}>
<FormattedMessage id="yearlyAutoNote" defaultMessage="Final report will be automatically generated on January 2nd." />
</Alert>
</Paper>
)}
@ -1076,11 +1017,14 @@ function YearlySection({
countFn={(r: YearlyReport) => r.monthCount}
sendEndpoint="/SendYearlyReportEmail"
sendParamsFn={(r: YearlyReport) => ({ installationId, year: r.year })}
htmlEndpoint="/GetYearlyReportHtml"
htmlParamsFn={(r: YearlyReport) => ({ year: r.year })}
installationId={installationId}
controlledIdx={selectedIdx}
onIdxChange={onSelectedIdxChange}
onPeriodChange={onPeriodChange}
/>
) : pendingYears.length === 0 ? (
) : !showPreview ? (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 6 }}>
<Alert severity="warning">
<FormattedMessage id="noReportData" defaultMessage="No report data found." />
@ -1101,6 +1045,9 @@ function AggregatedSection<T extends ReportSummary>({
countFn,
sendEndpoint,
sendParamsFn,
htmlEndpoint,
htmlParamsFn,
installationId,
controlledIdx,
onIdxChange,
onPeriodChange
@ -1112,6 +1059,9 @@ function AggregatedSection<T extends ReportSummary>({
countFn: (r: T) => number;
sendEndpoint: string;
sendParamsFn: (r: T) => object;
htmlEndpoint: string;
htmlParamsFn: (r: T) => Record<string, any>;
installationId: number;
controlledIdx?: number;
onIdxChange?: (idx: number) => void;
onPeriodChange?: (report: T) => void;
@ -1132,6 +1082,24 @@ function AggregatedSection<T extends ReportSummary>({
}
}, [reports.length]);
const [reportHtml, setReportHtml] = useState<string | null>(null);
const [htmlLoading, setHtmlLoading] = useState(false);
const r = reports.length > 0 ? reports[selectedIdx] : undefined;
useEffect(() => {
if (!r) return;
setHtmlLoading(true);
setReportHtml(null);
axiosConfig.get(htmlEndpoint, {
params: { installationId, language: intl.locale, source: 'web', ...htmlParamsFn(r) },
responseType: 'text'
})
.then(res => setReportHtml(res.data))
.catch(() => setReportHtml(null))
.finally(() => setHtmlLoading(false));
}, [r?.periodStart, r?.periodEnd, intl.locale, installationId]);
if (reports.length === 0) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 6 }}>
@ -1142,20 +1110,9 @@ function AggregatedSection<T extends ReportSummary>({
);
}
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(/\*\*/g, '').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 }
params: { ...sendParamsFn(r!), emailAddress }
});
};
@ -1180,77 +1137,14 @@ function AggregatedSection<T extends ReportSummary>({
</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>
{/* Report content — rendered from backend HTML */}
{htmlLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
<CircularProgress size={50} style={{ color: '#ffc04d' }} />
</Box>
</Paper>
) : reportHtml ? (
<ReportHtmlFrame html={reportHtml} />
) : null}
</>
);
}

View File

@ -133,7 +133,6 @@
"reportTitle": "Wöchentlicher Leistungsbericht",
"weeklyInsights": "Wöchentliche Einblicke",
"missingDaysWarning": "Daten verfügbar für {available}/{expected} Tage. Fehlend: {dates}",
"weeklySavings": "Ihre Einsparungen diese Woche",
"solarEnergyUsed": "Energie gespart",
"solarStayedHome": "Solar + Batterie, nicht vom Netz",
"daysOfYourUsage": "Tage Ihres Verbrauchs",
@ -141,10 +140,8 @@
"atCHFRate": "bei 0,39 CHF/kWh Ø",
"solarCoverage": "Energieunabhängigkeit",
"fromSolarSub": "aus eigenem Solar + Batterie System",
"avgDailyConsumption": "Ø Tagesverbrauch",
"batteryEfficiency": "Batterieeffizienz",
"batteryEffSub": "Entladung vs. Ladung",
"weeklySummary": "Wöchentliche Zusammenfassung",
"metric": "Kennzahl",
"thisWeek": "Diese Woche",
"change": "Änderung",
@ -154,11 +151,12 @@
"gridExport": "Netzeinspeisung",
"batteryInOut": "Batterie Laden / Entladen",
"dailyBreakdown": "Tägliche Aufschlüsselung",
"prevWeek": "(Vorwoche)",
"sendReport": "Bericht senden",
"generatingReport": "Wochenbericht wird erstellt...",
"reportSentTo": "Bericht gesendet an {email}",
"reportSendError": "Senden fehlgeschlagen. Bitte überprüfen Sie die E-Mail-Adresse und versuchen Sie es erneut.",
"refreshReport": "Bericht aktualisieren",
"refreshing": "Aktualisierung...",
"ok": "Ok",
"grantedAccessToUser": "Zugriff für Benutzer {name} gewährt",
"proceed": "Fortfahren",
@ -177,7 +175,7 @@
"dailySummary": "Tagesübersicht",
"noDataForDate": "Keine Daten für das gewählte Datum verfügbar.",
"noHourlyData": "Stündliche Daten für diesen Tag nicht verfügbar.",
"currentWeekHint": "Aktuelle Woche (Mogestern)",
"currentWeekHint": "Letzte 7 Tage",
"intradayChart": "Tagesverlauf Energiefluss",
"batteryPower": "Batterieleistung",
"batterySoCLabel": "Batterie SoC",
@ -191,25 +189,20 @@
"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)",
"regenerateReport": "Neu generieren",
"reportInProgress": "{month} (in Bearbeitung)",
"daysOfTotal": "{available} von {total} Tagen",
"monthsOfTotal": "{available} von {total} Monaten",
"monthlyAutoNote": "Der endgültige Bericht wird automatisch am 1. des nächsten Monats erstellt.",
"yearlyAutoNote": "Der endgültige Bericht wird automatisch am 2. Januar erstellt.",
"autoSendReports": "Berichte automatisch senden:",
"autoSendSaved": "Automatische Versandeinstellungen gespeichert.",
"autoSendSaveFailed": "Fehler beim Speichern der automatischen Versandeinstellungen.",
"autoSendNoEmail": "E-Mail-Adresse im Reiter Information eingeben, um den automatischen Versand zu aktivieren",
"generatingMonthly": "Wird generiert...",
"generatingYearly": "Wird generiert...",
"thisMonthWeeklyReports": "Wöchentliche Berichte dieses Monats",
"recentWeeklyReports": "Letzte Wochenberichte",
"ai_analyzing": "KI analysiert...",

View File

@ -115,7 +115,6 @@
"reportTitle": "Weekly Performance Report",
"weeklyInsights": "Weekly Insights",
"missingDaysWarning": "Data available for {available}/{expected} days. Missing: {dates}",
"weeklySavings": "Your Savings This Week",
"solarEnergyUsed": "Energy Saved",
"solarStayedHome": "solar + battery, not bought from grid",
"daysOfYourUsage": "days of your usage",
@ -123,10 +122,8 @@
"atCHFRate": "at 0.39 CHF/kWh avg.",
"solarCoverage": "Energy Independence",
"fromSolarSub": "from your own solar + battery system",
"avgDailyConsumption": "Avg Daily Consumption",
"batteryEfficiency": "Battery Efficiency",
"batteryEffSub": "discharge vs charge",
"weeklySummary": "Weekly Summary",
"metric": "Metric",
"thisWeek": "This Week",
"change": "Change",
@ -136,11 +133,12 @@
"gridExport": "Grid Export",
"batteryInOut": "Battery Charge / Discharge",
"dailyBreakdown": "Daily Breakdown",
"prevWeek": "(prev week)",
"sendReport": "Send Report",
"generatingReport": "Generating weekly report...",
"reportSentTo": "Report sent to {email}",
"reportSendError": "Failed to send. Please check the email address and try again.",
"refreshReport": "Refresh Report",
"refreshing": "Refreshing...",
"ok": "Ok",
"grantedAccessToUser": "Granted access to user {name}",
"proceed": "Proceed",
@ -159,7 +157,7 @@
"dailySummary": "Daily Summary",
"noDataForDate": "No data available for the selected date.",
"noHourlyData": "Hourly data not available for this day.",
"currentWeekHint": "Current week (Monyesterday)",
"currentWeekHint": "Last 7 days",
"intradayChart": "Intraday Power Flow",
"batteryPower": "Battery Power",
"batterySoCLabel": "Battery SoC",
@ -173,25 +171,20 @@
"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)",
"regenerateReport": "Regenerate",
"reportInProgress": "{month} (in progress)",
"daysOfTotal": "{available} of {total} days",
"monthsOfTotal": "{available} of {total} months",
"monthlyAutoNote": "Final report will be automatically generated on the 1st of next month.",
"yearlyAutoNote": "Final report will be automatically generated on January 2nd.",
"autoSendReports": "Auto-send reports:",
"autoSendSaved": "Auto-send preferences saved.",
"autoSendSaveFailed": "Failed to save auto-send preferences.",
"autoSendNoEmail": "Set email address in Information tab to enable auto-send",
"generatingMonthly": "Generating...",
"generatingYearly": "Generating...",
"thisMonthWeeklyReports": "This Month's Weekly Reports",
"recentWeeklyReports": "Recent Weekly Reports",
"ai_analyzing": "AI is analyzing...",

View File

@ -127,7 +127,6 @@
"reportTitle": "Rapport de performance hebdomadaire",
"weeklyInsights": "Aperçus hebdomadaires",
"missingDaysWarning": "Données disponibles pour {available}/{expected} jours. Manquants : {dates}",
"weeklySavings": "Vos économies cette semaine",
"solarEnergyUsed": "Énergie économisée",
"solarStayedHome": "solaire + batterie, non achetée au réseau",
"daysOfYourUsage": "jours de votre consommation",
@ -135,10 +134,8 @@
"atCHFRate": "à 0,39 CHF/kWh moy.",
"solarCoverage": "Indépendance énergétique",
"fromSolarSub": "de votre système solaire + batterie",
"avgDailyConsumption": "Conso. quotidienne moy.",
"batteryEfficiency": "Efficacité de la batterie",
"batteryEffSub": "décharge vs charge",
"weeklySummary": "Résumé hebdomadaire",
"metric": "Métrique",
"thisWeek": "Cette semaine",
"change": "Variation",
@ -148,11 +145,12 @@
"gridExport": "Exportation réseau",
"batteryInOut": "Batterie Charge / Décharge",
"dailyBreakdown": "Répartition quotidienne",
"prevWeek": "(semaine précédente)",
"sendReport": "Envoyer le rapport",
"generatingReport": "Génération du rapport hebdomadaire...",
"reportSentTo": "Rapport envoyé à {email}",
"reportSendError": "Échec de l'envoi. Veuillez vérifier l'adresse e-mail et réessayer.",
"refreshReport": "Actualiser le rapport",
"refreshing": "Actualisation...",
"ok": "Ok",
"grantedAccessToUser": "Accès accordé à l'utilisateur {name}",
"proceed": "Continuer",
@ -171,7 +169,7 @@
"dailySummary": "Résumé du jour",
"noDataForDate": "Aucune donnée disponible pour la date sélectionnée.",
"noHourlyData": "Données horaires non disponibles pour ce jour.",
"currentWeekHint": "Semaine en cours (lunhier)",
"currentWeekHint": "7 derniers jours",
"intradayChart": "Flux d'énergie journalier",
"batteryPower": "Puissance batterie",
"batterySoCLabel": "SoC batterie",
@ -185,25 +183,20 @@
"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)",
"regenerateReport": "Régénérer",
"reportInProgress": "{month} (en cours)",
"daysOfTotal": "{available} sur {total} jours",
"monthsOfTotal": "{available} sur {total} mois",
"monthlyAutoNote": "Le rapport final sera généré automatiquement le 1er du mois prochain.",
"yearlyAutoNote": "Le rapport final sera généré automatiquement le 2 janvier.",
"autoSendReports": "Envoi automatique des rapports :",
"autoSendSaved": "Préférences d'envoi automatique enregistrées.",
"autoSendSaveFailed": "Échec de l'enregistrement des préférences d'envoi automatique.",
"autoSendNoEmail": "Définir l'adresse e-mail dans l'onglet Information pour activer l'envoi automatique",
"generatingMonthly": "Génération en cours...",
"generatingYearly": "Génération en cours...",
"thisMonthWeeklyReports": "Rapports hebdomadaires de ce mois",
"recentWeeklyReports": "Derniers rapports hebdomadaires",
"ai_analyzing": "L'IA analyse...",

View File

@ -138,7 +138,6 @@
"reportTitle": "Rapporto settimanale sulle prestazioni",
"weeklyInsights": "Approfondimenti settimanali",
"missingDaysWarning": "Dati disponibili per {available}/{expected} giorni. Mancanti: {dates}",
"weeklySavings": "I tuoi risparmi questa settimana",
"solarEnergyUsed": "Energia risparmiata",
"solarStayedHome": "solare + batteria, non acquistata dalla rete",
"daysOfYourUsage": "giorni del tuo consumo",
@ -146,10 +145,8 @@
"atCHFRate": "a 0,39 CHF/kWh media",
"solarCoverage": "Indipendenza energetica",
"fromSolarSub": "dal proprio impianto solare + batteria",
"avgDailyConsumption": "Consumo medio giornaliero",
"batteryEfficiency": "Efficienza della batteria",
"batteryEffSub": "scarica vs carica",
"weeklySummary": "Riepilogo settimanale",
"metric": "Metrica",
"thisWeek": "Questa settimana",
"change": "Variazione",
@ -159,11 +156,12 @@
"gridExport": "Esportazione rete",
"batteryInOut": "Batteria Carica / Scarica",
"dailyBreakdown": "Ripartizione giornaliera",
"prevWeek": "(settimana precedente)",
"sendReport": "Invia rapporto",
"generatingReport": "Generazione del rapporto settimanale...",
"reportSentTo": "Rapporto inviato a {email}",
"reportSendError": "Invio fallito. Verificare l'indirizzo e-mail e riprovare.",
"refreshReport": "Aggiorna rapporto",
"refreshing": "Aggiornamento...",
"ok": "Ok",
"grantedAccessToUser": "Accesso concesso all'utente {name}",
"proceed": "Procedi",
@ -182,7 +180,7 @@
"dailySummary": "Riepilogo del giorno",
"noDataForDate": "Nessun dato disponibile per la data selezionata.",
"noHourlyData": "Dati orari non disponibili per questo giorno.",
"currentWeekHint": "Settimana corrente (lunieri)",
"currentWeekHint": "Ultimi 7 giorni",
"intradayChart": "Flusso energetico giornaliero",
"batteryPower": "Potenza batteria",
"batterySoCLabel": "SoC batteria",
@ -196,25 +194,20 @@
"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)",
"regenerateReport": "Rigenera",
"reportInProgress": "{month} (in corso)",
"daysOfTotal": "{available} di {total} giorni",
"monthsOfTotal": "{available} di {total} mesi",
"monthlyAutoNote": "Il rapporto finale verrà generato automaticamente il 1° del mese prossimo.",
"yearlyAutoNote": "Il rapporto finale verrà generato automaticamente il 2 gennaio.",
"autoSendReports": "Invio automatico rapporti:",
"autoSendSaved": "Preferenze di invio automatico salvate.",
"autoSendSaveFailed": "Impossibile salvare le preferenze di invio automatico.",
"autoSendNoEmail": "Impostare l'indirizzo e-mail nella scheda Informazioni per attivare l'invio automatico",
"generatingMonthly": "Generazione in corso...",
"generatingYearly": "Generazione in corso...",
"thisMonthWeeklyReports": "Rapporti settimanali di questo mese",
"recentWeeklyReports": "Ultimi rapporti settimanali",
"ai_analyzing": "L'IA sta analizzando...",