using LehrerApp.Core.Models; namespace LehrerApp.Core.Services; /// Ein aus vielen Wochen-Vorkommen abgeleitetes reguläres WebUntis-Wochenmuster /// (Nutzer-Feedback: "eine fuzzy logic, die erst mal die regulären Fächer meiner ical meinem /// Stundenplan in der App zuordnet"). public sealed class UntisWeeklyPattern { public DayOfWeek Weekday { get; init; } public TimeOnly StartTime { get; init; } public TimeOnly EndTime { get; init; } public string? Summary { get; init; } /// Klassen-Token(s) aus DESCRIPTION, Lehrkraft-Kürzel bereits abgetrennt — bei kombinierten /// Gruppen mehrere Einträge (z.B. ["10a", "10b", "10c", "Gastro"]). public List ClassTokens { get; init; } = []; public int OccurrenceCount { get; init; } } public sealed class UntisSlotMatch { public required UntisWeeklyPattern Pattern { get; init; } /// Erste/primäre Stunde, gesetzt wenn das Muster einen Klassenbezug hat (Unterrichtsstunde) — /// entspricht CoveredPeriods[0]. public int? PeriodNumber { get; init; } /// ALLE Stunden, die dieser WebUntis-Termin überdeckt — bei einer Doppelstunde meldet WebUntis /// EINEN Termin über beide Stundenzeiten hinweg (z.B. 07:50–09:20 für Stunde 1+2), während der /// Stundenplan der App dafür ZWEI TimetableSlot-Einträge (Stunde 1 UND Stunde 2) haben kann. /// Ohne diese Liste würde die zweite Stunde nie als "zugeordnet" gelten, selbst nachdem der /// Nutzer das Muster bestätigt hat (Nutzer-Feedback: "ich habe aber jetzt alles zugeordnet, /// und trotzdem erhalte ich die Warnung, dass 16 Stunden ohne Untis-Zuordnung sind"). public List CoveredPeriods { get; init; } = []; /// Gesetzt, wenn das Muster KEINEN Klassenbezug hat (z.B. Aufsicht/Springstunde) — die Pause /// direkt vor der Startzeit (0 = vor der 1. Stunde), siehe UntisSlotMapping.AfterPeriod. public int? AfterPeriod { get; init; } public bool IsSupervisionCandidate => Pattern.ClassTokens.Count == 0; public Guid? SuggestedGroupId { get; init; } public bool IsConfident { get; init; } } public sealed class UntisMatchResult { public List Matches { get; init; } = []; /// Vorhandene TimetableSlots, zu denen sich kein passendes WebUntis-Wochenmuster finden ließ /// (Nutzer-Feedback: "oder der Stundenplan gar nicht mehr passt"). public List UnmatchedTimetableSlots { get; init; } = []; } /// /// Stufe 1 des WebUntis-Abgleichs (siehe TODO.md): aus einem vollen iCal-Abruf (typischerweise /// ein Schuljahr an Einzelterminen, siehe IcsParser) das reguläre Wochenmuster ableiten und gegen /// den in der App gepflegten Stundenplan (TimetableSlot/LearningGroup) abgleichen. Framework-frei /// wie AttendanceBalanceService — reine Objekte rein, reine Objekte raus, ohne Datenbank- oder /// HTTP-Zugriff, dadurch ohne Mocking testbar. /// public class UntisMatchingService { // Kleine Toleranz gegen Minuten-Rundungsdifferenzen zwischen dem konfigurierten Stundenraster // und den tatsächlichen WebUntis-Zeiten. private const int StartTimeToleranceMinutes = 3; public UntisMatchResult BuildMatches(List events, List groups, List timetableSlots, PeriodScheduleService periodSchedule) { var teacherToken = DetectTeacherToken(events); var patterns = BuildWeeklyPatterns(events, teacherToken); var matches = new List(); foreach (var pattern in patterns) { // Kein Klassenbezug (z.B. Aufsicht/Springstunde, Nutzer-Feedback: "Zwei Termine sind // meine Aufsichten, die nicht zugeordnet werden können") - liegt typischerweise in // einer Pause, nicht auf einer Unterrichtsstunden-Startzeit, deshalb eigener, // grundsätzlich immer auflösbarer Weg statt ResolvePeriodNumber. if (pattern.ClassTokens.Count == 0) { matches.Add(new UntisSlotMatch { Pattern = pattern, AfterPeriod = ResolveAfterPeriod(pattern.StartTime, periodSchedule) }); continue; } var coveredPeriods = ResolveCoveredPeriods(pattern.StartTime, pattern.EndTime, periodSchedule); var periodNumber = coveredPeriods.Count > 0 ? coveredPeriods[0] : ResolvePeriodNumber(pattern.StartTime, periodSchedule); var (groupId, confident) = ResolveGroup(pattern.ClassTokens, periodNumber, groups, timetableSlots); matches.Add(new UntisSlotMatch { Pattern = pattern, PeriodNumber = periodNumber, CoveredPeriods = coveredPeriods, SuggestedGroupId = groupId, IsConfident = confident, }); } var matchedSlotKeys = matches .Where(m => m.SuggestedGroupId is not null) .SelectMany(m => (m.CoveredPeriods.Count > 0 ? m.CoveredPeriods : m.PeriodNumber is { } p ? [p] : []) .Select(period => (m.Pattern.Weekday, period, m.SuggestedGroupId!.Value))) .ToHashSet(); var unmatchedSlots = timetableSlots .Where(s => !matchedSlotKeys.Contains((s.Weekday, s.PeriodNumber, s.GroupId))) .ToList(); return new UntisMatchResult { Matches = matches, UnmatchedTimetableSlots = unmatchedSlots }; } /// Häufigstes letztes Wort in DESCRIPTION über alle Termine — bei einem persönlichen Feed /// konstant das eigene Kürzel, bewusst nicht hartkodiert (siehe Planungsdokument). private static string? DetectTeacherToken(List events) => events .Select(e => LastToken(e.Description)) .Where(t => t is not null) .GroupBy(t => t) .OrderByDescending(g => g.Count()) .Select(g => g.Key) .FirstOrDefault(); private static string? LastToken(string description) { var parts = description.Split(' ', StringSplitOptions.RemoveEmptyEntries); return parts.Length == 0 ? null : parts[^1]; } private static List BuildWeeklyPatterns(List events, string? teacherToken) { var withTokens = events.Select(e => new { Event = e, ClassTokens = ExtractClassTokens(e.Description, teacherToken), }); var patterns = new List(); foreach (var group in withTokens.GroupBy(x => (x.Event.Weekday, x.Event.StartTime, x.Event.EndTime))) { var modal = group .GroupBy(x => (x.Event.Summary, ClassTokens: string.Join(";", x.ClassTokens))) .OrderByDescending(g => g.Count()) .First(); patterns.Add(new UntisWeeklyPattern { Weekday = group.Key.Weekday, StartTime = group.Key.StartTime, EndTime = group.Key.EndTime, Summary = modal.Key.Summary, ClassTokens = modal.Key.ClassTokens.Length == 0 ? [] : modal.Key.ClassTokens.Split(';').ToList(), OccurrenceCount = modal.Count(), }); } return patterns; } // "10a; 10b; 10c; Gastro HED" -> ["10a", "10b", "10c", "Gastro"] (Lehrkraft-Token entfernt). private static List ExtractClassTokens(string description, string? teacherToken) { var withoutTeacher = teacherToken is not null && description.EndsWith(teacherToken) ? description[..^teacherToken.Length].TrimEnd() : description; if (string.IsNullOrWhiteSpace(withoutTeacher)) return []; return withoutTeacher.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(); } private static int? ResolvePeriodNumber(TimeOnly startTime, PeriodScheduleService periodSchedule) { var exact = periodSchedule.Periods.FirstOrDefault(p => p.Start == startTime); if (exact is not null) return exact.PeriodNumber; var closest = periodSchedule.Periods .Select(p => (Period: p, DiffMinutes: Math.Abs((p.Start.ToTimeSpan() - startTime.ToTimeSpan()).TotalMinutes))) .Where(t => t.DiffMinutes <= StartTimeToleranceMinutes) .OrderBy(t => t.DiffMinutes) .FirstOrDefault(); return closest.Period?.PeriodNumber; } // Alle konfigurierten Stunden, die vollständig innerhalb [start, end) liegen — bei einer // einzelnen Stunde genau eine, bei einer von WebUntis zu einem Termin zusammengefassten // Doppelstunde entsprechend zwei (siehe UntisSlotMatch.CoveredPeriods). private static List ResolveCoveredPeriods(TimeOnly start, TimeOnly end, PeriodScheduleService periodSchedule) { var tolerance = TimeSpan.FromMinutes(StartTimeToleranceMinutes); return periodSchedule.Periods .Where(p => p.Start.ToTimeSpan() >= start.ToTimeSpan() - tolerance && p.End.ToTimeSpan() <= end.ToTimeSpan() + tolerance) .OrderBy(p => p.PeriodNumber) .Select(p => p.PeriodNumber) .ToList(); } // Die Pause direkt vor startTime: die zuletzt endende konfigurierte Stunde davor, 0 (vor der // 1. Stunde) falls keine liegt - anders als ResolvePeriodNumber immer auflösbar, da eine // Pause per Definition zwischen/vor Stunden liegt statt exakt auf einer Startzeit. private static int ResolveAfterPeriod(TimeOnly startTime, PeriodScheduleService periodSchedule) => periodSchedule.Periods .Where(p => p.End <= startTime) .OrderByDescending(p => p.End) .Select(p => (int?)p.PeriodNumber) .FirstOrDefault() ?? 0; private static (Guid? GroupId, bool Confident) ResolveGroup(List classTokens, int? periodNumber, List groups, List timetableSlots) { if (classTokens.Count == 0) return (null, false); var candidates = classTokens .Select(token => ResolveSingleGroup(token, groups)) .Where(g => g is not null) .Select(g => g!) .Distinct() .ToList(); if (candidates.Count == 1) return (candidates[0].Id, true); if (candidates.Count == 0) return (null, false); // Mehrere Klassen im selben Termin (kombinierte/differenzierte Gruppen) — die Gruppe // bevorzugen, die an dieser Stelle bereits einen TimetableSlot hat. if (periodNumber is { } period) { var withExistingSlot = candidates .Where(g => timetableSlots.Any(s => s.GroupId == g.Id && s.PeriodNumber == period)) .ToList(); if (withExistingSlot.Count == 1) return (withExistingSlot[0].Id, true); } return (null, false); // mehrdeutig - der Nutzer entscheidet im Review-Dialog } private static LearningGroup? ResolveSingleGroup(string token, List groups) { var exact = groups.FirstOrDefault(g => string.Equals(g.Name, token, StringComparison.OrdinalIgnoreCase)); if (exact is not null) return exact; var normalizedToken = Normalize(token); return groups.FirstOrDefault(g => Normalize(g.Name) == normalizedToken); } private static string Normalize(string value) => new(value.Where(c => !char.IsWhiteSpace(c)).Select(char.ToLowerInvariant).ToArray()); }