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,
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))
{
var isoDate = date.ToString("yyyy-MM-dd");
var fileName = AggregatedJsonParser.ToJsonFileName(date);
var isoDate = date.ToString("yyyy-MM-dd");
// Try local file first
var localPath = Path.Combine(jsonDir, fileName);
String? content = System.IO.File.Exists(localPath) ? System.IO.File.ReadAllText(localPath) : null;
if (Db.DailyRecordExists(installationId, isoDate))
continue;
// Try S3 if no local file
content ??= AggregatedJsonParser.TryReadFromS3(installation, isoDate)
var content = AggregatedJsonParser.TryReadFromS3(installation, isoDate)
.GetAwaiter().GetResult();
if (content is null) continue;

View File

@ -28,6 +28,11 @@ public class WeeklyReportResponse
public List<DailyEnergyData> DailyData { get; set; } = new();
public BehavioralPattern? Behavior { 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

View File

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

View File

@ -5,7 +5,7 @@ namespace InnovEnergy.App.Backend.Services;
/// <summary>
/// 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
/// IngestDailyData API endpoint.
/// </summary>
@ -14,9 +14,6 @@ public static class DailyIngestionService
private static readonly String TmpReportDir =
Environment.CurrentDirectory + "/tmp_report/";
private static readonly String JsonAggregatedDir =
Environment.CurrentDirectory + "/tmp_report/aggregated/";
private static Timer? _dailyTimer;
/// <summary>
@ -53,7 +50,7 @@ public static class DailyIngestionService
Console.WriteLine($"[DailyIngestion] Starting ingestion for all SodioHome 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();
foreach (var installation in installations)
@ -72,7 +69,7 @@ public static class DailyIngestionService
}
/// <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.
/// JSON provides recent data; xlsx provides historical data.
/// </summary>
@ -82,47 +79,60 @@ public static class DailyIngestionService
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 newHourly = 0;
var jsonDir = Path.Combine(JsonAggregatedDir, installationId.ToString());
// Collect JSON content from local files
var jsonFiles = Directory.Exists(jsonDir)
? Directory.GetFiles(jsonDir, "*.json")
: Array.Empty<String>();
foreach (var jsonPath in jsonFiles.OrderBy(f => f))
for (var date = fromDate; date <= toDate; date = date.AddDays(1))
{
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);
newDaily += d;
newHourly += h;
}
// Also try S3 for recent days (yesterday + today) if no local files found
if (jsonFiles.Length == 0)
if (newDaily > 0 || newHourly > 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);
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++)
{
var installation = Db.GetInstallationById(installationId);
if (installation is not null)
{
for (var daysBack = 0; daysBack <= 1; daysBack++)
{
var date = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-daysBack));
var isoDate = date.ToString("yyyy-MM-dd");
var date = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-daysBack));
var isoDate = date.ToString("yyyy-MM-dd");
if (Db.DailyRecordExists(installationId, isoDate))
continue;
if (Db.DailyRecordExists(installationId, isoDate))
continue;
var content = await AggregatedJsonParser.TryReadFromS3(installation, isoDate);
if (content is null) continue;
var content = await AggregatedJsonParser.TryReadFromS3(installation, isoDate);
if (content is null) continue;
var (d, h) = IngestJsonContent(installationId, content);
newDaily += d;
newHourly += h;
}
}
var (d, h) = IngestJsonContent(installationId, content);
newDaily += d;
newHourly += h;
}
if (newDaily > 0 || newHourly > 0)

View File

@ -9,7 +9,7 @@ public static class ReportAggregationService
{
private static Timer? _monthEndTimer;
private static Timer? _yearEndTimer;
// private static Timer? _sundayReportTimer;
private static Timer? _weeklyReportTimer;
private const Double ElectricityPriceCHF = 0.39;
private static readonly String MistralUrl = "https://api.mistral.ai/v1/chat/completions";
@ -18,17 +18,42 @@ public static class ReportAggregationService
public static void StartScheduler()
{
// ScheduleSundayWeeklyReport();
ScheduleWeeklyReportJob();
ScheduleMonthEndJob();
ScheduleYearEndJob();
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()
{
// Run daily at 02:00, but only act on the 1st of the month
var now = DateTime.Now;
var next = now.Date.AddHours(2);
// Run daily at 04:00 UTC, but only act on the 1st of the month
var now = DateTime.UtcNow;
var next = now.Date.AddHours(4);
if (now >= next) next = next.AddDays(1);
_monthEndTimer = new Timer(
@ -36,7 +61,7 @@ public static class ReportAggregationService
{
try
{
if (DateTime.Now.Day == 1)
if (DateTime.UtcNow.Day == 1)
RunMonthEndAggregation().GetAwaiter().GetResult();
}
catch (Exception ex)
@ -46,14 +71,14 @@ public static class ReportAggregationService
},
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()
{
// Run daily at 03:00, but only act on Jan 2nd
var now = DateTime.Now;
var next = now.Date.AddHours(3);
// Run daily at 05:00 UTC, but only act on Jan 2nd
var now = DateTime.UtcNow;
var next = now.Date.AddHours(5);
if (now >= next) next = next.AddDays(1);
_yearEndTimer = new Timer(
@ -61,7 +86,7 @@ public static class ReportAggregationService
{
try
{
if (DateTime.Now.Month == 1 && DateTime.Now.Day == 2)
if (DateTime.UtcNow.Month == 1 && DateTime.UtcNow.Day == 2)
RunYearEndAggregation().GetAwaiter().GetResult();
}
catch (Exception ex)
@ -71,99 +96,40 @@ public static class ReportAggregationService
},
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 ─────────────────────────────
// 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.
// ── Weekly Report Auto-Generation ─────────────────────────────
// private static void ScheduleSundayWeeklyReport()
// {
// // Calculate delay until next Sunday 06:00
// var now = DateTime.Now;
// var daysUntil = ((int)DayOfWeek.Sunday - (int)now.DayOfWeek + 7) % 7;
// var nextSunday = now.Date.AddDays(daysUntil == 0 && now.Hour >= 6 ? 7 : daysUntil).AddHours(6);
//
// _sundayReportTimer = new Timer(
// _ =>
// {
// try
// {
// if (DateTime.Now.DayOfWeek == DayOfWeek.Sunday)
// RunSundayWeeklyReports().GetAwaiter().GetResult();
// }
// catch (Exception ex)
// {
// Console.Error.WriteLine($"[ReportAggregation] Sunday report error: {ex.Message}");
// }
// },
// null, nextSunday - now, TimeSpan.FromDays(7));
//
// Console.WriteLine($"[ReportAggregation] Sunday weekly report scheduled. Next run: {nextSunday:yyyy-MM-dd HH:mm}");
// }
//
// private static async Task RunSundayWeeklyReports()
// {
// Console.WriteLine("[ReportAggregation] Running Sunday weekly report generation...");
//
// // Find all SodiStoreHome installations
// var installations = Db.Installations
// .Where(i => i.Product == (int)ProductType.SodioHome)
// .ToList();
//
// foreach (var installation in installations)
// {
// try
// {
// // Generate the weekly report (covers last SundaySaturday)
// var report = await WeeklyReportService.GenerateReportAsync(
// installation.Id, installation.InstallationName, "en");
//
// // Save summary to DB for future monthly aggregation
// SaveWeeklySummary(installation.Id, report);
//
// // Email the report to all users who have access to this installation
// var userIds = Db.InstallationAccess
// .Where(a => a.InstallationId == installation.Id)
// .Select(a => a.UserId)
// .ToList();
//
// foreach (var userId in userIds)
// {
// var user = Db.GetUserById(userId);
// if (user == null || String.IsNullOrWhiteSpace(user.Email))
// continue;
//
// try
// {
// var lang = user.Language ?? "en";
// // Regenerate with user's language if different from "en"
// var localizedReport = lang == "en"
// ? report
// : await WeeklyReportService.GenerateReportAsync(
// installation.Id, installation.InstallationName, lang);
//
// await ReportEmailService.SendReportEmailAsync(localizedReport, user.Email, lang);
// Console.WriteLine($"[ReportAggregation] Weekly report emailed to {user.Email} for installation {installation.Id}");
// }
// catch (Exception emailEx)
// {
// Console.Error.WriteLine($"[ReportAggregation] Failed to email {user.Email}: {emailEx.Message}");
// }
// }
// }
// catch (Exception ex)
// {
// Console.Error.WriteLine($"[ReportAggregation] Failed weekly report for installation {installation.Id}: {ex.Message}");
// }
// }
//
// Console.WriteLine("[ReportAggregation] Sunday weekly report generation complete.");
// }
private static async Task RunWeeklyReportGeneration()
{
Console.WriteLine("[ReportAggregation] Running Monday weekly report generation...");
var installations = Db.Installations
.Where(i => i.Product == (Int32)ProductType.SodioHome && i.Device != 3) // Skip Growatt (device=3)
.ToList();
var generated = 0;
foreach (var installation in installations)
{
try
{
var report = await WeeklyReportService.GenerateReportAsync(
installation.Id, installation.InstallationName, "en");
SaveWeeklySummary(installation.Id, report, "en");
generated++;
Console.WriteLine($"[ReportAggregation] Weekly report generated for installation {installation.Id} ({installation.InstallationName})");
}
catch (Exception ex)
{
Console.Error.WriteLine($"[ReportAggregation] Failed weekly report for installation {installation.Id}: {ex.Message}");
}
}
Console.WriteLine($"[ReportAggregation] Weekly report generation complete. {generated}/{installations.Count} installations processed.");
}
// ── Save Weekly Summary ───────────────────────────────────────────
@ -335,7 +301,7 @@ public static class ReportAggregationService
private static async Task RunMonthEndAggregation()
{
var previousMonth = DateTime.Now.AddMonths(-1);
var previousMonth = DateTime.UtcNow.AddMonths(-1);
var year = previousMonth.Year;
var month = previousMonth.Month;
var first = new DateOnly(year, month, 1);
@ -453,7 +419,7 @@ public static class ReportAggregationService
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}...");

View File

@ -91,7 +91,8 @@ public static class WeeklyReportService
/// Generates a full weekly report for the given installation.
/// Data source priority:
/// 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.
/// Pass weekStartOverride to generate a report for a specific historical week (disables cache).
/// </summary>
@ -121,15 +122,25 @@ public static class WeeklyReportService
var previousWeekDays = Db.GetDailyRecords(installationId, prevMon, prevSun)
.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)
{
// Only parse xlsx files whose date range overlaps the needed weeks
var relevantFiles = GetRelevantXlsxFiles(installationId, prevMon, curSun);
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();
currentWeekDays = allDaysParsed
.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}. " +
"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)
.Select(ToHourlyEnergyData).ToList();
// 3b. Fallback: if DB empty, parse hourly data from xlsx
// 4b. xlsx fallback for hourly data
if (currentHourlyData.Count == 0)
{
var relevantFiles = GetRelevantXlsxFiles(installationId, curMon, curSun);
@ -264,11 +276,24 @@ public static class WeeklyReportService
selfSufficiency, totalEnergySaved, totalSavingsCHF,
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
{
InstallationName = installationName,
PeriodStart = currentWeekDays.First().Date,
PeriodEnd = currentWeekDays.Last().Date,
PeriodStart = weekStart?.ToString("yyyy-MM-dd") ?? currentWeekDays.First().Date,
PeriodEnd = weekEnd?.ToString("yyyy-MM-dd") ?? currentWeekDays.Last().Date,
CurrentWeek = currentSummary,
PreviousWeek = previousSummary,
TotalEnergySaved = totalEnergySaved,
@ -284,6 +309,9 @@ public static class WeeklyReportService
DailyData = currentWeekDays,
Behavior = behavior,
AiInsight = aiInsight,
DaysAvailable = currentWeekDays.Count,
DaysExpected = 7,
MissingDates = missingDates,
};
}

View File

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

View File

@ -607,14 +607,16 @@ function SodioHomeInstallation(props: singleInstallationProps) {
}
/>
<Route
path={routes.report}
element={
<WeeklyReport
installationId={props.current_installation.id}
/>
}
/>
{props.current_installation.device !== 3 && (
<Route
path={routes.report}
element={
<WeeklyReport
installationId={props.current_installation.id}
/>
}
/>
)}
<Route
path={'*'}

View File

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

View File

@ -231,6 +231,13 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
currentTab != 'tree' &&
!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
? [
{
@ -409,7 +416,9 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
textColor="primary"
indicatorColor="primary"
>
{tabs.map((tab) => (
{tabs
.filter((tab) => !(isGrowatt && tab.value === 'report'))
.map((tab) => (
<Tab
key={tab.value}
value={tab.value}
@ -476,7 +485,9 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
textColor="primary"
indicatorColor="primary"
>
{singleInstallationTabs.map((tab) => (
{singleInstallationTabs
.filter((tab) => !(isGrowatt && tab.value === 'report'))
.map((tab) => (
<Tab
key={tab.value}
value={tab.value}

View File

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

View File

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

View File

@ -95,6 +95,7 @@
"lastSeen": "Dernière connexion",
"reportTitle": "Rapport de performance hebdomadaire",
"weeklyInsights": "Aperçus hebdomadaires",
"missingDaysWarning": "Données disponibles pour {available}/{expected} jours. Manquants : {dates}",
"weeklySavings": "Vos économies cette semaine",
"solarEnergyUsed": "Énergie économisée",
"solarStayedHome": "solaire + batterie, non achetée au réseau",
@ -137,10 +138,9 @@
"dailyTab": "Quotidien",
"dailyReportTitle": "Résumé énergétique quotidien",
"dailySummary": "Résumé du jour",
"selectDate": "Sélectionner la date",
"noDataForDate": "Aucune donnée disponible pour la date sélectionnée.",
"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",
"batteryPower": "Puissance batterie",
"batterySoCLabel": "SoC batterie",

View File

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