unified daily report data from S3 and then db and fallback to xlsx and the container size is consistent among 4 tubs

This commit is contained in:
Yinyin Liu 2026-03-12 14:39:24 +01:00
parent 69148410f2
commit 50bc85ff2a
14 changed files with 306 additions and 327 deletions

View File

@ -1309,20 +1309,14 @@ public class Controller : ControllerBase
Int64 installationId, Installation installation, Int64 installationId, Installation installation,
DateOnly fromDate, DateOnly toDate) DateOnly fromDate, DateOnly toDate)
{ {
var jsonDir = Path.Combine(
Environment.CurrentDirectory, "tmp_report", "aggregated", installationId.ToString());
for (var date = fromDate; date <= toDate; date = date.AddDays(1)) for (var date = fromDate; date <= toDate; date = date.AddDays(1))
{ {
var isoDate = date.ToString("yyyy-MM-dd"); var isoDate = date.ToString("yyyy-MM-dd");
var fileName = AggregatedJsonParser.ToJsonFileName(date);
// Try local file first if (Db.DailyRecordExists(installationId, isoDate))
var localPath = Path.Combine(jsonDir, fileName); continue;
String? content = System.IO.File.Exists(localPath) ? System.IO.File.ReadAllText(localPath) : null;
// Try S3 if no local file var content = AggregatedJsonParser.TryReadFromS3(installation, isoDate)
content ??= AggregatedJsonParser.TryReadFromS3(installation, isoDate)
.GetAwaiter().GetResult(); .GetAwaiter().GetResult();
if (content is null) continue; if (content is null) continue;

View File

@ -28,6 +28,11 @@ public class WeeklyReportResponse
public List<DailyEnergyData> DailyData { get; set; } = new(); public List<DailyEnergyData> DailyData { get; set; } = new();
public BehavioralPattern? Behavior { get; set; } public BehavioralPattern? Behavior { get; set; }
public string AiInsight { get; set; } = ""; public string AiInsight { get; set; } = "";
// Data availability — lets UI show which days are missing
public int DaysAvailable { get; set; } // how many of the 7 days have data
public int DaysExpected { get; set; } // 7 (MonSun)
public List<string> MissingDates { get; set; } = new(); // ISO dates with no data
} }
public class WeeklySummary public class WeeklySummary

View File

@ -30,8 +30,8 @@ public static class Program
TicketDiagnosticService.Initialize(); TicketDiagnosticService.Initialize();
NetworkProviderService.Initialize(); NetworkProviderService.Initialize();
AlarmReviewService.StartDailyScheduler(); AlarmReviewService.StartDailyScheduler();
// DailyIngestionService.StartScheduler(); // Phase 2: enable when S3 auto-push is ready DailyIngestionService.StartScheduler();
// ReportAggregationService.StartScheduler(); // Phase 2: enable when scheduler should auto-run monthly/yearly ReportAggregationService.StartScheduler();
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
RabbitMqManager.InitializeEnvironment(); RabbitMqManager.InitializeEnvironment();

View File

@ -5,7 +5,7 @@ namespace InnovEnergy.App.Backend.Services;
/// <summary> /// <summary>
/// Ingests daily energy totals into the DailyEnergyRecord SQLite table. /// Ingests daily energy totals into the DailyEnergyRecord SQLite table.
/// Data source priority: JSON (local) → JSON (S3) → xlsx fallback. /// Data source priority: JSON (S3) → xlsx fallback.
/// Runs automatically at 01:00 UTC daily. Can also be triggered manually via the /// Runs automatically at 01:00 UTC daily. Can also be triggered manually via the
/// IngestDailyData API endpoint. /// IngestDailyData API endpoint.
/// </summary> /// </summary>
@ -14,9 +14,6 @@ public static class DailyIngestionService
private static readonly String TmpReportDir = private static readonly String TmpReportDir =
Environment.CurrentDirectory + "/tmp_report/"; Environment.CurrentDirectory + "/tmp_report/";
private static readonly String JsonAggregatedDir =
Environment.CurrentDirectory + "/tmp_report/aggregated/";
private static Timer? _dailyTimer; private static Timer? _dailyTimer;
/// <summary> /// <summary>
@ -53,7 +50,7 @@ public static class DailyIngestionService
Console.WriteLine($"[DailyIngestion] Starting ingestion for all SodioHome installations..."); Console.WriteLine($"[DailyIngestion] Starting ingestion for all SodioHome installations...");
var installations = Db.Installations var installations = Db.Installations
.Where(i => i.Product == (Int32)ProductType.SodioHome) .Where(i => i.Product == (Int32)ProductType.SodioHome && i.Device != 3) // Skip Growatt (device=3)
.ToList(); .ToList();
foreach (var installation in installations) foreach (var installation in installations)
@ -72,7 +69,7 @@ public static class DailyIngestionService
} }
/// <summary> /// <summary>
/// Ingests data for one installation. Tries JSON (local + S3) and xlsx. /// Ingests data for one installation. Tries JSON (S3) and xlsx.
/// Both sources are tried — idempotency checks prevent duplicates. /// Both sources are tried — idempotency checks prevent duplicates.
/// JSON provides recent data; xlsx provides historical data. /// JSON provides recent data; xlsx provides historical data.
/// </summary> /// </summary>
@ -82,31 +79,46 @@ public static class DailyIngestionService
IngestFromXlsx(installationId); IngestFromXlsx(installationId);
} }
private static async Task<Boolean> TryIngestFromJson(Int64 installationId) /// <summary>
/// Ingests S3 JSON data for a specific date range. Used by report services
/// as a fallback when SQLite has no records for the requested period.
/// Idempotent — skips dates already in DB.
/// </summary>
public static async Task IngestDateRangeAsync(Int64 installationId, DateOnly fromDate, DateOnly toDate)
{ {
var installation = Db.GetInstallationById(installationId);
if (installation is null) return;
var newDaily = 0; var newDaily = 0;
var newHourly = 0; var newHourly = 0;
var jsonDir = Path.Combine(JsonAggregatedDir, installationId.ToString());
// Collect JSON content from local files for (var date = fromDate; date <= toDate; date = date.AddDays(1))
var jsonFiles = Directory.Exists(jsonDir)
? Directory.GetFiles(jsonDir, "*.json")
: Array.Empty<String>();
foreach (var jsonPath in jsonFiles.OrderBy(f => f))
{ {
var content = File.ReadAllText(jsonPath); var isoDate = date.ToString("yyyy-MM-dd");
if (Db.DailyRecordExists(installationId, isoDate))
continue;
var content = await AggregatedJsonParser.TryReadFromS3(installation, isoDate);
if (content is null) continue;
var (d, h) = IngestJsonContent(installationId, content); var (d, h) = IngestJsonContent(installationId, content);
newDaily += d; newDaily += d;
newHourly += h; newHourly += h;
} }
// Also try S3 for recent days (yesterday + today) if no local files found if (newDaily > 0 || newHourly > 0)
if (jsonFiles.Length == 0) Console.WriteLine($"[DailyIngestion] Installation {installationId} (S3 date-range {fromDate:yyyy-MM-dd}{toDate:yyyy-MM-dd}): {newDaily} day(s), {newHourly} hour(s) ingested.");
}
private static async Task<Boolean> TryIngestFromJson(Int64 installationId)
{ {
var newDaily = 0;
var newHourly = 0;
var installation = Db.GetInstallationById(installationId); var installation = Db.GetInstallationById(installationId);
if (installation is not null) if (installation is null) return false;
{
// Try S3 for recent days (yesterday + today), skip if already in DB
for (var daysBack = 0; daysBack <= 1; daysBack++) for (var daysBack = 0; daysBack <= 1; daysBack++)
{ {
var date = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-daysBack)); var date = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-daysBack));
@ -122,8 +134,6 @@ public static class DailyIngestionService
newDaily += d; newDaily += d;
newHourly += h; newHourly += h;
} }
}
}
if (newDaily > 0 || newHourly > 0) if (newDaily > 0 || newHourly > 0)
Console.WriteLine($"[DailyIngestion] Installation {installationId} (JSON): {newDaily} day(s), {newHourly} hour(s) ingested."); Console.WriteLine($"[DailyIngestion] Installation {installationId} (JSON): {newDaily} day(s), {newHourly} hour(s) ingested.");

View File

@ -9,7 +9,7 @@ public static class ReportAggregationService
{ {
private static Timer? _monthEndTimer; private static Timer? _monthEndTimer;
private static Timer? _yearEndTimer; private static Timer? _yearEndTimer;
// private static Timer? _sundayReportTimer; private static Timer? _weeklyReportTimer;
private const Double ElectricityPriceCHF = 0.39; private const Double ElectricityPriceCHF = 0.39;
private static readonly String MistralUrl = "https://api.mistral.ai/v1/chat/completions"; private static readonly String MistralUrl = "https://api.mistral.ai/v1/chat/completions";
@ -18,17 +18,42 @@ public static class ReportAggregationService
public static void StartScheduler() public static void StartScheduler()
{ {
// ScheduleSundayWeeklyReport(); ScheduleWeeklyReportJob();
ScheduleMonthEndJob(); ScheduleMonthEndJob();
ScheduleYearEndJob(); ScheduleYearEndJob();
Console.WriteLine("[ReportAggregation] Scheduler started."); Console.WriteLine("[ReportAggregation] Scheduler started.");
} }
private static void ScheduleWeeklyReportJob()
{
// Run every Monday at 03:00 UTC — after DailyIngestionService (01:00 UTC)
var now = DateTime.UtcNow;
var daysUntil = ((Int32)DayOfWeek.Monday - (Int32)now.DayOfWeek + 7) % 7;
var nextMon = now.Date.AddDays(daysUntil == 0 && now.Hour >= 3 ? 7 : daysUntil).AddHours(3);
_weeklyReportTimer = new Timer(
_ =>
{
try
{
if (DateTime.UtcNow.DayOfWeek == DayOfWeek.Monday)
RunWeeklyReportGeneration().GetAwaiter().GetResult();
}
catch (Exception ex)
{
Console.Error.WriteLine($"[ReportAggregation] Weekly report error: {ex.Message}");
}
},
null, nextMon - now, TimeSpan.FromDays(7));
Console.WriteLine($"[ReportAggregation] Weekly report scheduled (Monday 03:00 UTC). Next run: {nextMon:yyyy-MM-dd HH:mm} UTC");
}
private static void ScheduleMonthEndJob() private static void ScheduleMonthEndJob()
{ {
// Run daily at 02:00, but only act on the 1st of the month // Run daily at 04:00 UTC, but only act on the 1st of the month
var now = DateTime.Now; var now = DateTime.UtcNow;
var next = now.Date.AddHours(2); var next = now.Date.AddHours(4);
if (now >= next) next = next.AddDays(1); if (now >= next) next = next.AddDays(1);
_monthEndTimer = new Timer( _monthEndTimer = new Timer(
@ -36,7 +61,7 @@ public static class ReportAggregationService
{ {
try try
{ {
if (DateTime.Now.Day == 1) if (DateTime.UtcNow.Day == 1)
RunMonthEndAggregation().GetAwaiter().GetResult(); RunMonthEndAggregation().GetAwaiter().GetResult();
} }
catch (Exception ex) catch (Exception ex)
@ -46,14 +71,14 @@ public static class ReportAggregationService
}, },
null, next - now, TimeSpan.FromDays(1)); 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}"); Console.WriteLine($"[ReportAggregation] Month-end timer scheduled (daily 04:00 UTC, acts on 1st). Next check: {next:yyyy-MM-dd HH:mm} UTC");
} }
private static void ScheduleYearEndJob() private static void ScheduleYearEndJob()
{ {
// Run daily at 03:00, but only act on Jan 2nd // Run daily at 05:00 UTC, but only act on Jan 2nd
var now = DateTime.Now; var now = DateTime.UtcNow;
var next = now.Date.AddHours(3); var next = now.Date.AddHours(5);
if (now >= next) next = next.AddDays(1); if (now >= next) next = next.AddDays(1);
_yearEndTimer = new Timer( _yearEndTimer = new Timer(
@ -61,7 +86,7 @@ public static class ReportAggregationService
{ {
try try
{ {
if (DateTime.Now.Month == 1 && DateTime.Now.Day == 2) if (DateTime.UtcNow.Month == 1 && DateTime.UtcNow.Day == 2)
RunYearEndAggregation().GetAwaiter().GetResult(); RunYearEndAggregation().GetAwaiter().GetResult();
} }
catch (Exception ex) catch (Exception ex)
@ -71,99 +96,40 @@ public static class ReportAggregationService
}, },
null, next - now, TimeSpan.FromDays(1)); 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}"); Console.WriteLine($"[ReportAggregation] Year-end timer scheduled (daily 05:00 UTC, acts on Jan 2). Next check: {next:yyyy-MM-dd HH:mm} UTC");
} }
// ── Sunday Weekly Report Automation ───────────────────────────── // ── Weekly Report Auto-Generation ─────────────────────────────
// 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() private static async Task RunWeeklyReportGeneration()
// { {
// // Calculate delay until next Sunday 06:00 Console.WriteLine("[ReportAggregation] Running Monday weekly report generation...");
// var now = DateTime.Now;
// var daysUntil = ((int)DayOfWeek.Sunday - (int)now.DayOfWeek + 7) % 7; var installations = Db.Installations
// var nextSunday = now.Date.AddDays(daysUntil == 0 && now.Hour >= 6 ? 7 : daysUntil).AddHours(6); .Where(i => i.Product == (Int32)ProductType.SodioHome && i.Device != 3) // Skip Growatt (device=3)
// .ToList();
// _sundayReportTimer = new Timer(
// _ => var generated = 0;
// { foreach (var installation in installations)
// try {
// { try
// if (DateTime.Now.DayOfWeek == DayOfWeek.Sunday) {
// RunSundayWeeklyReports().GetAwaiter().GetResult(); var report = await WeeklyReportService.GenerateReportAsync(
// } installation.Id, installation.InstallationName, "en");
// catch (Exception ex)
// { SaveWeeklySummary(installation.Id, report, "en");
// Console.Error.WriteLine($"[ReportAggregation] Sunday report error: {ex.Message}"); generated++;
// }
// }, Console.WriteLine($"[ReportAggregation] Weekly report generated for installation {installation.Id} ({installation.InstallationName})");
// null, nextSunday - now, TimeSpan.FromDays(7)); }
// catch (Exception ex)
// Console.WriteLine($"[ReportAggregation] Sunday weekly report scheduled. Next run: {nextSunday:yyyy-MM-dd HH:mm}"); {
// } Console.Error.WriteLine($"[ReportAggregation] Failed weekly report for installation {installation.Id}: {ex.Message}");
// }
// private static async Task RunSundayWeeklyReports() }
// {
// Console.WriteLine("[ReportAggregation] Running Sunday weekly report generation..."); Console.WriteLine($"[ReportAggregation] Weekly report generation complete. {generated}/{installations.Count} installations processed.");
// }
// // 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 ─────────────────────────────────────────── // ── Save Weekly Summary ───────────────────────────────────────────
@ -335,7 +301,7 @@ public static class ReportAggregationService
private static async Task RunMonthEndAggregation() private static async Task RunMonthEndAggregation()
{ {
var previousMonth = DateTime.Now.AddMonths(-1); var previousMonth = DateTime.UtcNow.AddMonths(-1);
var year = previousMonth.Year; var year = previousMonth.Year;
var month = previousMonth.Month; var month = previousMonth.Month;
var first = new DateOnly(year, month, 1); var first = new DateOnly(year, month, 1);
@ -453,7 +419,7 @@ public static class ReportAggregationService
private static async Task RunYearEndAggregation() private static async Task RunYearEndAggregation()
{ {
var previousYear = DateTime.Now.Year - 1; var previousYear = DateTime.UtcNow.Year - 1;
Console.WriteLine($"[ReportAggregation] Running year-end aggregation for {previousYear}..."); Console.WriteLine($"[ReportAggregation] Running year-end aggregation for {previousYear}...");

View File

@ -91,7 +91,8 @@ public static class WeeklyReportService
/// Generates a full weekly report for the given installation. /// Generates a full weekly report for the given installation.
/// Data source priority: /// Data source priority:
/// 1. DailyEnergyRecord rows in SQLite (populated by DailyIngestionService) /// 1. DailyEnergyRecord rows in SQLite (populated by DailyIngestionService)
/// 2. xlsx file fallback (if DB not yet populated for the target week) /// 2. S3 fallback — ingests missing dates into SQLite via IngestDateRangeAsync, then retries
/// 3. xlsx file fallback (legacy, if both DB and S3 are empty)
/// Cache is keyed to the calendar week — invalidated when the week changes. /// Cache is keyed to the calendar week — invalidated when the week changes.
/// Pass weekStartOverride to generate a report for a specific historical week (disables cache). /// Pass weekStartOverride to generate a report for a specific historical week (disables cache).
/// </summary> /// </summary>
@ -121,15 +122,25 @@ public static class WeeklyReportService
var previousWeekDays = Db.GetDailyRecords(installationId, prevMon, prevSun) var previousWeekDays = Db.GetDailyRecords(installationId, prevMon, prevSun)
.Select(ToDailyEnergyData).ToList(); .Select(ToDailyEnergyData).ToList();
// 2. Fallback: if DB empty, parse xlsx on the fly (pre-ingestion scenario) // 2. S3 fallback: if DB empty, try ingesting from S3 into SQLite, then retry
if (currentWeekDays.Count == 0)
{
Console.WriteLine($"[WeeklyReportService] DB empty for week {curMon:yyyy-MM-dd}, trying S3 ingestion...");
await DailyIngestionService.IngestDateRangeAsync(installationId, prevMon, curSun);
currentWeekDays = Db.GetDailyRecords(installationId, curMon, curSun)
.Select(ToDailyEnergyData).ToList();
previousWeekDays = Db.GetDailyRecords(installationId, prevMon, prevSun)
.Select(ToDailyEnergyData).ToList();
}
// 3. xlsx fallback: if still empty after S3, parse xlsx on the fly (legacy)
if (currentWeekDays.Count == 0) if (currentWeekDays.Count == 0)
{ {
// Only parse xlsx files whose date range overlaps the needed weeks
var relevantFiles = GetRelevantXlsxFiles(installationId, prevMon, curSun); var relevantFiles = GetRelevantXlsxFiles(installationId, prevMon, curSun);
if (relevantFiles.Count > 0) if (relevantFiles.Count > 0)
{ {
Console.WriteLine($"[WeeklyReportService] DB empty for week {curMon:yyyy-MM-dd}, falling back to {relevantFiles.Count} xlsx file(s)."); Console.WriteLine($"[WeeklyReportService] S3 empty, falling back to {relevantFiles.Count} xlsx file(s).");
var allDaysParsed = relevantFiles.SelectMany(p => ExcelDataParser.Parse(p)).ToList(); var allDaysParsed = relevantFiles.SelectMany(p => ExcelDataParser.Parse(p)).ToList();
currentWeekDays = allDaysParsed currentWeekDays = allDaysParsed
.Where(d => { var date = DateOnly.Parse(d.Date); return date >= curMon && date <= curSun; }) .Where(d => { var date = DateOnly.Parse(d.Date); return date >= curMon && date <= curSun; })
@ -145,11 +156,12 @@ public static class WeeklyReportService
$"No energy data available for week {curMon:yyyy-MM-dd}{curSun:yyyy-MM-dd}. " + $"No energy data available for week {curMon:yyyy-MM-dd}{curSun:yyyy-MM-dd}. " +
"Upload an xlsx file or wait for daily ingestion."); "Upload an xlsx file or wait for daily ingestion.");
// 3. Load hourly records from SQLite for behavioral analysis // 4. Load hourly records from SQLite for behavioral analysis
// (S3 ingestion above already populated hourly records if available)
var currentHourlyData = Db.GetHourlyRecords(installationId, curMon, curSun) var currentHourlyData = Db.GetHourlyRecords(installationId, curMon, curSun)
.Select(ToHourlyEnergyData).ToList(); .Select(ToHourlyEnergyData).ToList();
// 3b. Fallback: if DB empty, parse hourly data from xlsx // 4b. xlsx fallback for hourly data
if (currentHourlyData.Count == 0) if (currentHourlyData.Count == 0)
{ {
var relevantFiles = GetRelevantXlsxFiles(installationId, curMon, curSun); var relevantFiles = GetRelevantXlsxFiles(installationId, curMon, curSun);
@ -264,11 +276,24 @@ public static class WeeklyReportService
selfSufficiency, totalEnergySaved, totalSavingsCHF, selfSufficiency, totalEnergySaved, totalSavingsCHF,
behavior, installationName, language, location, country, region); behavior, installationName, language, location, country, region);
// Compute data availability — which days of the week are missing
var availableDates = currentWeekDays.Select(d => d.Date).ToHashSet();
var missingDates = new List<String>();
if (weekStart.HasValue && weekEnd.HasValue)
{
for (var d = weekStart.Value; d <= weekEnd.Value; d = d.AddDays(1))
{
var iso = d.ToString("yyyy-MM-dd");
if (!availableDates.Contains(iso))
missingDates.Add(iso);
}
}
return new WeeklyReportResponse return new WeeklyReportResponse
{ {
InstallationName = installationName, InstallationName = installationName,
PeriodStart = currentWeekDays.First().Date, PeriodStart = weekStart?.ToString("yyyy-MM-dd") ?? currentWeekDays.First().Date,
PeriodEnd = currentWeekDays.Last().Date, PeriodEnd = weekEnd?.ToString("yyyy-MM-dd") ?? currentWeekDays.Last().Date,
CurrentWeek = currentSummary, CurrentWeek = currentSummary,
PreviousWeek = previousSummary, PreviousWeek = previousSummary,
TotalEnergySaved = totalEnergySaved, TotalEnergySaved = totalEnergySaved,
@ -284,6 +309,9 @@ public static class WeeklyReportService
DailyData = currentWeekDays, DailyData = currentWeekDays,
Behavior = behavior, Behavior = behavior,
AiInsight = aiInsight, AiInsight = aiInsight,
DaysAvailable = currentWeekDays.Count,
DaysExpected = 7,
MissingDates = missingDates,
}; };
} }

View File

@ -4,10 +4,7 @@ import {
Alert, Alert,
Box, Box,
CircularProgress, CircularProgress,
Container,
Grid,
Paper, Paper,
TextField,
Typography Typography
} from '@mui/material'; } from '@mui/material';
import { Line } from 'react-chartjs-2'; import { Line } from 'react-chartjs-2';
@ -60,23 +57,16 @@ interface HourlyEnergyRecord {
// ── Date Helpers ───────────────────────────────────────────── // ── Date Helpers ─────────────────────────────────────────────
/** /**
* Anchor date for the 7-day strip. Returns last completed Sunday. * Returns the Monday of the current week.
* To switch to live-data mode later, change to: () => new Date()
*/ */
function getDataAnchorDate(): Date { function getCurrentMonday(): Date {
const today = new Date(); const today = new Date();
today.setHours(0, 0, 0, 0);
const dow = today.getDay(); // 0=Sun const dow = today.getDay(); // 0=Sun
const lastSunday = new Date(today); const offset = dow === 0 ? 6 : dow - 1; // Mon=0 offset
lastSunday.setDate(today.getDate() - (dow === 0 ? 7 : dow)); const monday = new Date(today);
lastSunday.setHours(0, 0, 0, 0); monday.setDate(today.getDate() - offset);
return lastSunday; return monday;
}
function getWeekRange(anchor: Date): { monday: Date; sunday: Date } {
const sunday = new Date(anchor);
const monday = new Date(sunday);
monday.setDate(sunday.getDate() - 6);
return { monday, sunday };
} }
function formatDateISO(d: Date): string { function formatDateISO(d: Date): string {
@ -86,12 +76,21 @@ function formatDateISO(d: Date): string {
return `${y}-${m}-${day}`; return `${y}-${m}-${day}`;
} }
function getWeekDays(monday: Date): Date[] { /**
return Array.from({ length: 7 }, (_, i) => { * Returns current week Monyesterday. Today excluded because
const d = new Date(monday); * S3 aggregated file is not available until end of day.
d.setDate(monday.getDate() + i); */
return d; function getCurrentWeekDays(currentMonday: Date): 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));
}
return days;
} }
// ── Main Component ─────────────────────────────────────────── // ── Main Component ───────────────────────────────────────────
@ -102,86 +101,72 @@ export default function DailySection({
installationId: number; installationId: number;
}) { }) {
const intl = useIntl(); const intl = useIntl();
const anchor = useMemo(() => getDataAnchorDate(), []); const currentMonday = useMemo(() => getCurrentMonday(), []);
const { monday, sunday } = useMemo(() => getWeekRange(anchor), [anchor]); const yesterday = useMemo(() => {
const weekDays = useMemo(() => getWeekDays(monday), [monday]); const d = new Date();
d.setHours(0, 0, 0, 0);
d.setDate(d.getDate() - 1);
return d;
}, []);
const [weekRecords, setWeekRecords] = useState<DailyEnergyData[]>([]); const [allRecords, setAllRecords] = useState<DailyEnergyData[]>([]);
const [weekHourlyRecords, setWeekHourlyRecords] = useState<HourlyEnergyRecord[]>([]); const [allHourlyRecords, setAllHourlyRecords] = useState<HourlyEnergyRecord[]>([]);
const [selectedDate, setSelectedDate] = useState(formatDateISO(sunday)); const [selectedDate, setSelectedDate] = useState(formatDateISO(yesterday));
const [selectedDayRecord, setSelectedDayRecord] = useState<DailyEnergyData | null>(null); const [selectedDayRecord, setSelectedDayRecord] = useState<DailyEnergyData | null>(null);
const [hourlyRecords, setHourlyRecords] = useState<HourlyEnergyRecord[]>([]); const [hourlyRecords, setHourlyRecords] = useState<HourlyEnergyRecord[]>([]);
const [loadingWeek, setLoadingWeek] = useState(false); const [loadingWeek, setLoadingWeek] = useState(false);
const [noData, setNoData] = useState(false); const [noData, setNoData] = useState(false);
// Fetch week data (daily + hourly) via combined endpoint with xlsx fallback + cache // Current week Mon→yesterday only
const weekDays = useMemo(
() => getCurrentWeekDays(currentMonday),
[currentMonday]
);
// Fetch data for current week days
useEffect(() => { useEffect(() => {
if (weekDays.length === 0) return;
const from = formatDateISO(weekDays[0]);
const to = formatDateISO(weekDays[weekDays.length - 1]);
setLoadingWeek(true); setLoadingWeek(true);
axiosConfig axiosConfig
.get('/GetDailyDetailRecords', { .get('/GetDailyDetailRecords', {
params: { params: { installationId, from, to }
installationId,
from: formatDateISO(monday),
to: formatDateISO(sunday)
}
}) })
.then((res) => { .then((res) => {
const daily = res.data?.dailyRecords?.records ?? []; const daily = res.data?.dailyRecords?.records ?? [];
const hourly = res.data?.hourlyRecords?.records ?? []; const hourly = res.data?.hourlyRecords?.records ?? [];
setWeekRecords(Array.isArray(daily) ? daily : []); setAllRecords(Array.isArray(daily) ? daily : []);
setWeekHourlyRecords(Array.isArray(hourly) ? hourly : []); setAllHourlyRecords(Array.isArray(hourly) ? hourly : []);
}) })
.catch(() => { .catch(() => {
setWeekRecords([]); setAllRecords([]);
setWeekHourlyRecords([]); setAllHourlyRecords([]);
}) })
.finally(() => setLoadingWeek(false)); .finally(() => setLoadingWeek(false));
}, [installationId, monday, sunday]); }, [installationId, weekDays]);
// When selected date changes, extract data from week cache or fetch // When selected date changes, extract data from cache
useEffect(() => { useEffect(() => {
setNoData(false); setNoData(false);
setSelectedDayRecord(null); setSelectedDayRecord(null);
// Try week cache first const cachedDay = allRecords.find((r) => r.date === selectedDate);
const cachedDay = weekRecords.find((r) => r.date === selectedDate); const cachedHours = allHourlyRecords.filter((r) => r.date === selectedDate);
const cachedHours = weekHourlyRecords.filter((r) => r.date === selectedDate);
if (cachedDay) { if (cachedDay) {
setSelectedDayRecord(cachedDay); setSelectedDayRecord(cachedDay);
setHourlyRecords(cachedHours); setHourlyRecords(cachedHours);
return; } else if (!loadingWeek) {
}
// Not in cache (date picker outside strip) — fetch via combined endpoint
axiosConfig
.get('/GetDailyDetailRecords', {
params: { installationId, from: selectedDate, to: selectedDate }
})
.then((res) => {
const daily = res.data?.dailyRecords?.records ?? [];
const hourly = res.data?.hourlyRecords?.records ?? [];
setHourlyRecords(Array.isArray(hourly) ? hourly : []);
if (Array.isArray(daily) && daily.length > 0) {
setSelectedDayRecord(daily[0]);
} else {
setNoData(true); setNoData(true);
}
})
.catch(() => {
setHourlyRecords([]); setHourlyRecords([]);
setNoData(true); }
}); }, [installationId, selectedDate, allRecords, allHourlyRecords, loadingWeek]);
}, [installationId, selectedDate, weekRecords, weekHourlyRecords]);
const record = selectedDayRecord; const record = selectedDayRecord;
const kpis = useMemo(() => computeKPIs(record), [record]); const kpis = useMemo(() => computeKPIs(record), [record]);
const handleDatePicker = (e: React.ChangeEvent<HTMLInputElement>) => {
setSelectedDate(e.target.value);
};
const handleStripSelect = (date: string) => { const handleStripSelect = (date: string) => {
setSelectedDate(date); setSelectedDate(date);
setNoData(false); setNoData(false);
@ -197,53 +182,37 @@ export default function DailySection({
return ( return (
<> <>
{/* Date Picker */} {/* Day Strip — current week Mon→yesterday */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
<Typography variant="body1" fontWeight="bold">
<FormattedMessage id="selectDate" defaultMessage="Select Date" />
</Typography>
<TextField
type="date"
size="small"
value={selectedDate}
onChange={handleDatePicker}
inputProps={{ max: formatDateISO(new Date()) }}
sx={{ width: 200 }}
/>
</Box>
{/* 7-Day Strip */}
<DayStrip <DayStrip
weekDays={weekDays} weekDays={weekDays}
weekRecords={weekRecords}
selectedDate={selectedDate} selectedDate={selectedDate}
onSelect={handleStripSelect} onSelect={handleStripSelect}
sunday={sunday}
loading={loadingWeek}
/> />
{/* Loading state */} {/* Loading state */}
{loadingWeek && !record && ( {loadingWeek && !record && (
<Container <Box
sx={{ sx={{
display: 'flex', display: 'flex',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
height: '20vh' py: 6
}} }}
> >
<CircularProgress size={40} style={{ color: '#ffc04d' }} /> <CircularProgress size={40} style={{ color: '#ffc04d' }} />
</Container> </Box>
)} )}
{/* No data state */} {/* No data state */}
{!loadingWeek && noData && !record && ( {!loadingWeek && noData && !record && (
<Alert severity="info" sx={{ mb: 3 }}> <Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 6 }}>
<Alert severity="warning">
<FormattedMessage <FormattedMessage
id="noDataForDate" id="noReportData"
defaultMessage="No data available for the selected date." defaultMessage="No report data found."
/> />
</Alert> </Alert>
</Box>
)} )}
{/* Day detail */} {/* Day detail */}
@ -312,25 +281,14 @@ function computeKPIs(record: DailyEnergyData | null) {
function DayStrip({ function DayStrip({
weekDays, weekDays,
weekRecords,
selectedDate, selectedDate,
onSelect, onSelect,
sunday,
loading
}: { }: {
weekDays: Date[]; weekDays: Date[];
weekRecords: DailyEnergyData[];
selectedDate: string; selectedDate: string;
onSelect: (date: string) => void; onSelect: (date: string) => void;
sunday: Date;
loading: boolean;
}) { }) {
const intl = useIntl(); const intl = useIntl();
const sundayLabel = sunday.toLocaleDateString(intl.locale, {
year: 'numeric',
month: 'short',
day: 'numeric'
});
return ( return (
<Box sx={{ mb: 3 }}> <Box sx={{ mb: 3 }}>
@ -339,19 +297,12 @@ function DayStrip({
display: 'flex', display: 'flex',
gap: 1, gap: 1,
overflowX: 'auto', overflowX: 'auto',
pb: 1, pb: 1
mb: 1
}} }}
> >
{weekDays.map((day) => { {weekDays.map((day) => {
const dateStr = formatDateISO(day); const dateStr = formatDateISO(day);
const isSelected = dateStr === selectedDate; const isSelected = dateStr === selectedDate;
const record = weekRecords.find((r) => r.date === dateStr);
const selfSuff =
record && record.loadConsumption > 0
? Math.min(100, (1 - record.gridImport / record.loadConsumption) * 100)
: null;
return ( return (
<Paper <Paper
key={dateStr} key={dateStr}
@ -375,25 +326,14 @@ function DayStrip({
<Typography variant="h6" fontWeight="bold" sx={{ lineHeight: 1.2 }}> <Typography variant="h6" fontWeight="bold" sx={{ lineHeight: 1.2 }}>
{day.getDate()} {day.getDate()}
</Typography> </Typography>
<Typography
variant="caption"
sx={{ color: selfSuff != null ? '#8e44ad' : '#ccc' }}
>
{loading
? '...'
: selfSuff != null
? `${selfSuff.toFixed(0)}%`
: '—'}
</Typography>
</Paper> </Paper>
); );
})} })}
</Box> </Box>
<Typography variant="caption" sx={{ color: '#888' }}> <Typography variant="caption" sx={{ color: '#888' }}>
<FormattedMessage <FormattedMessage
id="dataUpTo" id="currentWeekHint"
defaultMessage="Data up to {date}" defaultMessage="Current week (Monyesterday)"
values={{ date: sundayLabel }}
/> />
</Typography> </Typography>
</Box> </Box>

View File

@ -607,6 +607,7 @@ function SodioHomeInstallation(props: singleInstallationProps) {
} }
/> />
{props.current_installation.device !== 3 && (
<Route <Route
path={routes.report} path={routes.report}
element={ element={
@ -615,6 +616,7 @@ function SodioHomeInstallation(props: singleInstallationProps) {
/> />
} }
/> />
)}
<Route <Route
path={'*'} path={'*'}

View File

@ -66,6 +66,9 @@ interface WeeklyReportResponse {
gridImportChangePercent: number; gridImportChangePercent: number;
dailyData: DailyEnergyData[]; dailyData: DailyEnergyData[];
aiInsight: string; aiInsight: string;
daysAvailable: number;
daysExpected: number;
missingDates: string[];
} }
interface ReportSummary { interface ReportSummary {
@ -285,7 +288,7 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
const safeTab = Math.min(activeTab, tabs.length - 1); const safeTab = Math.min(activeTab, tabs.length - 1);
return ( return (
<Box sx={{ p: 2, maxWidth: 900, mx: 'auto' }} className="report-container"> <Box sx={{ p: 2, width: '100%', maxWidth: 900, mx: 'auto' }} className="report-container">
<style>{` <style>{`
@media print { @media print {
body * { visibility: hidden; } body * { visibility: hidden; }
@ -312,10 +315,10 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
</Button> </Button>
</Box> </Box>
<Box sx={{ display: tabs[safeTab]?.key === 'daily' ? 'block' : 'none' }}> <Box sx={{ display: tabs[safeTab]?.key === 'daily' ? 'block' : 'none', minHeight: '50vh' }}>
<DailySection installationId={installationId} /> <DailySection installationId={installationId} />
</Box> </Box>
<Box sx={{ display: tabs[safeTab]?.key === 'weekly' ? 'block' : 'none' }}> <Box sx={{ display: tabs[safeTab]?.key === 'weekly' ? 'block' : 'none', minHeight: '50vh' }}>
<WeeklySection <WeeklySection
installationId={installationId} installationId={installationId}
latestMonthlyPeriodEnd={ latestMonthlyPeriodEnd={
@ -325,7 +328,7 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
} }
/> />
</Box> </Box>
<Box sx={{ display: tabs[safeTab]?.key === 'monthly' ? 'block' : 'none' }}> <Box sx={{ display: tabs[safeTab]?.key === 'monthly' ? 'block' : 'none', minHeight: '50vh' }}>
<MonthlySection <MonthlySection
installationId={installationId} installationId={installationId}
reports={monthlyReports} reports={monthlyReports}
@ -334,7 +337,7 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
onGenerate={handleGenerateMonthly} onGenerate={handleGenerateMonthly}
/> />
</Box> </Box>
<Box sx={{ display: tabs[safeTab]?.key === 'yearly' ? 'block' : 'none' }}> <Box sx={{ display: tabs[safeTab]?.key === 'yearly' ? 'block' : 'none', minHeight: '50vh' }}>
<YearlySection <YearlySection
installationId={installationId} installationId={installationId}
reports={yearlyReports} reports={yearlyReports}
@ -386,28 +389,30 @@ function WeeklySection({ installationId, latestMonthlyPeriodEnd }: { installatio
if (loading) { if (loading) {
return ( return (
<Container <Box
sx={{ sx={{
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
height: '50vh' py: 6
}} }}
> >
<CircularProgress size={50} style={{ color: '#ffc04d' }} /> <CircularProgress size={50} style={{ color: '#ffc04d' }} />
<Typography variant="body2" mt={2}> <Typography variant="body2" mt={2}>
<FormattedMessage id="generatingReport" defaultMessage="Generating weekly report..." /> <FormattedMessage id="generatingReport" defaultMessage="Generating weekly report..." />
</Typography> </Typography>
</Container> </Box>
); );
} }
if (error) { if (error) {
return ( return (
<Container sx={{ py: 4 }}> <Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 6 }}>
<Alert severity="warning"><FormattedMessage id="noReportData" defaultMessage="No report data found." /></Alert> <Alert severity="warning">
</Container> <FormattedMessage id="noReportData" defaultMessage="No report data found." />
</Alert>
</Box>
); );
} }
@ -483,6 +488,21 @@ function WeeklySection({ installationId, latestMonthlyPeriodEnd }: { installatio
</Box> </Box>
</Paper> </Paper>
{/* 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 */} {/* 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' }}>
@ -801,9 +821,11 @@ function MonthlySection({
onRegenerate={(r: MonthlyReport) => onGenerate(r.year, r.month)} onRegenerate={(r: MonthlyReport) => onGenerate(r.year, r.month)}
/> />
) : pendingMonths.length === 0 ? ( ) : pendingMonths.length === 0 ? (
<Alert severity="info"> <Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 6 }}>
<FormattedMessage id="noMonthlyData" defaultMessage="No monthly reports available yet. Weekly reports will appear here for aggregation once generated." /> <Alert severity="warning">
<FormattedMessage id="noReportData" defaultMessage="No report data found." />
</Alert> </Alert>
</Box>
) : null} ) : null}
</> </>
); );
@ -875,9 +897,11 @@ function YearlySection({
onRegenerate={(r: YearlyReport) => onGenerate(r.year)} onRegenerate={(r: YearlyReport) => onGenerate(r.year)}
/> />
) : pendingYears.length === 0 ? ( ) : pendingYears.length === 0 ? (
<Alert severity="info"> <Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 6 }}>
<FormattedMessage id="noYearlyData" defaultMessage="No yearly reports available yet. Monthly reports will appear here for aggregation once generated." /> <Alert severity="warning">
<FormattedMessage id="noReportData" defaultMessage="No report data found." />
</Alert> </Alert>
</Box>
) : null} ) : null}
</> </>
); );
@ -910,12 +934,11 @@ function AggregatedSection<T extends ReportSummary>({
if (reports.length === 0) { if (reports.length === 0) {
return ( return (
<Alert severity="info"> <Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 6 }}>
<FormattedMessage <Alert severity="warning">
id={type === 'monthly' ? 'noMonthlyData' : 'noYearlyData'} <FormattedMessage id="noReportData" defaultMessage="No report data found." />
defaultMessage={type === 'monthly' ? 'No monthly reports available yet.' : 'No yearly reports available yet.'}
/>
</Alert> </Alert>
</Box>
); );
} }

View File

@ -231,6 +231,13 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
currentTab != 'tree' && currentTab != 'tree' &&
!location.pathname.includes('folder'); !location.pathname.includes('folder');
// Determine if current installation is Growatt (device=3) to hide report tab
const currentInstallation = sodiohomeInstallations.find((i) =>
location.pathname.includes(`/${i.id}/`)
);
const isGrowatt = currentInstallation?.device === 3
|| (sodiohomeInstallations.length === 1 && sodiohomeInstallations[0].device === 3);
const tabs = inInstallationView && currentUser.userType == UserType.admin const tabs = inInstallationView && currentUser.userType == UserType.admin
? [ ? [
{ {
@ -409,7 +416,9 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
textColor="primary" textColor="primary"
indicatorColor="primary" indicatorColor="primary"
> >
{tabs.map((tab) => ( {tabs
.filter((tab) => !(isGrowatt && tab.value === 'report'))
.map((tab) => (
<Tab <Tab
key={tab.value} key={tab.value}
value={tab.value} value={tab.value}
@ -476,7 +485,9 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
textColor="primary" textColor="primary"
indicatorColor="primary" indicatorColor="primary"
> >
{singleInstallationTabs.map((tab) => ( {singleInstallationTabs
.filter((tab) => !(isGrowatt && tab.value === 'report'))
.map((tab) => (
<Tab <Tab
key={tab.value} key={tab.value}
value={tab.value} value={tab.value}

View File

@ -101,6 +101,7 @@
"lastSeen": "Zuletzt gesehen", "lastSeen": "Zuletzt gesehen",
"reportTitle": "Wöchentlicher Leistungsbericht", "reportTitle": "Wöchentlicher Leistungsbericht",
"weeklyInsights": "Wöchentliche Einblicke", "weeklyInsights": "Wöchentliche Einblicke",
"missingDaysWarning": "Daten verfügbar für {available}/{expected} Tage. Fehlend: {dates}",
"weeklySavings": "Ihre Einsparungen diese Woche", "weeklySavings": "Ihre Einsparungen diese Woche",
"solarEnergyUsed": "Energie gespart", "solarEnergyUsed": "Energie gespart",
"solarStayedHome": "Solar + Batterie, nicht vom Netz", "solarStayedHome": "Solar + Batterie, nicht vom Netz",
@ -143,10 +144,9 @@
"dailyTab": "Täglich", "dailyTab": "Täglich",
"dailyReportTitle": "Tägliche Energieübersicht", "dailyReportTitle": "Tägliche Energieübersicht",
"dailySummary": "Tagesübersicht", "dailySummary": "Tagesübersicht",
"selectDate": "Datum wählen",
"noDataForDate": "Keine Daten für das gewählte Datum verfügbar.", "noDataForDate": "Keine Daten für das gewählte Datum verfügbar.",
"noHourlyData": "Stündliche Daten für diesen Tag nicht verfügbar.", "noHourlyData": "Stündliche Daten für diesen Tag nicht verfügbar.",
"dataUpTo": "Daten bis {date}", "currentWeekHint": "Aktuelle Woche (Mogestern)",
"intradayChart": "Tagesverlauf Energiefluss", "intradayChart": "Tagesverlauf Energiefluss",
"batteryPower": "Batterieleistung", "batteryPower": "Batterieleistung",
"batterySoCLabel": "Batterie SoC", "batterySoCLabel": "Batterie SoC",

View File

@ -83,6 +83,7 @@
"lastSeen": "Last seen", "lastSeen": "Last seen",
"reportTitle": "Weekly Performance Report", "reportTitle": "Weekly Performance Report",
"weeklyInsights": "Weekly Insights", "weeklyInsights": "Weekly Insights",
"missingDaysWarning": "Data available for {available}/{expected} days. Missing: {dates}",
"weeklySavings": "Your Savings This Week", "weeklySavings": "Your Savings This Week",
"solarEnergyUsed": "Energy Saved", "solarEnergyUsed": "Energy Saved",
"solarStayedHome": "solar + battery, not bought from grid", "solarStayedHome": "solar + battery, not bought from grid",
@ -125,10 +126,9 @@
"dailyTab": "Daily", "dailyTab": "Daily",
"dailyReportTitle": "Daily Energy Summary", "dailyReportTitle": "Daily Energy Summary",
"dailySummary": "Daily Summary", "dailySummary": "Daily Summary",
"selectDate": "Select Date",
"noDataForDate": "No data available for the selected date.", "noDataForDate": "No data available for the selected date.",
"noHourlyData": "Hourly data not available for this day.", "noHourlyData": "Hourly data not available for this day.",
"dataUpTo": "Data up to {date}", "currentWeekHint": "Current week (Monyesterday)",
"intradayChart": "Intraday Power Flow", "intradayChart": "Intraday Power Flow",
"batteryPower": "Battery Power", "batteryPower": "Battery Power",
"batterySoCLabel": "Battery SoC", "batterySoCLabel": "Battery SoC",

View File

@ -95,6 +95,7 @@
"lastSeen": "Dernière connexion", "lastSeen": "Dernière connexion",
"reportTitle": "Rapport de performance hebdomadaire", "reportTitle": "Rapport de performance hebdomadaire",
"weeklyInsights": "Aperçus hebdomadaires", "weeklyInsights": "Aperçus hebdomadaires",
"missingDaysWarning": "Données disponibles pour {available}/{expected} jours. Manquants : {dates}",
"weeklySavings": "Vos économies cette semaine", "weeklySavings": "Vos économies cette semaine",
"solarEnergyUsed": "Énergie économisée", "solarEnergyUsed": "Énergie économisée",
"solarStayedHome": "solaire + batterie, non achetée au réseau", "solarStayedHome": "solaire + batterie, non achetée au réseau",
@ -137,10 +138,9 @@
"dailyTab": "Quotidien", "dailyTab": "Quotidien",
"dailyReportTitle": "Résumé énergétique quotidien", "dailyReportTitle": "Résumé énergétique quotidien",
"dailySummary": "Résumé du jour", "dailySummary": "Résumé du jour",
"selectDate": "Sélectionner la date",
"noDataForDate": "Aucune donnée disponible pour la date sélectionnée.", "noDataForDate": "Aucune donnée disponible pour la date sélectionnée.",
"noHourlyData": "Données horaires non disponibles pour ce jour.", "noHourlyData": "Données horaires non disponibles pour ce jour.",
"dataUpTo": "Données jusqu'au {date}", "currentWeekHint": "Semaine en cours (lunhier)",
"intradayChart": "Flux d'énergie journalier", "intradayChart": "Flux d'énergie journalier",
"batteryPower": "Puissance batterie", "batteryPower": "Puissance batterie",
"batterySoCLabel": "SoC batterie", "batterySoCLabel": "SoC batterie",

View File

@ -106,6 +106,7 @@
"lastSeen": "Ultima visualizzazione", "lastSeen": "Ultima visualizzazione",
"reportTitle": "Rapporto settimanale sulle prestazioni", "reportTitle": "Rapporto settimanale sulle prestazioni",
"weeklyInsights": "Approfondimenti settimanali", "weeklyInsights": "Approfondimenti settimanali",
"missingDaysWarning": "Dati disponibili per {available}/{expected} giorni. Mancanti: {dates}",
"weeklySavings": "I tuoi risparmi questa settimana", "weeklySavings": "I tuoi risparmi questa settimana",
"solarEnergyUsed": "Energia risparmiata", "solarEnergyUsed": "Energia risparmiata",
"solarStayedHome": "solare + batteria, non acquistata dalla rete", "solarStayedHome": "solare + batteria, non acquistata dalla rete",
@ -148,10 +149,9 @@
"dailyTab": "Giornaliero", "dailyTab": "Giornaliero",
"dailyReportTitle": "Riepilogo energetico giornaliero", "dailyReportTitle": "Riepilogo energetico giornaliero",
"dailySummary": "Riepilogo del giorno", "dailySummary": "Riepilogo del giorno",
"selectDate": "Seleziona data",
"noDataForDate": "Nessun dato disponibile per la data selezionata.", "noDataForDate": "Nessun dato disponibile per la data selezionata.",
"noHourlyData": "Dati orari non disponibili per questo giorno.", "noHourlyData": "Dati orari non disponibili per questo giorno.",
"dataUpTo": "Dati fino al {date}", "currentWeekHint": "Settimana corrente (lunieri)",
"intradayChart": "Flusso energetico giornaliero", "intradayChart": "Flusso energetico giornaliero",
"batteryPower": "Potenza batteria", "batteryPower": "Potenza batteria",
"batterySoCLabel": "SoC batteria", "batterySoCLabel": "SoC batteria",