using Microsoft.EntityFrameworkCore; using timetracker.Shared; namespace timetracker.Data; public class TimetrackerService(IDbContextFactory factory) : ITimetrackerService { public async Task> GetWeekAsync(int userId, DateOnly monday) { await using var db = await factory.CreateDbContextAsync(); return await db.WorkDays .Include(w => w.Breaks) .Where(w => w.UserId == userId && w.Date >= monday && w.Date < monday.AddDays(7)) .OrderBy(w => w.Date) .ToListAsync(); } public async Task UpsertWorkDayAsync(WorkDay workDay) { await using var db = await factory.CreateDbContextAsync(); var existing = await db.WorkDays .Include(w => w.Breaks) .FirstOrDefaultAsync(w => w.UserId == workDay.UserId && w.Date == workDay.Date); if (existing == null) { workDay.Id = 0; foreach (var b in workDay.Breaks) b.Id = 0; db.WorkDays.Add(workDay); } else { existing.StartTime = workDay.StartTime; existing.EndTime = workDay.EndTime; db.BreakEntries.RemoveRange(existing.Breaks); existing.Breaks.Clear(); foreach (var b in workDay.Breaks) existing.Breaks.Add(new BreakEntry { WorkDayId = existing.Id, StartTime = b.StartTime, EndTime = b.EndTime }); } await db.SaveChangesAsync(); } public async Task GetSettingsAsync(int userId) { await using var db = await factory.CreateDbContextAsync(); return await db.AppSettings.FirstOrDefaultAsync(s => s.UserId == userId) ?? new AppSettings { UserId = userId }; } public async Task SaveSettingsAsync(AppSettings settings) { await using var db = await factory.CreateDbContextAsync(); var existing = await db.AppSettings.FirstOrDefaultAsync(s => s.UserId == settings.UserId); if (existing == null) db.AppSettings.Add(settings); else { existing.DailyTargetHours = settings.DailyTargetHours; existing.MinimumBreakMinutes = settings.MinimumBreakMinutes; existing.VacationDaysPerYear = settings.VacationDaysPerYear; existing.WorkMonday = settings.WorkMonday; existing.WorkTuesday = settings.WorkTuesday; existing.WorkWednesday = settings.WorkWednesday; existing.WorkThursday = settings.WorkThursday; existing.WorkFriday = settings.WorkFriday; existing.WorkSaturday = settings.WorkSaturday; existing.WorkSunday = settings.WorkSunday; } await db.SaveChangesAsync(); } // ── Urlaub ──────────────────────────────────────────────────────────── public async Task> GetVacationDaysAsync(int userId, int year) { await using var db = await factory.CreateDbContextAsync(); return await db.VacationDays .Where(v => v.UserId == userId && v.Date.Year == year) .OrderBy(v => v.Date) .ToListAsync(); } public async Task AddVacationDayAsync(VacationDay vacationDay) { await using var db = await factory.CreateDbContextAsync(); var exists = await db.VacationDays.AnyAsync(v => v.UserId == vacationDay.UserId && v.Date == vacationDay.Date); if (!exists) { vacationDay.Id = 0; db.VacationDays.Add(vacationDay); await db.SaveChangesAsync(); } } public async Task RemoveVacationDayAsync(int userId, int id) { await using var db = await factory.CreateDbContextAsync(); var v = await db.VacationDays.FirstOrDefaultAsync(v => v.Id == id && v.UserId == userId); if (v != null) { db.VacationDays.Remove(v); await db.SaveChangesAsync(); } } // ── Gleitzeitkonto ─────────────────────────────────────────────────── public async Task GetTotalOvertimeAsync(int userId, AppSettings settings) { await using var db = await factory.CreateDbContextAsync(); // 1. Finde das Startdatum für die Berechnung var firstDay = await db.WorkDays .Where(w => w.UserId == userId) .OrderBy(w => w.Date) .Select(w => (DateOnly?)w.Date) .FirstOrDefaultAsync(); if (firstDay == null && !settings.FlexTimeStartDate.HasValue) return TimeSpan.FromHours(settings.FlexTimeStartingBalanceHours); var startDate = settings.FlexTimeStartDate ?? firstDay!.Value; var endDate = DateOnly.FromDateTime(DateTime.Today); if (startDate > endDate) return TimeSpan.FromHours(settings.FlexTimeStartingBalanceHours); // 2. Lade alle erfassten Arbeitstage in diesem Zeitraum var workDays = await db.WorkDays .Include(w => w.Breaks) .Where(w => w.UserId == userId && w.Date >= startDate && w.Date <= endDate) .ToDictionaryAsync(w => w.Date); // 3. Lade alle Feiertage in diesem Zeitraum var holidaysList = await db.PublicHolidays .Where(h => h.Date >= startDate && h.Date <= endDate) .ToListAsync(); var state = settings.GermanState; var holidaySet = holidaysList .Where(h => string.IsNullOrEmpty(h.Counties) || (!string.IsNullOrEmpty(state) && h.Counties.Split(',').Contains(state))) .Select(h => h.Date) .ToHashSet(); // 4. Lade alle Urlaubstage in diesem Zeitraum var vacationSet = await db.VacationDays .Where(v => v.UserId == userId && v.Date >= startDate && v.Date <= endDate) .Select(v => v.Date) .ToHashSetAsync(); double totalOvertimeHours = settings.FlexTimeStartingBalanceHours; for (var date = startDate; date <= endDate; date = date.AddDays(1)) { bool isWorkDay = settings.IsWorkDay(date.DayOfWeek); bool isHoliday = holidaySet.Contains(date); bool isVacation = vacationSet.Contains(date); // Sollzeit gilt nur an regulären Arbeitstagen, die weder Feiertag noch Urlaub sind double target = (isWorkDay && !isHoliday && !isVacation) ? settings.DailyTargetHours : 0.0; double actual = 0.0; if (workDays.TryGetValue(date, out var wd) && wd.StartTime != null && wd.EndTime != null) { var gross = wd.EndTime.Value.ToTimeSpan() - wd.StartTime.Value.ToTimeSpan(); if (gross > TimeSpan.Zero) { var breakTotal = wd.Breaks .Where(b => b.StartTime.HasValue && b.EndTime.HasValue && b.EndTime > b.StartTime) .Aggregate(TimeSpan.Zero, (s, b) => s + (b.EndTime!.Value.ToTimeSpan() - b.StartTime!.Value.ToTimeSpan())); var minMinutes = gross.TotalHours >= 9.0 ? 45 : 30; var minBreak = TimeSpan.FromMinutes(Math.Max(minMinutes, settings.MinimumBreakMinutes)); var finalBreak = breakTotal > minBreak ? breakTotal : minBreak; actual = (gross - finalBreak).TotalHours; if (actual < 0) actual = 0.0; } } totalOvertimeHours += (actual - target); } return TimeSpan.FromHours(totalOvertimeHours); } // ── Monatsübersicht ─────────────────────────────────────────────────── public async Task> GetMonthAsync(int userId, int year, int month) { await using var db = await factory.CreateDbContextAsync(); var from = new DateOnly(year, month, 1); var to = from.AddMonths(1); return await db.WorkDays .Include(w => w.Breaks) .Where(w => w.UserId == userId && w.Date >= from && w.Date < to) .OrderBy(w => w.Date) .ToListAsync(); } // ── Dashboard ───────────────────────────────────────────────────────── public async Task GetDashboardStatsAsync(int userId, int year, int month) { await using var db = await factory.CreateDbContextAsync(); var startDate = new DateOnly(year, month, 1); var endDate = startDate.AddMonths(1).AddDays(-1); var workDays = await db.WorkDays .Include(w => w.Breaks) .Where(w => w.UserId == userId && w.Date >= startDate && w.Date <= endDate) .ToListAsync(); var stats = new DashboardStatsVm(); if (workDays.Any(w => w.StartTime.HasValue)) { var earliest = workDays.Where(w => w.StartTime.HasValue).Min(w => w.StartTime!.Value); stats.EarliestStart = earliest.ToTimeSpan(); } if (workDays.Any(w => w.EndTime.HasValue)) { var latest = workDays.Where(w => w.EndTime.HasValue).Max(w => w.EndTime!.Value); stats.LatestEnd = latest.ToTimeSpan(); } var daysWithBreaks = workDays.Where(w => w.Breaks.Any(b => b.StartTime.HasValue && b.EndTime.HasValue)).ToList(); if (daysWithBreaks.Any()) { var totalBreakTicks = daysWithBreaks.Sum(w => w.Breaks.Where(b => b.StartTime.HasValue && b.EndTime.HasValue) .Sum(b => (b.EndTime!.Value - b.StartTime!.Value).Ticks)); stats.AverageBreak = TimeSpan.FromTicks(totalBreakTicks / daysWithBreaks.Count); } var allUserDays = await db.WorkDays .Where(w => w.UserId == userId && w.StartTime.HasValue && w.EndTime.HasValue) .OrderBy(w => w.Date) .Select(w => w.Date) .ToListAsync(); if (allUserDays.Any()) { int currentStreak = 1; int maxStreak = 1; int tempStreak = 1; for (int i = 1; i < allUserDays.Count; i++) { if (allUserDays[i].DayNumber == allUserDays[i - 1].DayNumber + 1) { tempStreak++; } else if (allUserDays[i].DayNumber > allUserDays[i - 1].DayNumber + 1) { if (tempStreak > maxStreak) maxStreak = tempStreak; tempStreak = 1; } } if (tempStreak > maxStreak) maxStreak = tempStreak; stats.LongestStreak = maxStreak; var today = DateOnly.FromDateTime(DateTime.Today); var yesterday = today.AddDays(-1); if (allUserDays.Last() == today || allUserDays.Last() == yesterday) { stats.CurrentStreak = tempStreak; } else { stats.CurrentStreak = 0; } } return stats; } public async Task GetDashboardChartDataAsync(int userId, int year) { await using var db = await factory.CreateDbContextAsync(); var chartData = new DashboardChartDataVm(); var settings = await GetSettingsAsync(userId); var workDaysThisYear = await db.WorkDays .Include(w => w.Breaks) .Where(w => w.UserId == userId && w.Date.Year == year) .ToListAsync(); var vacationsThisYear = await db.VacationDays .Where(v => v.UserId == userId && v.Date.Year == year) .ToListAsync(); var holidaysThisYear = await db.PublicHolidays .Where(h => h.Date.Year == year && h.Counties.Contains(settings.GermanState)) .ToListAsync(); chartData.WorkDaysCount = workDaysThisYear.Count(w => w.StartTime.HasValue); chartData.VacationDaysCount = vacationsThisYear.Count; chartData.PublicHolidaysCount = holidaysThisYear.Count(h => settings.IsWorkDay(h.Date.DayOfWeek)); for (int month = 1; month <= 12; month++) { var daysInMonth = workDaysThisYear.Where(w => w.Date.Month == month).ToList(); if (!daysInMonth.Any(d => d.StartTime.HasValue)) continue; double monthOvertime = 0; foreach (var day in daysInMonth) { if (!day.StartTime.HasValue || !day.EndTime.HasValue) continue; var gross = day.EndTime.Value - day.StartTime.Value; var breakTicks = day.Breaks .Where(b => b.StartTime.HasValue && b.EndTime.HasValue) .Sum(b => (b.EndTime!.Value - b.StartTime!.Value).Ticks); var enteredBreak = TimeSpan.FromTicks(breakTicks); int minMinutes = gross.TotalHours >= 9.0 ? 45 : 30; int finalMin = Math.Max(minMinutes, settings.MinimumBreakMinutes); var minBreak = TimeSpan.FromMinutes(finalMin); var effectiveBreak = enteredBreak > minBreak ? enteredBreak : minBreak; var net = gross - effectiveBreak; var target = TimeSpan.FromHours(settings.DailyTargetHours); var isHoliday = holidaysThisYear.Any(h => h.Date == day.Date); var isVacation = vacationsThisYear.Any(v => v.Date == day.Date); var isWorkDay = settings.IsWorkDay(day.Date.DayOfWeek); if (!isWorkDay || isHoliday || isVacation) target = TimeSpan.Zero; monthOvertime += (net - target).TotalHours; } var monthName = new DateTime(year, month, 1).ToString("MMM", new System.Globalization.CultureInfo("de-DE")); chartData.MonthlyOvertime.Add(new MonthlyOvertimeVm { MonthName = monthName, OvertimeHours = monthOvertime }); } return chartData; } }