From 8e502202427226abf2db2853596b689b6130ffa7 Mon Sep 17 00:00:00 2001 From: Yinyin Liu Date: Wed, 18 Feb 2026 12:12:58 +0100 Subject: [PATCH] added language support for monitor AI and non-AI content and email delivery --- csharp/App/Backend/Controller.cs | 12 +- csharp/App/Backend/DataTypes/Methods/User.cs | 59 ++++- csharp/App/Backend/DataTypes/User.cs | 1 + csharp/App/Backend/Database/Db.cs | 6 + .../App/Backend/Services/DiagnosticService.cs | 50 ++-- .../Backend/Services/ReportEmailService.cs | 231 +++++++++++++++--- .../Backend/Services/WeeklyReportService.cs | 35 ++- typescript/frontend-marios2/src/App.tsx | 44 +++- .../BatteryView/DetailedBatteryView.tsx | 33 ++- .../DetailedBatteryViewSalidomo.tsx | 34 ++- .../src/content/dashboards/Log/Log.tsx | 2 +- .../dashboards/ManageAccess/Access.tsx | 29 +-- .../dashboards/ManageAccess/UserAccess.tsx | 27 +- .../content/dashboards/Overview/overview.tsx | 2 +- .../SodiohomeInstallations/WeeklyReport.tsx | 68 +++--- .../src/contexts/AccessContextProvider.tsx | 70 ++---- typescript/frontend-marios2/src/lang/de.json | 43 +++- typescript/frontend-marios2/src/lang/en.json | 44 +++- typescript/frontend-marios2/src/lang/fr.json | 51 +++- typescript/frontend-marios2/src/lang/it.json | 139 +++++++++++ .../SidebarLayout/Header/Menu/index.tsx | 7 +- 21 files changed, 721 insertions(+), 266 deletions(-) create mode 100644 typescript/frontend-marios2/src/lang/it.json diff --git a/csharp/App/Backend/Controller.cs b/csharp/App/Backend/Controller.cs index 5ab5041ec..4d68c2888 100644 --- a/csharp/App/Backend/Controller.cs +++ b/csharp/App/Backend/Controller.cs @@ -763,7 +763,7 @@ public class Controller : ControllerBase installation.Product != (int)ProductType.SodiStoreMax) return BadRequest("AI diagnostics not available for this product."); - var result = await DiagnosticService.DiagnoseAsync(installationId, errorDescription); + var result = await DiagnosticService.DiagnoseAsync(installationId, errorDescription, user.Language ?? "en"); if (result is null) return NoContent(); // no diagnosis available (not in knowledge base, no API key) @@ -869,7 +869,7 @@ public class Controller : ControllerBase /// Returns JSON with daily data, weekly totals, ratios, and AI insight. /// [HttpGet(nameof(GetWeeklyReport))] - public async Task> GetWeeklyReport(Int64 installationId, Token authToken) + public async Task> GetWeeklyReport(Int64 installationId, Token authToken, string? language = null) { var user = Db.GetSession(authToken)?.User; if (user == null) @@ -885,7 +885,8 @@ public class Controller : ControllerBase try { - var report = await WeeklyReportService.GenerateReportAsync(installationId, installation.InstallationName); + var lang = language ?? user.Language ?? "en"; + var report = await WeeklyReportService.GenerateReportAsync(installationId, installation.InstallationName, lang); return Ok(report); } catch (Exception ex) @@ -911,8 +912,9 @@ public class Controller : ControllerBase try { - var report = await WeeklyReportService.GenerateReportAsync(installationId, installation.InstallationName); - await ReportEmailService.SendReportEmailAsync(report, emailAddress); + var lang = user.Language ?? "en"; + var report = await WeeklyReportService.GenerateReportAsync(installationId, installation.InstallationName, lang); + await ReportEmailService.SendReportEmailAsync(report, emailAddress, lang); return Ok(new { message = $"Report sent to {emailAddress}" }); } catch (Exception ex) diff --git a/csharp/App/Backend/DataTypes/Methods/User.cs b/csharp/App/Backend/DataTypes/Methods/User.cs index cd6b992c9..5792993e6 100644 --- a/csharp/App/Backend/DataTypes/Methods/User.cs +++ b/csharp/App/Backend/DataTypes/Methods/User.cs @@ -237,26 +237,63 @@ public static class UserMethods public static Task SendPasswordResetEmail(this User user, String token) { - const String subject = "Reset the password of your Inesco Energy Account"; const String resetLink = "https://monitor.inesco.energy/api/ResetPassword"; // TODO: move to settings file var encodedToken = HttpUtility.UrlEncode(token); - - var body = $"Dear {user.Name}\n" + - $"To reset your password " + - $"please open this link:{resetLink}?token={encodedToken}"; + + var (subject, body) = (user.Language ?? "en") switch + { + "de" => ( + "Passwort Ihres Inesco Energy Kontos zurücksetzen", + $"Sehr geehrte/r {user.Name}\n" + + $"Um Ihr Passwort zurückzusetzen, öffnen Sie bitte diesen Link: {resetLink}?token={encodedToken}" + ), + "fr" => ( + "Réinitialisation du mot de passe de votre compte Inesco Energy", + $"Cher/Chère {user.Name}\n" + + $"Pour réinitialiser votre mot de passe, veuillez ouvrir ce lien : {resetLink}?token={encodedToken}" + ), + "it" => ( + "Reimposta la password del tuo account Inesco Energy", + $"Gentile {user.Name}\n" + + $"Per reimpostare la password, apra questo link: {resetLink}?token={encodedToken}" + ), + _ => ( + "Reset the password of your Inesco Energy Account", + $"Dear {user.Name}\n" + + $"To reset your password please open this link: {resetLink}?token={encodedToken}" + ) + }; return user.SendEmail(subject, body); } public static Task SendNewUserWelcomeMessage(this User user) { - const String subject = "Your new Inesco Energy Account"; - var resetLink = $"https://monitor.inesco.energy/?username={user.Email}"; // TODO: move to settings file - - var body = $"Dear {user.Name}\n" + - $"To set your password and log in to your " + - $"Inesco Energy Account open this link:{resetLink}"; + + var (subject, body) = (user.Language ?? "en") switch + { + "de" => ( + "Ihr neues Inesco Energy Konto", + $"Sehr geehrte/r {user.Name}\n" + + $"Um Ihr Passwort festzulegen und sich bei Ihrem Inesco Energy Konto anzumelden, öffnen Sie bitte diesen Link: {resetLink}" + ), + "fr" => ( + "Votre nouveau compte Inesco Energy", + $"Cher/Chère {user.Name}\n" + + $"Pour définir votre mot de passe et vous connecter à votre compte Inesco Energy, veuillez ouvrir ce lien : {resetLink}" + ), + "it" => ( + "Il tuo nuovo account Inesco Energy", + $"Gentile {user.Name}\n" + + $"Per impostare la password e accedere al suo account Inesco Energy, apra questo link: {resetLink}" + ), + _ => ( + "Your new Inesco Energy Account", + $"Dear {user.Name}\n" + + $"To set your password and log in to your Inesco Energy Account open this link: {resetLink}" + ) + }; return user.SendEmail(subject, body); } diff --git a/csharp/App/Backend/DataTypes/User.cs b/csharp/App/Backend/DataTypes/User.cs index 4738c7f45..a5ec78224 100644 --- a/csharp/App/Backend/DataTypes/User.cs +++ b/csharp/App/Backend/DataTypes/User.cs @@ -10,6 +10,7 @@ public class User : TreeNode public int UserType { get; set; } = 0; public Boolean MustResetPassword { get; set; } = false; public String? Password { get; set; } = null!; + public String Language { get; set; } = "en"; [Unique] public override String Name { get; set; } = null!; diff --git a/csharp/App/Backend/Database/Db.cs b/csharp/App/Backend/Database/Db.cs index 7d21eee60..5f10d3091 100644 --- a/csharp/App/Backend/Database/Db.cs +++ b/csharp/App/Backend/Database/Db.cs @@ -53,6 +53,12 @@ public static partial class Db Connection.CreateTable(); }); + // One-time migration: normalize legacy long-form language values to ISO codes + Connection.Execute("UPDATE User SET Language = 'en' WHERE Language IS NULL OR Language = '' OR Language = 'english'"); + Connection.Execute("UPDATE User SET Language = 'de' WHERE Language = 'german'"); + Connection.Execute("UPDATE User SET Language = 'fr' WHERE Language = 'french'"); + Connection.Execute("UPDATE User SET Language = 'it' WHERE Language = 'italian'"); + //UpdateKeys(); CleanupSessions().SupressAwaitWarning(); DeleteSnapshots().SupressAwaitWarning(); diff --git a/csharp/App/Backend/Services/DiagnosticService.cs b/csharp/App/Backend/Services/DiagnosticService.cs index 4757c97d7..fbe4c2937 100644 --- a/csharp/App/Backend/Services/DiagnosticService.cs +++ b/csharp/App/Backend/Services/DiagnosticService.cs @@ -38,26 +38,40 @@ public static class DiagnosticService // ── public entry-point ────────────────────────────────────────── - /// - /// Returns a diagnosis for . - /// First checks the static AlarmKnowledgeBase for known Sinexcel/Growatt alarms. - /// Falls back to in-memory cache, then calls Mistral AI only for unknown alarms. - /// - public static async Task DiagnoseAsync(Int64 installationId, string errorDescription) + private static string LanguageName(string code) => code switch { - // 1. Check the static knowledge base first (no API call needed) - var knownDiagnosis = AlarmKnowledgeBase.TryGetDiagnosis(errorDescription); - if (knownDiagnosis is not null) + "de" => "German", + "fr" => "French", + "it" => "Italian", + _ => "English" + }; + + /// + /// Returns a diagnosis for in the given language. + /// For English: checks the static AlarmKnowledgeBase first, then in-memory cache, then Mistral AI. + /// For other languages: skips the knowledge base (English-only) and goes directly to Mistral AI. + /// Cache is keyed by (errorDescription, language) so each language is cached separately. + /// + public static async Task DiagnoseAsync(Int64 installationId, string errorDescription, string language = "en") + { + var cacheKey = $"{errorDescription}|{language}"; + + // 1. For English only: check the static knowledge base first (no API call needed) + if (language == "en") { - Console.WriteLine($"[DiagnosticService] Found diagnosis in knowledge base for: {errorDescription}"); - return knownDiagnosis; + var knownDiagnosis = AlarmKnowledgeBase.TryGetDiagnosis(errorDescription); + if (knownDiagnosis is not null) + { + Console.WriteLine($"[DiagnosticService] Found diagnosis in knowledge base for: {errorDescription}"); + return knownDiagnosis; + } } // 2. If AI is not enabled, we can't proceed further if (!IsEnabled) return null; // 3. Check in-memory cache for previously fetched AI diagnoses - if (Cache.TryGetValue(errorDescription, out var cached)) + if (Cache.TryGetValue(cacheKey, out var cached)) return cached; // 4. Gather context from the DB for AI prompt @@ -77,14 +91,14 @@ public static class DiagnosticService .ToList(); // 5. Build prompt and call Mistral API (only for unknown alarms) - Console.WriteLine($"[DiagnosticService] Calling Mistral for unknown alarm: {errorDescription}"); - var prompt = BuildPrompt(errorDescription, productName, recentDescriptions); + Console.WriteLine($"[DiagnosticService] Calling Mistral for unknown alarm: {errorDescription} ({language})"); + var prompt = BuildPrompt(errorDescription, productName, recentDescriptions, language); var response = await CallMistralAsync(prompt); if (response is null) return null; // 6. Store in cache for future requests - Cache.TryAdd(errorDescription, response); + Cache.TryAdd(cacheKey, response); return response; } @@ -101,7 +115,7 @@ public static class DiagnosticService if (Cache.TryGetValue(errorDescription, out var cached)) return cached; - var prompt = BuildPrompt(errorDescription, "SodioHome", new List()); + var prompt = BuildPrompt(errorDescription, "SodioHome", new List(), "en"); var response = await CallMistralAsync(prompt); if (response is not null) @@ -112,7 +126,7 @@ public static class DiagnosticService // ── prompt ────────────────────────────────────────────────────── - private static string BuildPrompt(string errorDescription, string productName, List recentErrors) + private static string BuildPrompt(string errorDescription, string productName, List recentErrors, string language = "en") { var recentList = recentErrors.Count > 0 ? string.Join(", ", recentErrors) @@ -128,7 +142,7 @@ Explain for a non-technical homeowner. Keep it very short and simple: - explanation: 1 short sentence, no jargon - causes: 2-3 bullet points, plain language - nextSteps: 2-3 simple action items a homeowner can understand -Reply with ONLY valid JSON, no markdown: +IMPORTANT: Write all text values in {LanguageName(language)}. Reply with ONLY valid JSON, no markdown: {{""explanation"":""1 short sentence"",""causes"":[""...""],""nextSteps"":[""...""]}} "; } diff --git a/csharp/App/Backend/Services/ReportEmailService.cs b/csharp/App/Backend/Services/ReportEmailService.cs index 39af3d4ec..d3d08647c 100644 --- a/csharp/App/Backend/Services/ReportEmailService.cs +++ b/csharp/App/Backend/Services/ReportEmailService.cs @@ -10,13 +10,14 @@ namespace InnovEnergy.App.Backend.Services; public static class ReportEmailService { /// - /// Sends the weekly report as a nicely formatted HTML email. + /// Sends the weekly report as a nicely formatted HTML email in the user's language. /// Uses MailKit directly (same config as existing Mailer library) but with HTML support. /// - public static async Task SendReportEmailAsync(WeeklyReportResponse report, string recipientEmail) + public static async Task SendReportEmailAsync(WeeklyReportResponse report, string recipientEmail, string language = "en") { - var subject = $"Weekly Energy Report — {report.InstallationName} ({report.PeriodStart} to {report.PeriodEnd})"; - var html = BuildHtmlEmail(report); + var strings = GetStrings(language); + var subject = $"{strings.Title} — {report.InstallationName} ({report.PeriodStart} to {report.PeriodEnd})"; + var html = BuildHtmlEmail(report, strings); var config = await ReadMailerConfig(); @@ -49,9 +50,169 @@ public static class ReportEmailService return config ?? throw new InvalidOperationException("Failed to read MailerConfig.json"); } + // ── Translation strings ───────────────────────────────────────────────── + + private record EmailStrings( + string Title, + string Insights, + string Summary, + string SavingsHeader, + string DailyBreakdown, + string Metric, + string ThisWeek, + string LastWeek, + string Change, + string PvProduction, + string Consumption, + string GridImport, + string GridExport, + string BatteryInOut, + string SolarEnergyUsed, + string StayedAtHome, + string EstMoneySaved, + string AtRate, + string SolarCoverage, + string FromSolar, + string BatteryEff, + string OutVsIn, + string Day, + string Load, + string GridIn, + string GridOut, + string BattInOut, + string Footer + ); + + private static EmailStrings GetStrings(string language) => language switch + { + "de" => new EmailStrings( + Title: "Wöchentlicher Leistungsbericht", + Insights: "Wöchentliche Erkenntnisse", + Summary: "Wöchentliche Zusammenfassung", + SavingsHeader: "Ihre Ersparnisse diese Woche", + DailyBreakdown: "Tägliche Aufschlüsselung (kWh)", + Metric: "Kennzahl", + ThisWeek: "Diese Woche", + LastWeek: "Letzte Woche", + Change: "Änderung", + PvProduction: "PV-Produktion", + Consumption: "Verbrauch", + GridImport: "Netzbezug", + GridExport: "Netzeinspeisung", + BatteryInOut: "Batterie Ein/Aus", + SolarEnergyUsed: "Genutzte Solarenergie", + StayedAtHome: "direkt genutzt", + EstMoneySaved: "Geschätzte Ersparnis", + AtRate: "bei 0.27 CHF/kWh", + SolarCoverage: "Solare Deckung", + FromSolar: "durch Solar", + BatteryEff: "Batterie-Eff.", + OutVsIn: "Aus vs. Ein", + Day: "Tag", + Load: "Last", + GridIn: "Netz Ein", + GridOut: "Netz Aus", + BattInOut: "Batt. Ein/Aus", + Footer: "Erstellt von Inesco Energy Monitor Platform · Powered by Mistral AI" + ), + "fr" => new EmailStrings( + Title: "Rapport de performance hebdomadaire", + Insights: "Aperçus de la semaine", + Summary: "Résumé de la semaine", + SavingsHeader: "Vos économies cette semaine", + DailyBreakdown: "Détail quotidien (kWh)", + Metric: "Indicateur", + ThisWeek: "Cette semaine", + LastWeek: "Semaine dernière", + Change: "Variation", + PvProduction: "Production PV", + Consumption: "Consommation", + GridImport: "Import réseau", + GridExport: "Export réseau", + BatteryInOut: "Batterie Entrée/Sortie", + SolarEnergyUsed: "Énergie solaire utilisée", + StayedAtHome: "autoconsommée", + EstMoneySaved: "Économies estimées", + AtRate: "à 0.27 CHF/kWh", + SolarCoverage: "Couverture solaire", + FromSolar: "depuis le solaire", + BatteryEff: "Eff. batterie", + OutVsIn: "sortie vs entrée", + Day: "Jour", + Load: "Charge", + GridIn: "Réseau Ent.", + GridOut: "Réseau Sor.", + BattInOut: "Batt. Ent./Sor.", + Footer: "Généré par Inesco Energy Monitor Platform · Propulsé par Mistral AI" + ), + "it" => new EmailStrings( + Title: "Rapporto settimanale delle prestazioni", + Insights: "Approfondimenti settimanali", + Summary: "Riepilogo settimanale", + SavingsHeader: "I tuoi risparmi questa settimana", + DailyBreakdown: "Dettaglio giornaliero (kWh)", + Metric: "Metrica", + ThisWeek: "Questa settimana", + LastWeek: "La settimana scorsa", + Change: "Variazione", + PvProduction: "Produzione PV", + Consumption: "Consumo", + GridImport: "Import dalla rete", + GridExport: "Export nella rete", + BatteryInOut: "Batteria Ent./Usc.", + SolarEnergyUsed: "Energia solare utilizzata", + StayedAtHome: "rimasta in casa", + EstMoneySaved: "Risparmio stimato", + AtRate: "a 0.27 CHF/kWh", + SolarCoverage: "Copertura solare", + FromSolar: "dal solare", + BatteryEff: "Eff. batteria", + OutVsIn: "uscita vs entrata", + Day: "Giorno", + Load: "Carico", + GridIn: "Rete Ent.", + GridOut: "Rete Usc.", + BattInOut: "Batt. Ent./Usc.", + Footer: "Generato da Inesco Energy Monitor Platform · Powered by Mistral AI" + ), + _ => new EmailStrings( + Title: "Weekly Performance Report", + Insights: "Weekly Insights", + Summary: "Weekly Summary", + SavingsHeader: "Your Savings This Week", + DailyBreakdown: "Daily Breakdown (kWh)", + Metric: "Metric", + ThisWeek: "This Week", + LastWeek: "Last Week", + Change: "Change", + PvProduction: "PV Production", + Consumption: "Consumption", + GridImport: "Grid Import", + GridExport: "Grid Export", + BatteryInOut: "Battery In/Out", + SolarEnergyUsed: "Solar Energy Used", + StayedAtHome: "stayed at home", + EstMoneySaved: "Est. Money Saved", + AtRate: "at 0.27 CHF/kWh", + SolarCoverage: "Solar Coverage", + FromSolar: "from solar", + BatteryEff: "Battery Eff.", + OutVsIn: "out vs in", + Day: "Day", + Load: "Load", + GridIn: "Grid In", + GridOut: "Grid Out", + BattInOut: "Batt In/Out", + Footer: "Generated by Inesco Energy Monitor Platform · Powered by Mistral AI" + ) + }; + // ── HTML email template ───────────────────────────────────────────── - public static string BuildHtmlEmail(WeeklyReportResponse r) + public static string BuildHtmlEmail(WeeklyReportResponse r, string language = "en") + => BuildHtmlEmail(r, GetStrings(language)); + + private static string BuildHtmlEmail(WeeklyReportResponse r, EmailStrings s) { var cur = r.CurrentWeek; var prev = r.PreviousWeek; @@ -91,47 +252,47 @@ public static class ReportEmailService var comparisonHtml = prev != null ? $@" - PV Production + {s.PvProduction} {cur.TotalPvProduction:F1} kWh {prev.TotalPvProduction:F1} kWh {FormatChange(r.PvChangePercent)} - Consumption + {s.Consumption} {cur.TotalConsumption:F1} kWh {prev.TotalConsumption:F1} kWh {FormatChange(r.ConsumptionChangePercent)} - Grid Import + {s.GridImport} {cur.TotalGridImport:F1} kWh {prev.TotalGridImport:F1} kWh {FormatChange(r.GridImportChangePercent)} - Grid Export + {s.GridExport} {cur.TotalGridExport:F1} kWh {prev.TotalGridExport:F1} kWh — - Battery In/Out + {s.BatteryInOut} {cur.TotalBatteryCharged:F1}/{cur.TotalBatteryDischarged:F1} kWh {prev.TotalBatteryCharged:F1}/{prev.TotalBatteryDischarged:F1} kWh — " : $@" - PV Production{cur.TotalPvProduction:F1} kWh - Consumption{cur.TotalConsumption:F1} kWh - Grid Import{cur.TotalGridImport:F1} kWh - Grid Export{cur.TotalGridExport:F1} kWh - Battery In/Out{cur.TotalBatteryCharged:F1}/{cur.TotalBatteryDischarged:F1} kWh"; + {s.PvProduction}{cur.TotalPvProduction:F1} kWh + {s.Consumption}{cur.TotalConsumption:F1} kWh + {s.GridImport}{cur.TotalGridImport:F1} kWh + {s.GridExport}{cur.TotalGridExport:F1} kWh + {s.BatteryInOut}{cur.TotalBatteryCharged:F1}/{cur.TotalBatteryDischarged:F1} kWh"; var comparisonHeaders = prev != null - ? @"This Week - Last Week - Change" - : @"This Week"; + ? $@"{s.ThisWeek} + {s.LastWeek} + {s.Change}" + : $@"{s.ThisWeek}"; return $@" @@ -145,7 +306,7 @@ public static class ReportEmailService -
Weekly Performance Report
+
{s.Title}
{r.InstallationName}
{r.PeriodStart} — {r.PeriodEnd}
@@ -154,7 +315,7 @@ public static class ReportEmailService -
Weekly Insights
+
{s.Insights}
{insightHtml}
@@ -164,10 +325,10 @@ public static class ReportEmailService -
Weekly Summary
+
{s.Summary}
- + {comparisonHeaders} {comparisonHtml} @@ -178,13 +339,13 @@ public static class ReportEmailService @@ -193,15 +354,15 @@ public static class ReportEmailService diff --git a/csharp/App/Backend/Services/WeeklyReportService.cs b/csharp/App/Backend/Services/WeeklyReportService.cs index 3d735fa58..195bcba5a 100644 --- a/csharp/App/Backend/Services/WeeklyReportService.cs +++ b/csharp/App/Backend/Services/WeeklyReportService.cs @@ -11,14 +11,17 @@ public static class WeeklyReportService private static readonly ConcurrentDictionary InsightCache = new(); + // Bump this version when the AI prompt changes to automatically invalidate old cache files + private const string CacheVersion = "v2"; + /// /// Generates a full weekly report for the given installation. - /// Caches the full report as JSON next to the xlsx. Cache is invalidated when xlsx is updated. + /// Caches the full report as JSON next to the xlsx. Cache is invalidated when xlsx is updated or CacheVersion changes. /// - public static async Task GenerateReportAsync(long installationId, string installationName) + public static async Task GenerateReportAsync(long installationId, string installationName, string language = "en") { var xlsxPath = TmpReportDir + installationId + ".xlsx"; - var cachePath = TmpReportDir + installationId + ".cache.json"; + var cachePath = TmpReportDir + $"{installationId}_{language}_{CacheVersion}.cache.json"; // Use cached report if xlsx hasn't changed since cache was written if (File.Exists(cachePath) && File.Exists(xlsxPath)) @@ -33,7 +36,7 @@ public static class WeeklyReportService await File.ReadAllTextAsync(cachePath)); if (cached != null) { - Console.WriteLine($"[WeeklyReportService] Returning cached report for installation {installationId}."); + Console.WriteLine($"[WeeklyReportService] Returning cached report for installation {installationId} ({language})."); return cached; } } @@ -45,7 +48,7 @@ public static class WeeklyReportService } var allDays = ExcelDataParser.Parse(xlsxPath); - var report = await GenerateReportFromDataAsync(allDays, installationName); + var report = await GenerateReportFromDataAsync(allDays, installationName, language); // Write cache try @@ -64,7 +67,7 @@ public static class WeeklyReportService /// Core report generation from daily data. Data-source agnostic. /// public static async Task GenerateReportFromDataAsync( - List allDays, string installationName) + List allDays, string installationName, string language = "en") { // Sort by date allDays = allDays.OrderBy(d => d.Date).ToList(); @@ -111,7 +114,7 @@ public static class WeeklyReportService // AI insight var aiInsight = await GetAiInsightAsync(currentWeekDays, currentSummary, previousSummary, - selfSufficiency, gridDependency, batteryEfficiency, installationName); + selfSufficiency, gridDependency, batteryEfficiency, installationName, language); return new WeeklyReportResponse { @@ -152,6 +155,14 @@ public static class WeeklyReportService private static readonly string MistralUrl = "https://api.mistral.ai/v1/chat/completions"; + private static string LanguageName(string code) => code switch + { + "de" => "German", + "fr" => "French", + "it" => "Italian", + _ => "English" + }; + private static async Task GetAiInsightAsync( List currentWeek, WeeklySummary current, @@ -159,7 +170,8 @@ public static class WeeklyReportService double selfSufficiency, double gridDependency, double batteryEfficiency, - string installationName) + string installationName, + string language = "en") { var apiKey = Environment.GetEnvironmentVariable("MISTRAL_API_KEY"); if (string.IsNullOrWhiteSpace(apiKey)) @@ -168,8 +180,8 @@ public static class WeeklyReportService return "AI insight unavailable (API key not configured)."; } - // Cache key: installation + period - var cacheKey = $"{installationName}_{currentWeek.Last().Date}"; + // Cache key: installation + period + language + var cacheKey = $"{installationName}_{currentWeek.Last().Date}_{language}"; if (InsightCache.TryGetValue(cacheKey, out var cached)) return cached; @@ -190,12 +202,15 @@ public static class WeeklyReportService Write exactly 4 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. + 1. Solar savings: this week the system saved {solarSavings} kWh from the grid. Explain what this means in simple terms (e.g. equivalent to X days of average household use, or roughly X CHF saved at ~0.27 CHF/kWh). 2. Best vs worst solar day: name the best and worst days with their PV kWh values. Mention likely weather reason. 3. Battery performance: was the battery well-utilized this week? Mention charge/discharge totals and any standout days. 4. Tip of the week: one specific, practical recommendation based on THIS week's patterns to save more energy or money. Rules: Use actual day names and numbers. Keep each bullet to 1-2 sentences. Write for a homeowner, not an engineer. Do NOT use asterisks or any formatting marks. +IMPORTANT: Write your entire response in {LanguageName(language)}. Daily data (kWh): {dayLines} diff --git a/typescript/frontend-marios2/src/App.tsx b/typescript/frontend-marios2/src/App.tsx index 8ea1209fe..b03530cb7 100644 --- a/typescript/frontend-marios2/src/App.tsx +++ b/typescript/frontend-marios2/src/App.tsx @@ -1,21 +1,22 @@ import { Navigate, Route, Routes, useNavigate } from 'react-router-dom'; import { CssBaseline } from '@mui/material'; import ThemeProvider from './theme/ThemeProvider'; -import React, { lazy, Suspense, useContext, useState } from 'react'; +import React, { lazy, Suspense, useContext, useEffect, useState } from 'react'; import { UserContext } from './contexts/userContext'; import Login from './components/login'; import { IntlProvider } from 'react-intl'; import en from './lang/en.json'; import de from './lang/de.json'; import fr from './lang/fr.json'; +import it from './lang/it.json'; import SuspenseLoader from './components/SuspenseLoader'; +import axiosConfig, { axiosConfigWithoutToken } from './Resources/axiosConfig'; import SidebarLayout from './layouts/SidebarLayout'; import { TokenContext } from './contexts/tokenContext'; import InstallationTabs from './content/dashboards/Installations/index'; import routes from 'src/Resources/routes.json'; import './App.css'; import ForgotPassword from './components/ForgotPassword'; -import { axiosConfigWithoutToken } from './Resources/axiosConfig'; import InstallationsContextProvider from './contexts/InstallationsContextProvider'; import AccessContextProvider from './contexts/AccessContextProvider'; import SalidomoInstallationTabs from './content/dashboards/SalidomoInstallations'; @@ -37,15 +38,38 @@ function App() { setAccessToSodistore } = useContext(ProductIdContext); - const [language, setLanguage] = useState('en'); + const [language, setLanguage] = useState( + () => localStorage.getItem('language') || currentUser?.language || 'en' + ); + + const onSelectLanguage = (lang: string) => { + setLanguage(lang); + localStorage.setItem('language', lang); + if (currentUser) { + const updatedUser = { ...currentUser, language: lang }; + setUser(updatedUser); + axiosConfig.put('/UpdateUser', updatedUser).catch(() => {}); + } + }; + + // Sync localStorage language to DB when it differs (e.g. user changed language before new code was deployed) + useEffect(() => { + if (currentUser && token) { + const storedLang = localStorage.getItem('language'); + if (storedLang && storedLang !== currentUser.language) { + const updatedUser = { ...currentUser, language: storedLang }; + setUser(updatedUser); + axiosConfig.put('/UpdateUser', updatedUser).catch(() => {}); + } + } + }, [token]); + const getTranslations = () => { switch (language) { - case 'en': - return en; - case 'de': - return de; - case 'fr': - return fr; + case 'de': return de; + case 'fr': return fr; + case 'it': return it; + default: return en; } }; @@ -151,7 +175,7 @@ function App() { element={ } > diff --git a/typescript/frontend-marios2/src/content/dashboards/BatteryView/DetailedBatteryView.tsx b/typescript/frontend-marios2/src/content/dashboards/BatteryView/DetailedBatteryView.tsx index 34f8d9954..0dd779294 100644 --- a/typescript/frontend-marios2/src/content/dashboards/BatteryView/DetailedBatteryView.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/BatteryView/DetailedBatteryView.tsx @@ -1,4 +1,5 @@ import React, { useContext, useEffect, useState } from 'react'; +import { FormattedMessage, useIntl } from 'react-intl'; import { I_S3Credentials } from '../../../interfaces/S3Types'; import { Box, @@ -36,6 +37,7 @@ function DetailedBatteryView(props: DetailedBatteryViewProps) { if (props.batteryData === null) { return null; } + const intl = useIntl(); const navigate = useNavigate(); const [openModalFirmwareUpdate, setOpenModalFirmwareUpdate] = useState(false); const [openModalResultFirmwareUpdate, setOpenModalResultFirmwareUpdate] = @@ -242,7 +244,7 @@ function DetailedBatteryView(props: DetailedBatteryViewProps) { } } catch (error) { console.error('Error:', error.message); - setErrorMessage('Download battery log failed, please try again.'); + setErrorMessage(intl.formatMessage({ id: 'downloadBatteryLogFailed' })); setOpenModalError(true); } finally { setOpenModalStartDownloadBatteryLog(false); @@ -282,7 +284,7 @@ function DetailedBatteryView(props: DetailedBatteryViewProps) { gutterBottom sx={{ fontWeight: 'bold' }} > - The firmware is getting updated. Please wait... +
- Ok +
@@ -337,12 +339,11 @@ function DetailedBatteryView(props: DetailedBatteryViewProps) { gutterBottom sx={{ fontWeight: 'bold' }} > - Do you really want to update the firmware? + - This action requires the battery service to be stopped for around - 10-15 minutes. +
- Proceed +
@@ -409,8 +410,7 @@ function DetailedBatteryView(props: DetailedBatteryViewProps) { gutterBottom sx={{ fontWeight: 'bold' }} > - The battery log is getting downloaded. It will be saved in the - Downloads folder. Please wait... +
- Ok +
@@ -465,12 +465,11 @@ function DetailedBatteryView(props: DetailedBatteryViewProps) { gutterBottom sx={{ fontWeight: 'bold' }} > - Do you really want to download battery log? + - This action requires the battery service to be stopped for around - 10-15 minutes. +
- Proceed +
@@ -553,7 +552,7 @@ function DetailedBatteryView(props: DetailedBatteryViewProps) { }} onClick={ErrorModalHandleOk} > - Ok + diff --git a/typescript/frontend-marios2/src/content/dashboards/BatteryView/DetailedBatteryViewSalidomo.tsx b/typescript/frontend-marios2/src/content/dashboards/BatteryView/DetailedBatteryViewSalidomo.tsx index e62529324..5eb2172c7 100644 --- a/typescript/frontend-marios2/src/content/dashboards/BatteryView/DetailedBatteryViewSalidomo.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/BatteryView/DetailedBatteryViewSalidomo.tsx @@ -1,4 +1,5 @@ import React, { useContext, useEffect, useState } from 'react'; +import { FormattedMessage, useIntl } from 'react-intl'; import { I_S3Credentials } from '../../../interfaces/S3Types'; import { Box, @@ -36,7 +37,7 @@ function DetailedBatteryViewSalidomo(props: DetailedBatteryViewProps) { if (props.batteryData === null) { return null; } - + const intl = useIntl(); const navigate = useNavigate(); const [openModalFirmwareUpdate, setOpenModalFirmwareUpdate] = useState(false); const [openModalResultFirmwareUpdate, setOpenModalResultFirmwareUpdate] = @@ -243,7 +244,7 @@ function DetailedBatteryViewSalidomo(props: DetailedBatteryViewProps) { } } catch (error) { console.error('Error:', error.message); - setErrorMessage('Download battery log failed, please try again.'); + setErrorMessage(intl.formatMessage({ id: 'downloadBatteryLogFailed' })); setOpenModalError(true); } finally { setOpenModalStartDownloadBatteryLog(false); @@ -283,7 +284,7 @@ function DetailedBatteryViewSalidomo(props: DetailedBatteryViewProps) { gutterBottom sx={{ fontWeight: 'bold' }} > - The firmware is getting updated. Please wait... +
- Ok +
@@ -338,12 +339,11 @@ function DetailedBatteryViewSalidomo(props: DetailedBatteryViewProps) { gutterBottom sx={{ fontWeight: 'bold' }} > - Do you really want to update the firmware? + - This action requires the battery service to be stopped for around - 10-15 minutes. +
- Proceed +
@@ -410,8 +410,7 @@ function DetailedBatteryViewSalidomo(props: DetailedBatteryViewProps) { gutterBottom sx={{ fontWeight: 'bold' }} > - The battery log is getting downloaded. It will be saved in the - Downloads folder. Please wait... +
- Ok +
@@ -466,12 +465,11 @@ function DetailedBatteryViewSalidomo(props: DetailedBatteryViewProps) { gutterBottom sx={{ fontWeight: 'bold' }} > - Do you really want to download battery log? + - This action requires the battery service to be stopped for around - 10-15 minutes. +
- Proceed +
@@ -554,7 +552,7 @@ function DetailedBatteryViewSalidomo(props: DetailedBatteryViewProps) { }} onClick={ErrorModalHandleOk} > - Ok + diff --git a/typescript/frontend-marios2/src/content/dashboards/Log/Log.tsx b/typescript/frontend-marios2/src/content/dashboards/Log/Log.tsx index 3fc396d2f..3f545dc60 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Log/Log.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Log/Log.tsx @@ -494,7 +494,7 @@ function Log(props: LogProps) { {diag.description} - Last seen: {diag.lastSeen} + : {diag.lastSeen} diff --git a/typescript/frontend-marios2/src/content/dashboards/ManageAccess/Access.tsx b/typescript/frontend-marios2/src/content/dashboards/ManageAccess/Access.tsx index ba7fd33f6..8962d9d7a 100644 --- a/typescript/frontend-marios2/src/content/dashboards/ManageAccess/Access.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/ManageAccess/Access.tsx @@ -26,7 +26,7 @@ import PersonIcon from '@mui/icons-material/Person'; import Button from '@mui/material/Button'; import { Close as CloseIcon } from '@mui/icons-material'; import { AccessContext } from 'src/contexts/AccessContextProvider'; -import { FormattedMessage } from 'react-intl'; +import { FormattedMessage, useIntl } from 'react-intl'; import { UserType } from '../../../interfaces/UserTypes'; interface AccessProps { @@ -35,6 +35,7 @@ interface AccessProps { } function Access(props: AccessProps) { + const intl = useIntl(); const theme = useTheme(); const tokencontext = useContext(TokenContext); const { removeToken } = tokencontext; @@ -159,31 +160,11 @@ function Access(props: AccessProps) { if (NotGrantedAccessUsers.length > 0) { setError(true); - - const message = - ( - - ).props.defaultMessage + - ' ' + - NotGrantedAccessUsers.join(', '); - - setErrorMessage(message); + setErrorMessage(intl.formatMessage({ id: 'unableToGrantAccess' }) + ' ' + NotGrantedAccessUsers.join(', ')); } if (grantedAccessUsers.length > 0) { - const message = - ( - - ).props.defaultMessage + - ' ' + - grantedAccessUsers.join(', '); - setUpdatedMessage(message); + setUpdatedMessage(intl.formatMessage({ id: 'grantedAccessToUsers' }) + ' ' + grantedAccessUsers.join(', ')); setUpdated(true); @@ -306,7 +287,7 @@ function Access(props: AccessProps) { }} onClick={handleCloseFolder} > - Ok + diff --git a/typescript/frontend-marios2/src/content/dashboards/ManageAccess/UserAccess.tsx b/typescript/frontend-marios2/src/content/dashboards/ManageAccess/UserAccess.tsx index a0b8f5ddf..d0c471703 100644 --- a/typescript/frontend-marios2/src/content/dashboards/ManageAccess/UserAccess.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/ManageAccess/UserAccess.tsx @@ -29,7 +29,7 @@ import PersonIcon from '@mui/icons-material/Person'; import Button from '@mui/material/Button'; import { Close as CloseIcon } from '@mui/icons-material'; import { AccessContext } from 'src/contexts/AccessContextProvider'; -import { FormattedMessage } from 'react-intl'; +import { FormattedMessage, useIntl } from 'react-intl'; import { InnovEnergyUser, UserType } from '../../../interfaces/UserTypes'; import PersonRemoveIcon from '@mui/icons-material/PersonRemove'; import { @@ -47,6 +47,7 @@ function UserAccess(props: UserAccessProps) { return null; } + const intl = useIntl(); const theme = useTheme(); const tokencontext = useContext(TokenContext); const { removeToken } = tokencontext; @@ -165,20 +166,12 @@ function UserAccess(props: UserAccessProps) { ) .then((response) => { if (response) { - setUpdatedMessage( - 'Granted access to user ' + props.current_user.name - ); + setUpdatedMessage(intl.formatMessage({ id: 'grantedAccessToUser' }, { name: props.current_user.name })); setUpdated(true); } }) .catch((err) => { - if (err.response && err.response.status === 401) { - setErrorMessage( - `User ${props.current_user.name} already has access to folder "${folder.name}" or you don't have permission to grant this access` - ); - } else { - setErrorMessage('An error has occured'); - } + setErrorMessage(intl.formatMessage({ id: 'errorOccured' })); setError(true); }); } @@ -194,20 +187,12 @@ function UserAccess(props: UserAccessProps) { ) .then((response) => { if (response) { - setUpdatedMessage( - 'Granted access to user ' + props.current_user.name - ); + setUpdatedMessage(intl.formatMessage({ id: 'grantedAccessToUser' }, { name: props.current_user.name })); setUpdated(true); } }) .catch((err) => { - if (err.response && err.response.status === 401) { - setErrorMessage( - `User ${props.current_user.name} already has access to installation "${installation.name}" or you don't have permission to grant this access` - ); - } else { - setErrorMessage('An error has occured'); - } + setErrorMessage(intl.formatMessage({ id: 'errorOccured' })); setError(true); }); } diff --git a/typescript/frontend-marios2/src/content/dashboards/Overview/overview.tsx b/typescript/frontend-marios2/src/content/dashboards/Overview/overview.tsx index 8e0a4a249..d0170668d 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Overview/overview.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Overview/overview.tsx @@ -621,7 +621,7 @@ function Overview(props: OverviewProps) { )} - {!loading && ( + {!loading && dailyDataArray.length > 0 && ( {dailyData && ( (null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -89,14 +91,14 @@ function WeeklyReport({ installationId }: WeeklyReportProps) { useEffect(() => { fetchReport(); - }, [installationId]); + }, [installationId, intl.locale]); const fetchReport = async () => { setLoading(true); setError(null); try { const res = await axiosConfig.get('/GetWeeklyReport', { - params: { installationId } + params: { installationId, language: intl.locale } }); setReport(res.data); } catch (err: any) { @@ -117,9 +119,9 @@ function WeeklyReport({ installationId }: WeeklyReportProps) { await axiosConfig.post('/SendWeeklyReportEmail', null, { params: { installationId, emailAddress: email.trim() } }); - setSendStatus({ message: `Report sent to ${email}`, severity: 'success' }); + setSendStatus({ message: intl.formatMessage({ id: 'reportSentTo' }, { email }), severity: 'success' }); } catch (err: any) { - setSendStatus({ message: 'Failed to send. Please check the email address and try again.', severity: 'error' }); + setSendStatus({ message: intl.formatMessage({ id: 'reportSendError' }), severity: 'error' }); } finally { setSending(false); } @@ -138,7 +140,7 @@ function WeeklyReport({ installationId }: WeeklyReportProps) { > - Generating weekly report... + ); @@ -147,7 +149,7 @@ function WeeklyReport({ installationId }: WeeklyReportProps) { if (error) { return ( - {error} + ); } @@ -201,7 +203,7 @@ function WeeklyReport({ installationId }: WeeklyReportProps) { disabled={sending || !email.trim()} sx={{ bgcolor: '#2c3e50', '&:hover': { bgcolor: '#34495e' } }} > - Send Report + {sendStatus && ( @@ -222,7 +224,7 @@ function WeeklyReport({ installationId }: WeeklyReportProps) { }} > - Weekly Performance Report + {report.installationName} @@ -235,7 +237,7 @@ function WeeklyReport({ installationId }: WeeklyReportProps) { {/* Weekly Insights (was AI Insights) */} - Weekly Insights + - Your Savings This Week + @@ -303,44 +305,44 @@ function WeeklyReport({ installationId }: WeeklyReportProps) { {/* Weekly Summary Table */} - Weekly Summary +
- - - {prev && } - {prev && } + + + {prev && } + {prev && } - + {prev && } {prev && } - + {prev && } {prev && } - + {prev && } {prev && } - + {prev && } {prev && } - + {prev && } {prev && } @@ -353,18 +355,18 @@ function WeeklyReport({ installationId }: WeeklyReportProps) { {report.dailyData.length > 0 && ( - Daily Breakdown + {/* Legend */} - PV Production + - Consumption + - Grid Import + {/* Bars */} @@ -377,7 +379,7 @@ function WeeklyReport({ installationId }: WeeklyReportProps) { {dayLabel} - {!isCurrentWeek && (prev week)} + {!isCurrentWeek && } PV {d.pvProduction.toFixed(1)} | Load {d.loadConsumption.toFixed(1)} | Grid {d.gridImport.toFixed(1)} kWh diff --git a/typescript/frontend-marios2/src/contexts/AccessContextProvider.tsx b/typescript/frontend-marios2/src/contexts/AccessContextProvider.tsx index 5280ff674..c7a58c1b6 100644 --- a/typescript/frontend-marios2/src/contexts/AccessContextProvider.tsx +++ b/typescript/frontend-marios2/src/contexts/AccessContextProvider.tsx @@ -11,7 +11,7 @@ import { I_UserWithInheritedAccess, InnovEnergyUser } from '../interfaces/UserTypes'; -import { FormattedMessage } from 'react-intl'; +import { useIntl } from 'react-intl'; import { I_Installation } from '../interfaces/InstallationTypes'; interface AccessContextProviderProps { @@ -69,10 +69,11 @@ export const AccessContext = createContext({ }); const AccessContextProvider = ({ children }: { children: ReactNode }) => { + const intl = useIntl(); const [error, setError] = useState(false); - const [errormessage, setErrorMessage] = useState('An error has occured'); + const [errormessage, setErrorMessage] = useState(''); const [updated, setUpdated] = useState(false); - const [updatedmessage, setUpdatedMessage] = useState('Successfully updated'); + const [updatedmessage, setUpdatedMessage] = useState(''); const tokencontext = useContext(TokenContext); const { removeToken } = tokencontext; const [usersWithDirectAccess, setUsersWithDirectAccess] = useState< @@ -95,20 +96,12 @@ const AccessContextProvider = ({ children }: { children: ReactNode }) => { setUsersWithDirectAccess(response.data); } }) - .catch((error) => { + .catch(() => { setError(true); - - const message = ( - - ).props.defaultMessage; - - setErrorMessage(message); + setErrorMessage(intl.formatMessage({ id: 'unableToLoadData' })); }); }, - [] + [intl] ); const fetchInstallationsForUser = useCallback(async (userId: number) => { @@ -119,17 +112,11 @@ const AccessContextProvider = ({ children }: { children: ReactNode }) => { setAccessibleInstallationsForUser(response.data); } }) - .catch((error) => { + .catch(() => { setError(true); - const message = ( - - ).props.defaultMessage; - setErrorMessage(message); + setErrorMessage(intl.formatMessage({ id: 'unableToLoadData' })); }); - }, []); + }, [intl]); const fetchUsersWithInheritedAccessForResource = useCallback( async (tempresourceType: string, id: number) => { @@ -140,18 +127,12 @@ const AccessContextProvider = ({ children }: { children: ReactNode }) => { setUsersWithInheritedAccess(response.data); } }) - .catch((error) => { + .catch(() => { setError(true); - const message = ( - - ).props.defaultMessage; - setErrorMessage(message); + setErrorMessage(intl.formatMessage({ id: 'unableToLoadData' })); }); }, - [] + [intl] ); const fetchAvailableUsers = async (): Promise => { @@ -183,17 +164,7 @@ const AccessContextProvider = ({ children }: { children: ReactNode }) => { current_ResourceId ); - const message = - ( - - ).props.defaultMessage + - ' ' + - name; - - setUpdatedMessage(message); + setUpdatedMessage(intl.formatMessage({ id: 'revokedAccessFromUser' }) + ' ' + name); setUpdated(true); setTimeout(() => { @@ -201,19 +172,12 @@ const AccessContextProvider = ({ children }: { children: ReactNode }) => { }, 3000); } }) - .catch((error) => { + .catch(() => { setError(true); - const message = ( - - ).props.defaultMessage; - - setErrorMessage(message); + setErrorMessage(intl.formatMessage({ id: 'unableToRevokeAccess' })); }); }, - [] + [intl, fetchUsersWithDirectAccessForResource, fetchUsersWithInheritedAccessForResource] ); return ( diff --git a/typescript/frontend-marios2/src/lang/de.json b/typescript/frontend-marios2/src/lang/de.json index 6abfa2c0d..cfbeb1878 100644 --- a/typescript/frontend-marios2/src/lang/de.json +++ b/typescript/frontend-marios2/src/lang/de.json @@ -13,7 +13,9 @@ "english": "Englisch", "error": "Fehler", "folder": "Ordner", + "french": "Französisch", "german": "Deutsch", + "italian": "Italienisch", "groupTabs": "Gruppen", "groupTree": "Gruppenbaum", "overview": "Überblick", @@ -89,5 +91,44 @@ "unableToGrantAccess": "Der Zugriff kann nicht gewährt werden", "unableToLoadData": "Daten können nicht geladen werden", "unableToRevokeAccess": "Der Zugriff konnte nicht widerrufen werden", - "revokedAccessFromUser": "Zugriff vom Benutzer widerrufen" + "revokedAccessFromUser": "Zugriff vom Benutzer widerrufen", + "Show Errors": "Fehler anzeigen", + "Show Warnings": "Warnungen anzeigen", + "lastSeen": "Zuletzt gesehen", + "reportTitle": "Wöchentlicher Leistungsbericht", + "weeklyInsights": "Wöchentliche Einblicke", + "weeklySavings": "Ihre Einsparungen diese Woche", + "solarEnergyUsed": "Genutzte Solarenergie", + "solarStayedHome": "Ihrer Solarenergie blieb zu Hause", + "estMoneySaved": "Geschätzte Ersparnisse", + "atCHFRate": "bei 0,27 CHF/kWh Ø", + "solarCoverage": "Solarabdeckung", + "fromSolarSub": "des Verbrauchs aus Solar", + "batteryEfficiency": "Batterieeffizienz", + "batteryEffSub": "Energie aus vs. Energie ein", + "weeklySummary": "Wöchentliche Zusammenfassung", + "metric": "Kennzahl", + "thisWeek": "Diese Woche", + "change": "Änderung", + "pvProduction": "PV-Produktion", + "consumption": "Verbrauch", + "gridImport": "Netzbezug", + "gridExport": "Netzeinspeisung", + "batteryInOut": "Batterie Ein / Aus", + "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.", + "ok": "Ok", + "grantedAccessToUser": "Zugriff für Benutzer {name} gewährt", + "proceed": "Fortfahren", + "firmwareUpdating": "Firmware wird aktualisiert. Bitte warten...", + "confirmFirmwareUpdate": "Möchten Sie die Firmware wirklich aktualisieren?", + "batteryServiceStopWarning": "Diese Aktion erfordert, dass der Batteriedienst ca. 10-15 Minuten gestoppt wird.", + "downloadingBatteryLog": "Das Batterieprotokoll wird heruntergeladen. Es wird im Downloads-Ordner gespeichert. Bitte warten...", + "confirmBatteryLogDownload": "Möchten Sie das Batterieprotokoll wirklich herunterladen?", + "downloadBatteryLogFailed": "Herunterladen des Batterieprotokolls fehlgeschlagen, bitte versuchen Sie es erneut.", + "noReportData": "Keine Berichtsdaten gefunden." } \ No newline at end of file diff --git a/typescript/frontend-marios2/src/lang/en.json b/typescript/frontend-marios2/src/lang/en.json index 71756e085..a659b6aea 100644 --- a/typescript/frontend-marios2/src/lang/en.json +++ b/typescript/frontend-marios2/src/lang/en.json @@ -5,6 +5,9 @@ "customerName": "Customer name", "english": "English", "german": "German", + "french": "French", + "italian": "Italian", + "language": "Language", "installation": "Installation", "location": "Location", "log": "Log", @@ -70,5 +73,44 @@ "unableToGrantAccess": "Unable to grant access to: ", "unableToLoadData": "Unable to load data", "unableToRevokeAccess": "Unable to revoke access", - "revokedAccessFromUser": "Revoked access from user: " + "revokedAccessFromUser": "Revoked access from user: ", + "Show Errors": "Show Errors", + "Show Warnings": "Show Warnings", + "lastSeen": "Last seen", + "reportTitle": "Weekly Performance Report", + "weeklyInsights": "Weekly Insights", + "weeklySavings": "Your Savings This Week", + "solarEnergyUsed": "Solar Energy Used", + "solarStayedHome": "of your solar stayed at home", + "estMoneySaved": "Est. Money Saved", + "atCHFRate": "at 0.27 CHF/kWh avg.", + "solarCoverage": "Solar Coverage", + "fromSolarSub": "of consumption from solar", + "batteryEfficiency": "Battery Efficiency", + "batteryEffSub": "energy out vs energy in", + "weeklySummary": "Weekly Summary", + "metric": "Metric", + "thisWeek": "This Week", + "change": "Change", + "pvProduction": "PV Production", + "consumption": "Consumption", + "gridImport": "Grid Import", + "gridExport": "Grid Export", + "batteryInOut": "Battery In / Out", + "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.", + "ok": "Ok", + "grantedAccessToUser": "Granted access to user {name}", + "proceed": "Proceed", + "firmwareUpdating": "The firmware is getting updated. Please wait...", + "confirmFirmwareUpdate": "Do you really want to update the firmware?", + "batteryServiceStopWarning": "This action requires the battery service to be stopped for around 10-15 minutes.", + "downloadingBatteryLog": "The battery log is getting downloaded. It will be saved in the Downloads folder. Please wait...", + "confirmBatteryLogDownload": "Do you really want to download battery log?", + "downloadBatteryLogFailed": "Download battery log failed, please try again.", + "noReportData": "No report data found." } diff --git a/typescript/frontend-marios2/src/lang/fr.json b/typescript/frontend-marios2/src/lang/fr.json index 4e5487359..16088f576 100644 --- a/typescript/frontend-marios2/src/lang/fr.json +++ b/typescript/frontend-marios2/src/lang/fr.json @@ -11,7 +11,10 @@ "english": "Anglais", "error": "Erreur", "folder": "Dossier", + "french": "Français", "german": "Allemand", + "italian": "Italien", + "language": "Langue", "overview": "Aperçu", "manage": "Gestion des accès", "configuration": "Configuration", @@ -19,7 +22,6 @@ "apply_changes": "Appliquer", "delete_user": "Supprimer l'utilisateur", "installation_name_simple": "Nom de l'installation: ", - "language": "Langue", "minimum_soc": "Soc minimum", "calibration_charge_forced": "Charge d'étalonnage forcée", "grid_set_point": "Point de consigne de grid", @@ -41,7 +43,7 @@ "lastWeek": "La semaine dernière", "location": "Localité", "log": "Journal", - "logout": "Fermer las session", + "logout": "Fermer la session", "makeASelection": "Veuillez faire une sélection à gauche", "manageAccess": "Gérer l'accès", "move": "Déplacer", @@ -63,7 +65,7 @@ "status": "Statut", "live": "Diffusion en direct", "deleteInstallation": "Supprimer l'installation", - "errorOccured": "Une erreur s’est produite", + "errorOccured": "Une erreur s'est produite", "successfullyUpdated": "Mise à jour réussie", "grantAccess": "Accorder l'accès", "UserswithDirectAccess": "Utilisateurs avec accès direct", @@ -83,5 +85,44 @@ "unableToGrantAccess": "Impossible d'accorder l'accès à", "unableToLoadData": "Impossible de charger les données", "unableToRevokeAccess": "Impossible de révoquer l'accès", - "revokedAccessFromUser": "Accès révoqué de l'utilisateur" -} \ No newline at end of file + "revokedAccessFromUser": "Accès révoqué de l'utilisateur", + "Show Errors": "Afficher les erreurs", + "Show Warnings": "Afficher les avertissements", + "lastSeen": "Dernière connexion", + "reportTitle": "Rapport de performance hebdomadaire", + "weeklyInsights": "Aperçus hebdomadaires", + "weeklySavings": "Vos économies cette semaine", + "solarEnergyUsed": "Énergie solaire utilisée", + "solarStayedHome": "de votre solaire est resté à la maison", + "estMoneySaved": "Économies estimées", + "atCHFRate": "à 0,27 CHF/kWh moy.", + "solarCoverage": "Couverture solaire", + "fromSolarSub": "de la consommation provenant du solaire", + "batteryEfficiency": "Efficacité de la batterie", + "batteryEffSub": "énergie sortante vs énergie entrante", + "weeklySummary": "Résumé hebdomadaire", + "metric": "Métrique", + "thisWeek": "Cette semaine", + "change": "Variation", + "pvProduction": "Production PV", + "consumption": "Consommation", + "gridImport": "Importation réseau", + "gridExport": "Exportation réseau", + "batteryInOut": "Batterie Entrée / Sortie", + "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.", + "ok": "Ok", + "grantedAccessToUser": "Accès accordé à l'utilisateur {name}", + "proceed": "Continuer", + "firmwareUpdating": "Le firmware est en cours de mise à jour. Veuillez patienter...", + "confirmFirmwareUpdate": "Voulez-vous vraiment mettre à jour le firmware?", + "batteryServiceStopWarning": "Cette action nécessite l'arrêt du service batterie pendant environ 10-15 minutes.", + "downloadingBatteryLog": "Le journal de la batterie est en cours de téléchargement. Il sera enregistré dans le dossier Téléchargements. Veuillez patienter...", + "confirmBatteryLogDownload": "Voulez-vous vraiment télécharger le journal de la batterie?", + "downloadBatteryLogFailed": "Échec du téléchargement du journal de la batterie, veuillez réessayer.", + "noReportData": "Aucune donnée de rapport trouvée." +} diff --git a/typescript/frontend-marios2/src/lang/it.json b/typescript/frontend-marios2/src/lang/it.json new file mode 100644 index 000000000..c1a0c7f41 --- /dev/null +++ b/typescript/frontend-marios2/src/lang/it.json @@ -0,0 +1,139 @@ +{ + "allInstallations": "Tutte le installazioni", + "applyChanges": "Applica modifiche", + "country": "Paese", + "customerName": "Nome cliente", + "english": "Inglese", + "german": "Tedesco", + "french": "Francese", + "italian": "Italiano", + "language": "Lingua", + "installation": "Installazione", + "location": "Posizione", + "log": "Registro", + "orderNumbers": "Numeri d'ordine", + "region": "Regione", + "search": "Cerca", + "users": "Utenti", + "logout": "Disconnetti", + "updatedSuccessfully": "Aggiornamento riuscito", + "groups": "Gruppi", + "group": "Gruppo", + "folder": "Cartella", + "updateFolderErrorMessage": "Impossibile aggiornare la cartella, si è verificato un errore", + "Information": "Informazioni", + "addNewChild": "Aggiungi nuovo figlio", + "addNewDialogButton": "Aggiungi nuovo pulsante di dialogo", + "addUser": "Crea utente", + "createNewFolder": "Crea nuova cartella", + "createNewUser": "Crea nuovo utente", + "email": "Email", + "error": "Errore", + "groupTabs": "Schede gruppo", + "groupTree": "Albero gruppo", + "information": "Informazioni", + "inheritedAccess": "Accesso ereditato da", + "installationTabs": "Schede installazione", + "installations": "Installazioni", + "lastWeek": "Settimana scorsa", + "makeASelection": "Effettuare una selezione a sinistra", + "manageAccess": "Gestisci accesso", + "move": "Sposta", + "moveTo": "Sposta in", + "moveTree": "Sposta albero", + "name": "Nome", + "navigationTabs": "Schede di navigazione", + "requiredLocation": "La posizione è obbligatoria", + "requiredName": "Il nome è obbligatorio", + "requiredRegion": "La regione è obbligatoria", + "requiredOrderNumber": "Numero d'ordine obbligatorio", + "submit": "Invia", + "user": "Utente", + "userTabs": "Schede utente", + "status": "Stato", + "live": "Vista in diretta", + "deleteInstallation": "Elimina installazione", + "errorOccured": "Si è verificato un errore", + "successfullyUpdated": "Aggiornamento riuscito", + "grantAccess": "Concedi accesso", + "UserswithDirectAccess": "Utenti con accesso diretto", + "UserswithInheritedAccess": "Utenti con accesso ereditato", + "noerrors": "Non ci sono errori", + "nowarnings": "Non ci sono avvisi", + "noUsersWithDirectAccessToThis": "Nessun utente con accesso diretto a questo", + "selectUsers": "Seleziona utenti", + "cancel": "Annulla", + "addNewFolder": "Aggiungi nuova cartella", + "addNewInstallation": "Aggiungi nuova installazione", + "deleteFolder": "Elimina cartella", + "grantAccessToFolders": "Concedi accesso alle cartelle", + "grantAccessToInstallations": "Concedi accesso alle installazioni", + "cannotloadloggingdata": "Impossibile caricare i dati di registro", + "grantedAccessToUsers": "Accesso concesso agli utenti: ", + "unableToGrantAccess": "Impossibile concedere l'accesso a: ", + "unableToLoadData": "Impossibile caricare i dati", + "unableToRevokeAccess": "Impossibile revocare l'accesso", + "revokedAccessFromUser": "Accesso revocato all'utente: ", + "alarms": "Allarmi", + "overview": "Panoramica", + "manage": "Gestione accessi", + "configuration": "Configurazione", + "installation_name_simple": "Nome installazione: ", + "installation_name": "Nome installazione", + "minimum_soc": "SoC minimo", + "calibration_charge_forced": "Carica di calibrazione forzata", + "grid_set_point": "Punto di riferimento rete", + "Installed_Power_DC1010": "Potenza installata DC1010", + "Maximum_Discharge_Power": "Potenza massima di scarica", + "Number_of_Batteries": "Numero di batterie", + "24_hours": "24 ore", + "lastweek": "Settimana scorsa", + "lastmonth": "Mese scorso", + "apply_changes": "Applica modifiche", + "delete_user": "Elimina utente", + "battery_temperature": "Temperatura batteria", + "pv_production": "Produzione fotovoltaica", + "grid_power": "Potenza di rete", + "battery_power": "Potenza batteria", + "dc_voltage": "Tensione bus DC", + "battery_soc": "Stato di carica (SOC)", + "Show Errors": "Mostra errori", + "Show Warnings": "Mostra avvisi", + "lastSeen": "Ultima visualizzazione", + "reportTitle": "Rapporto settimanale sulle prestazioni", + "weeklyInsights": "Approfondimenti settimanali", + "weeklySavings": "I tuoi risparmi questa settimana", + "solarEnergyUsed": "Energia solare utilizzata", + "solarStayedHome": "della tua energia solare è rimasta a casa", + "estMoneySaved": "Risparmio stimato", + "atCHFRate": "a 0,27 CHF/kWh media", + "solarCoverage": "Copertura solare", + "fromSolarSub": "del consumo da fonte solare", + "batteryEfficiency": "Efficienza della batteria", + "batteryEffSub": "energia in uscita vs energia in entrata", + "weeklySummary": "Riepilogo settimanale", + "metric": "Metrica", + "thisWeek": "Questa settimana", + "change": "Variazione", + "pvProduction": "Produzione FV", + "consumption": "Consumo", + "gridImport": "Importazione rete", + "gridExport": "Esportazione rete", + "batteryInOut": "Batteria Entrata / Uscita", + "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.", + "ok": "Ok", + "grantedAccessToUser": "Accesso concesso all'utente {name}", + "proceed": "Procedi", + "firmwareUpdating": "Il firmware è in fase di aggiornamento. Attendere prego...", + "confirmFirmwareUpdate": "Vuoi davvero aggiornare il firmware?", + "batteryServiceStopWarning": "Questa azione richiede l'interruzione del servizio batteria per circa 10-15 minuti.", + "downloadingBatteryLog": "Il registro della batteria è in fase di download. Verrà salvato nella cartella Download. Attendere prego...", + "confirmBatteryLogDownload": "Vuoi davvero scaricare il registro della batteria?", + "downloadBatteryLogFailed": "Download del registro della batteria fallito, riprovare.", + "noReportData": "Nessun dato del rapporto trovato." +} diff --git a/typescript/frontend-marios2/src/layouts/SidebarLayout/Header/Menu/index.tsx b/typescript/frontend-marios2/src/layouts/SidebarLayout/Header/Menu/index.tsx index 743301461..32ceaf848 100644 --- a/typescript/frontend-marios2/src/layouts/SidebarLayout/Header/Menu/index.tsx +++ b/typescript/frontend-marios2/src/layouts/SidebarLayout/Header/Menu/index.tsx @@ -141,10 +141,13 @@ function HeaderMenu(props: HeaderButtonsProps) { English handleLanguageSelect('de')}> - German + Deutsch handleLanguageSelect('fr')}> - French + Français + + handleLanguageSelect('it')}> + Italiano
Metric{s.Metric}
-
Your Savings This Week
+
{s.SavingsHeader}
- {SavingsBox("Solar Energy Used", $"{r.CurrentWeek.TotalPvProduction - r.CurrentWeek.TotalGridExport:F1} kWh", "stayed at home", "#27ae60")} - {SavingsBox("Est. Money Saved", $"~{(r.CurrentWeek.TotalPvProduction - r.CurrentWeek.TotalGridExport) * 0.27:F1} CHF", "at 0.27 CHF/kWh", "#2980b9")} - {SavingsBox("Solar Coverage", $"{r.SelfSufficiencyPercent:F0}%", "from solar", "#8e44ad")} - {SavingsBox("Battery Eff.", $"{r.BatteryEfficiencyPercent:F0}%", "out vs in", "#e67e22")} + {SavingsBox(s.SolarEnergyUsed, $"{r.CurrentWeek.TotalPvProduction - r.CurrentWeek.TotalGridExport:F1} kWh", s.StayedAtHome, "#27ae60")} + {SavingsBox(s.EstMoneySaved, $"~{(r.CurrentWeek.TotalPvProduction - r.CurrentWeek.TotalGridExport) * 0.27:F1} CHF", s.AtRate, "#2980b9")} + {SavingsBox(s.SolarCoverage, $"{r.SelfSufficiencyPercent:F0}%", s.FromSolar, "#8e44ad")} + {SavingsBox(s.BatteryEff, $"{r.BatteryEfficiencyPercent:F0}%", s.OutVsIn, "#e67e22")}
-
Daily Breakdown (kWh)
+
{s.DailyBreakdown}
- + - - - - + + + + {dailyRows}
Day{s.Day} PVLoadGrid InGrid OutBatt In/Out{s.Load}{s.GridIn}{s.GridOut}{s.BattInOut}
@@ -211,7 +372,7 @@ public static class ReportEmailService
- Generated by Inesco Energy Monitor Platform · Powered by Mistral AI + {s.Footer}
MetricThis WeekLast WeekChange
PV Production {cur.totalPvProduction.toFixed(1)} kWh{prev.totalPvProduction.toFixed(1)} kWh{formatChange(report.pvChangePercent)}
Consumption {cur.totalConsumption.toFixed(1)} kWh{prev.totalConsumption.toFixed(1)} kWh{formatChange(report.consumptionChangePercent)}
Grid Import {cur.totalGridImport.toFixed(1)} kWh{prev.totalGridImport.toFixed(1)} kWh{formatChange(report.gridImportChangePercent)}
Grid Export {cur.totalGridExport.toFixed(1)} kWh{prev.totalGridExport.toFixed(1)} kWh
Battery In / Out {cur.totalBatteryCharged.toFixed(1)} / {cur.totalBatteryDischarged.toFixed(1)} kWh{prev.totalBatteryCharged.toFixed(1)} / {prev.totalBatteryDischarged.toFixed(1)} kWh