Jahresplanimport und Ablgeich von Untis

This commit is contained in:
2026-08-23 19:23:05 +02:00
parent 4a59bff9de
commit dd5ecb0334
20 changed files with 1039 additions and 20 deletions
@@ -143,6 +143,17 @@ public interface IUntisSlotMappingRepository
void Save(UntisSlotMapping mapping); void Save(UntisSlotMapping mapping);
void Delete(Guid id); void Delete(Guid id);
} }
/// Lokal importierter, rein informativer Schuljahresplan. Bewusst getrennt von WebUntis-
/// Stundenplanabweichungen und nicht über den App-Sync repliziert; jedes Gerät bezieht denselben
/// externen Feed selbst.
public interface IAnnualPlanEventRepository
{
List<AnnualPlanEvent> GetAll();
List<AnnualPlanEvent> GetByRange(DateOnly from, DateOnly to);
AnnualPlanEvent? GetByExternalId(string externalId);
void Save(AnnualPlanEvent entry);
void Delete(Guid id);
}
public interface IDocumentationRepository public interface IDocumentationRepository
{ {
List<Documentation> GetByStudent(Guid studentId); List<Documentation> GetByStudent(Guid studentId);
+37
View File
@@ -0,0 +1,37 @@
namespace LehrerApp.Core.Models;
/// <summary>
/// Ein aus dem schulweiten Jahresplan importierter Termin. Anders als <see cref="TimetableSlot"/>
/// und <see cref="SubstitutionEntry"/> ist er reine Kalenderinformation: Er verändert weder den
/// regulären Stundenplan noch erzeugt er Vertretungen oder Ausfälle.
/// </summary>
public class AnnualPlanEvent
{
public Guid Id { get; set; } = Guid.NewGuid();
/// <summary>Stabile UID aus dem iCal-Feed; fachlicher Schlüssel für idempotente Importe.</summary>
public string ExternalId { get; set; } = "";
public string Title { get; set; } = "";
public string Description { get; set; } = "";
public string Location { get; set; } = "";
/// <summary>Erster beziehungsweise letzter tatsächlich belegter lokaler Kalendertag.</summary>
public DateOnly StartDate { get; set; }
public DateOnly EndDate { get; set; }
/// <summary>
/// Bei Ganztagsterminen null. Zeitgebundene UTC-Werte aus dem Feed werden beim Import in die
/// lokale Schulzeit Europe/Berlin umgerechnet.
/// </summary>
public TimeOnly? StartTime { get; set; }
public TimeOnly? EndTime { get; set; }
public bool IsAllDay { get; set; }
/// <summary>ClassyPlan-Gruppe, z.B. "Lehrkräfte" oder "Öffentlich".</summary>
public string CalendarGroup { get; set; } = "";
public string Color { get; set; } = "";
public string Status { get; set; } = "CONFIRMED";
public int Sequence { get; set; }
public DateTime? SourceLastModifiedUtc { get; set; }
}
@@ -0,0 +1,205 @@
using System.Globalization;
using System.Text;
using LehrerApp.Core.Models;
namespace LehrerApp.Core.Services;
/// <summary>
/// Parser für den beobachteten ClassyPlan-Jahresplanexport. Im Gegensatz zum bewusst schmalen
/// WebUntis-Parser unterstützt er insbesondere DATE-Ganztagstermine und mehrtägige Ereignisse.
/// Er arbeitet absichtlich strikt: Ist ein VEVENT unvollständig oder ungültig, scheitert der
/// gesamte Parse, damit ein nachgelagerter Snapshot-Abgleich keine bestehenden Termine löscht.
/// </summary>
public static class AnnualPlanIcsParser
{
private static readonly TimeZoneInfo BerlinTimeZone =
TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
private readonly record struct Property(string Head, string Value)
{
public bool IsDate => Head.Contains("VALUE=DATE", StringComparison.OrdinalIgnoreCase);
}
public static List<AnnualPlanEvent> Parse(string icsText)
{
var lines = Unfold(icsText);
if (!lines.Any(l => l.Equals("BEGIN:VCALENDAR", StringComparison.OrdinalIgnoreCase)) ||
!lines.Any(l => l.Equals("END:VCALENDAR", StringComparison.OrdinalIgnoreCase)))
throw new FormatException("Kein vollständiger iCal-Kalender gefunden.");
var result = new List<AnnualPlanEvent>();
Dictionary<string, Property>? current = null;
foreach (var line in lines)
{
if (line.Equals("BEGIN:VEVENT", StringComparison.OrdinalIgnoreCase))
{
if (current is not null) throw new FormatException("Verschachteltes VEVENT gefunden.");
current = new Dictionary<string, Property>(StringComparer.OrdinalIgnoreCase);
continue;
}
if (line.Equals("END:VEVENT", StringComparison.OrdinalIgnoreCase))
{
if (current is null) throw new FormatException("END:VEVENT ohne BEGIN:VEVENT gefunden.");
result.Add(BuildEvent(current, result.Count + 1));
current = null;
continue;
}
if (current is null) continue;
var colon = line.IndexOf(':');
if (colon < 0) continue;
var head = line[..colon];
var separator = head.IndexOf(';');
var key = separator < 0 ? head : head[..separator];
if (key.Length > 0) current[key] = new Property(head, line[(colon + 1)..]);
}
if (current is not null) throw new FormatException("Nicht abgeschlossenes VEVENT gefunden.");
if (result.Select(e => e.ExternalId).Distinct(StringComparer.Ordinal).Count() != result.Count)
throw new FormatException("Der Jahresplan enthält doppelte iCal-UIDs.");
return result;
}
private static AnnualPlanEvent BuildEvent(Dictionary<string, Property> properties, int number)
{
var uid = Required(properties, "UID", number).Value.Trim();
if (uid.Length == 0) throw Invalid(number, "UID fehlt");
var startProperty = Required(properties, "DTSTART", number);
var endProperty = properties.GetValueOrDefault("DTEND");
DateOnly startDate;
DateOnly endDate;
TimeOnly? startTime;
TimeOnly? endTime;
var isAllDay = startProperty.IsDate;
if (isAllDay)
{
startDate = ParseDate(startProperty.Value, number, "DTSTART");
var endExclusive = endProperty == default
? startDate.AddDays(1)
: endProperty.IsDate
? ParseDate(endProperty.Value, number, "DTEND")
: throw Invalid(number, "DTSTART und DTEND verwenden unterschiedliche Werttypen");
if (endExclusive <= startDate) throw Invalid(number, "DTEND liegt nicht nach DTSTART");
endDate = endExclusive.AddDays(-1); // RFC 5545: DTEND eines Ganztagstermins ist exklusiv.
startTime = null;
endTime = null;
}
else
{
var start = ParseLocalDateTime(startProperty, number, "DTSTART");
var end = endProperty == default
? start
: !endProperty.IsDate
? ParseLocalDateTime(endProperty, number, "DTEND")
: throw Invalid(number, "DTSTART und DTEND verwenden unterschiedliche Werttypen");
if (end < start) throw Invalid(number, "DTEND liegt vor DTSTART");
startDate = DateOnly.FromDateTime(start);
endDate = DateOnly.FromDateTime(end);
startTime = TimeOnly.FromDateTime(start);
endTime = TimeOnly.FromDateTime(end);
}
return new AnnualPlanEvent
{
ExternalId = uid,
Title = Text(properties, "SUMMARY"),
Description = Text(properties, "DESCRIPTION"),
Location = Text(properties, "LOCATION"),
StartDate = startDate,
EndDate = endDate,
StartTime = startTime,
EndTime = endTime,
IsAllDay = isAllDay,
CalendarGroup = Text(properties, "X-GROUPNAME"),
Color = Text(properties, "X-COLOR"),
Status = Text(properties, "STATUS") is { Length: > 0 } status ? status : "CONFIRMED",
Sequence = int.TryParse(Raw(properties, "SEQUENCE"), NumberStyles.Integer,
CultureInfo.InvariantCulture, out var sequence) ? sequence : 0,
SourceLastModifiedUtc = ParseOptionalUtc(properties.GetValueOrDefault("LAST-MODIFIED"), number),
};
}
private static Property Required(Dictionary<string, Property> properties, string key, int number) =>
properties.TryGetValue(key, out var value) ? value : throw Invalid(number, $"{key} fehlt");
private static string Raw(Dictionary<string, Property> properties, string key) =>
properties.TryGetValue(key, out var value) ? value.Value : "";
private static string Text(Dictionary<string, Property> properties, string key) =>
Unescape(Raw(properties, key));
private static DateOnly ParseDate(string value, int number, string field)
{
if (DateOnly.TryParseExact(value, "yyyyMMdd", CultureInfo.InvariantCulture,
DateTimeStyles.None, out var date)) return date;
throw Invalid(number, $"ungültiges {field}: {value}");
}
private static DateTime ParseLocalDateTime(Property property, int number, string field)
{
var utc = property.Value.EndsWith('Z');
var value = utc ? property.Value[..^1] : property.Value;
var formats = new[] { "yyyyMMdd'T'HHmmss", "yyyyMMdd'T'HHmm" };
if (!DateTime.TryParseExact(value, formats, CultureInfo.InvariantCulture,
DateTimeStyles.None, out var parsed))
throw Invalid(number, $"ungültiges {field}: {property.Value}");
return utc
? TimeZoneInfo.ConvertTimeFromUtc(DateTime.SpecifyKind(parsed, DateTimeKind.Utc), BerlinTimeZone)
: DateTime.SpecifyKind(parsed, DateTimeKind.Unspecified);
}
private static DateTime? ParseOptionalUtc(Property property, int number)
{
if (property == default || string.IsNullOrWhiteSpace(property.Value)) return null;
var local = ParseLocalDateTime(property, number, "LAST-MODIFIED");
return property.Value.EndsWith('Z')
? TimeZoneInfo.ConvertTimeToUtc(local, BerlinTimeZone)
: TimeZoneInfo.ConvertTimeToUtc(DateTime.SpecifyKind(local, DateTimeKind.Unspecified), BerlinTimeZone);
}
private static List<string> Unfold(string text)
{
var result = new List<string>();
foreach (var line in text.Replace("\r\n", "\n").Replace("\r", "\n").Split('\n'))
{
if ((line.StartsWith(' ') || line.StartsWith('\t')) && result.Count > 0)
result[^1] += line[1..];
else
result.Add(line);
}
return result;
}
private static string Unescape(string value)
{
var result = new StringBuilder(value.Length);
for (var i = 0; i < value.Length; i++)
{
if (value[i] != '\\' || i + 1 >= value.Length)
{
result.Append(value[i]);
continue;
}
var next = value[++i];
result.Append(next switch
{
'n' or 'N' => '\n',
'\\' => '\\',
',' => ',',
';' => ';',
_ => next,
});
}
return result.ToString();
}
private static FormatException Invalid(int number, string message) =>
new($"Ungültiger Jahresplan-Termin #{number}: {message}.");
}
+25
View File
@@ -1028,6 +1028,31 @@ public sealed class RepositoryTests
Assert.Empty(repo.GetAll()); Assert.Empty(repo.GetAll());
} }
// ── AnnualPlanEventRepository ────────────────────────────────────────────
[Fact]
public void AnnualPlanEventRepository_GetByRange_FindetAuchUeberlappendeMehrtagstermine()
{
using var db = NewInMemoryContext();
var repo = new AnnualPlanEventRepository(db);
repo.Save(new AnnualPlanEvent
{
ExternalId = "fahrt", Title = "Klassenfahrt",
StartDate = new DateOnly(2026, 6, 15), EndDate = new DateOnly(2026, 6, 19), IsAllDay = true,
});
repo.Save(new AnnualPlanEvent
{
ExternalId = "spaeter", Title = "Sommerfest",
StartDate = new DateOnly(2026, 7, 1), EndDate = new DateOnly(2026, 7, 1), IsAllDay = true,
});
var result = repo.GetByRange(new DateOnly(2026, 6, 17), new DateOnly(2026, 6, 17));
Assert.Single(result);
Assert.Equal("fahrt", result[0].ExternalId);
Assert.Equal("fahrt", repo.GetByExternalId("fahrt")!.ExternalId);
}
// ── WorkTaskRepository ──────────────────────────────────────────────────── // ── WorkTaskRepository ────────────────────────────────────────────────────
[Fact] [Fact]
+4
View File
@@ -68,6 +68,7 @@ public class LiteDbContext : IDisposable
public ILiteCollection<SubstitutionEntry> SubstitutionEntries => _db.GetCollection<SubstitutionEntry>("substitution_entries"); public ILiteCollection<SubstitutionEntry> SubstitutionEntries => _db.GetCollection<SubstitutionEntry>("substitution_entries");
public ILiteCollection<UntisSnapshotEntry> UntisSnapshotEntries => _db.GetCollection<UntisSnapshotEntry>("untis_snapshot_entries"); public ILiteCollection<UntisSnapshotEntry> UntisSnapshotEntries => _db.GetCollection<UntisSnapshotEntry>("untis_snapshot_entries");
public ILiteCollection<UntisSlotMapping> UntisSlotMappings => _db.GetCollection<UntisSlotMapping>("untis_slot_mappings"); public ILiteCollection<UntisSlotMapping> UntisSlotMappings => _db.GetCollection<UntisSlotMapping>("untis_slot_mappings");
public ILiteCollection<AnnualPlanEvent> AnnualPlanEvents => _db.GetCollection<AnnualPlanEvent>("annual_plan_events");
public ILiteCollection<TrashedItem> TrashedItems => _db.GetCollection<TrashedItem>("trash"); public ILiteCollection<TrashedItem> TrashedItems => _db.GetCollection<TrashedItem>("trash");
public void Checkpoint() => _db.Checkpoint(); public void Checkpoint() => _db.Checkpoint();
@@ -531,6 +532,9 @@ public class LiteDbContext : IDisposable
SupervisionDuties.EnsureIndex("ux_supervision_weekday_period", SupervisionDuties.EnsureIndex("ux_supervision_weekday_period",
BsonExpression.Create("STRING($.Weekday) + ':' + STRING($.AfterPeriod)"), unique: true); BsonExpression.Create("STRING($.Weekday) + ':' + STRING($.AfterPeriod)"), unique: true);
SubstitutionEntries.EnsureIndex(x => x.Date); SubstitutionEntries.EnsureIndex(x => x.Date);
AnnualPlanEvents.EnsureIndex(x => x.ExternalId, unique: true);
AnnualPlanEvents.EnsureIndex(x => x.StartDate);
AnnualPlanEvents.EnsureIndex(x => x.EndDate);
} }
public void Dispose() => _db.Dispose(); public void Dispose() => _db.Dispose();
@@ -776,6 +776,24 @@ public class UntisSlotMappingRepository(LiteDbContext db) : IUntisSlotMappingRep
public void Delete(Guid id) => db.UntisSlotMappings.Delete(id); public void Delete(Guid id) => db.UntisSlotMappings.Delete(id);
} }
// Wie UntisSnapshotEntry rein lokal: Der Jahresplan ist ein wiederherstellbarer Cache eines
// externen Feeds. db.OnChange würde bei jedem vollständigen Abruf hunderte Sync-Ereignisse erzeugen.
public class AnnualPlanEventRepository(LiteDbContext db) : IAnnualPlanEventRepository
{
public List<AnnualPlanEvent> GetAll() =>
db.AnnualPlanEvents.FindAll().OrderBy(e => e.StartDate).ThenBy(e => e.StartTime).ToList();
public List<AnnualPlanEvent> GetByRange(DateOnly from, DateOnly to) =>
db.AnnualPlanEvents.Find(e => e.StartDate <= to && e.EndDate >= from)
.OrderBy(e => e.StartDate).ThenBy(e => e.StartTime).ToList();
public AnnualPlanEvent? GetByExternalId(string externalId) =>
db.AnnualPlanEvents.FindOne(e => e.ExternalId == externalId);
public void Save(AnnualPlanEvent entry) => db.AnnualPlanEvents.Upsert(entry);
public void Delete(Guid id) => db.AnnualPlanEvents.Delete(id);
}
public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRepository public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRepository
{ {
public List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel) => public List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel) =>
@@ -0,0 +1,46 @@
using LehrerApp.Desktop.Services;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class AnnualPlanSettingsServiceTests
{
private const string SampleUrl = "https://example.org/export.php?type=ics&token=intern";
private static string TempPath()
{
var path = Path.Combine(Path.GetTempPath(), $"lehrerapp-annualplansettingssvc-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(path);
return path;
}
[Fact]
public void IcalUrl_WirdVerschluesseltPersistiertUndWiederGeladen()
{
var path = TempPath();
var service = new AnnualPlanSettingsService(path);
service.SetIcalUrl(SampleUrl);
service.SetEnabled(true);
var raw = File.ReadAllText(Path.Combine(path, "annual-plan-settings.json"));
var reloaded = new AnnualPlanSettingsService(path);
Assert.DoesNotContain("token=intern", raw);
Assert.Equal(SampleUrl, reloaded.GetIcalUrl());
Assert.True(reloaded.Enabled);
}
[Fact]
public void ClearIcalUrl_EntferntUrlUndDeaktiviertDenImport()
{
var service = new AnnualPlanSettingsService(TempPath());
service.SetIcalUrl(SampleUrl);
service.SetEnabled(true);
service.ClearIcalUrl();
Assert.False(service.IsConfigured);
Assert.False(service.Enabled);
Assert.Null(service.GetIcalUrl());
}
}
@@ -0,0 +1,73 @@
using LehrerApp.Core.Models;
using LehrerApp.Desktop.Services;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class AnnualPlanSyncServiceTests
{
private static string Calendar(params string[] events) =>
"BEGIN:VCALENDAR\nVERSION:2.0\n" + string.Join("", events) + "END:VCALENDAR\n";
private static string Event(string uid, string title, string date = "20260823") =>
$"BEGIN:VEVENT\nUID:{uid}\nDTSTART;VALUE=DATE:{date}\nSUMMARY:{title}\nEND:VEVENT\n";
private static AnnualPlanSyncService Build(FakeAnnualPlanEvents events) =>
new(new HttpClient(), TestSupport.BuildAnnualPlanSettingsService(), events);
[Fact]
public void ProcessIcsText_LegtAn_AktualisiertIdempotent_UndEntferntVerschwundeneTermine()
{
var events = new FakeAnnualPlanEvents();
using var service = Build(events);
var first = service.ProcessIcsText(Calendar(Event("1", "Konferenz"), Event("2", "Messe")));
var originalId = events.GetByExternalId("1")!.Id;
var unchanged = service.ProcessIcsText(Calendar(Event("1", "Konferenz"), Event("2", "Messe")));
var changed = service.ProcessIcsText(Calendar(Event("1", "Konferenz verschoben", "20260824")));
Assert.Equal((2, 0, 0), (first.Added, first.Updated, first.Deleted));
Assert.Equal(0, unchanged.ChangeCount);
Assert.Equal((0, 1, 1), (changed.Added, changed.Updated, changed.Deleted));
Assert.Equal(originalId, events.GetByExternalId("1")!.Id);
Assert.Null(events.GetByExternalId("2"));
}
[Fact]
public void ProcessIcsText_DefekterTeilimport_VeraendertBestehendenBestandNicht()
{
var events = new FakeAnnualPlanEvents();
events.Add(new AnnualPlanEvent
{
ExternalId = "bestand", Title = "Bestehend",
StartDate = new DateOnly(2026, 8, 23), EndDate = new DateOnly(2026, 8, 23), IsAllDay = true,
});
using var service = Build(events);
var broken = Calendar(Event("neu", "Neu"),
"BEGIN:VEVENT\nUID:defekt\nSUMMARY:Ohne Datum\nEND:VEVENT\n");
Assert.Throws<FormatException>(() => service.ProcessIcsText(broken));
var remaining = Assert.Single(events.GetAll());
Assert.Equal("bestand", remaining.ExternalId);
}
[Fact]
public void Clear_EntferntDenLokalenFeedCache()
{
var events = new FakeAnnualPlanEvents();
events.Add(new AnnualPlanEvent
{
ExternalId = "1", StartDate = new DateOnly(2026, 8, 23),
EndDate = new DateOnly(2026, 8, 23), IsAllDay = true,
});
using var service = Build(events);
var notified = false;
service.DataChanged += () => notified = true;
service.Clear();
Assert.Empty(events.GetAll());
Assert.True(notified);
}
}
@@ -42,7 +42,8 @@ public sealed class DashboardViewModelTests
FakeWorkTasks? tasks = null, FakeStudents? students = null, FakeDocumentation? documentation = null, FakeWorkTasks? tasks = null, FakeStudents? students = null, FakeDocumentation? documentation = null,
DashboardSettingsService? dashboardSettings = null, FakeSchoolHolidays? schoolHolidays = null, DashboardSettingsService? dashboardSettings = null, FakeSchoolHolidays? schoolHolidays = null,
FakeLessons? lessons = null, FakeSubstitutionEntries? substitutions = null, FakeLessons? lessons = null, FakeSubstitutionEntries? substitutions = null,
FakeSessions? sessions = null, FakeEntries? entries = null) FakeSessions? sessions = null, FakeEntries? entries = null,
FakeAnnualPlanEvents? annualPlanEvents = null)
{ {
lessons ??= new FakeLessons(); lessons ??= new FakeLessons();
lessons.Add(lesson); lessons.Add(lesson);
@@ -55,7 +56,7 @@ public sealed class DashboardViewModelTests
slots ?? new FakeTimetableSlots(), periodSchedule ?? NewPeriodSchedule(), slots ?? new FakeTimetableSlots(), periodSchedule ?? NewPeriodSchedule(),
new AttendanceBalanceService(), new SchoolYearService(), dashboardSettings ?? NewDashboardSettings(), new AttendanceBalanceService(), new SchoolYearService(), dashboardSettings ?? NewDashboardSettings(),
schoolHolidays ?? new FakeSchoolHolidays(), new PublicHolidayService(), NewCalendarSettings(), schoolHolidays ?? new FakeSchoolHolidays(), new PublicHolidayService(), NewCalendarSettings(),
substitutions ?? new FakeSubstitutionEntries()); substitutions ?? new FakeSubstitutionEntries(), annualPlanEvents);
} }
[Fact] [Fact]
@@ -244,6 +245,37 @@ public sealed class DashboardViewModelTests
e => e.Kind == CalendarEventKind.ParticipationSession && e.Subtitle == "Aufsatz"); e => e.Kind == CalendarEventKind.ParticipationSession && e.Subtitle == "Aufsatz");
} }
[Fact]
public void Kalender_ZeigtMehrtaegigenJahresplanParallelZumUnterrichtAn()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var group = new LearningGroup { Name = "9c" };
var annualPlan = new FakeAnnualPlanEvents();
annualPlan.Add(new AnnualPlanEvent
{
ExternalId = "fahrt", Title = "Klassenfahrt 6a",
Description = "Aushang beachten", Location = "Jugendherberge",
CalendarGroup = "Lehrkräfte", StartDate = today, EndDate = today.AddDays(2),
StartTime = new TimeOnly(13, 30), EndTime = new TimeOnly(15, 0), IsAllDay = false,
});
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today, Topic = "Redox" },
annualPlanEvents: annualPlan);
var day = vm.CalendarDays.Single(d => d.Date == today);
Assert.True(day.HasLesson);
Assert.True(day.HasAnnualPlanEvent);
vm.SelectCalendarDayCommand.Execute(day);
Assert.Contains(vm.SelectedDayEvents, e => e.Kind == CalendarEventKind.Lesson);
var annual = Assert.Single(vm.SelectedDayEvents, e => e.Kind == CalendarEventKind.AnnualPlan);
Assert.Equal("Klassenfahrt 6a", annual.Title);
Assert.Equal("Aushang beachten", annual.Description);
Assert.Contains("13:30", annual.Subtitle);
Assert.Null(annual.GroupId);
}
[Fact] [Fact]
public void Kalender_AusStundeErzeugteSitzungErzeugtKeinenDoppelteintrag() public void Kalender_AusStundeErzeugteSitzungErzeugtKeinenDoppelteintrag()
{ {
+21
View File
@@ -39,6 +39,14 @@ public static class TestSupport
return new WebUntisSettingsService(tempPath); return new WebUntisSettingsService(tempPath);
} }
/// Analog zu den übrigen dateibasierten Feed-Einstellungen: eigenes Temp-Verzeichnis.
public static AnnualPlanSettingsService BuildAnnualPlanSettingsService()
{
var tempPath = Path.Combine(Path.GetTempPath(), $"lehrerapp-annualplansettings-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempPath);
return new AnnualPlanSettingsService(tempPath);
}
/// Analog zu <see cref="BuildAiSettingsService"/>, eigenes Temp-Verzeichnis je Aufruf. /// Analog zu <see cref="BuildAiSettingsService"/>, eigenes Temp-Verzeichnis je Aufruf.
public static SyncSettingsService BuildSyncSettingsService() public static SyncSettingsService BuildSyncSettingsService()
{ {
@@ -453,6 +461,19 @@ public class FakeUntisSlotMappings : IUntisSlotMappingRepository
public void Delete(Guid id) => _all.RemoveAll(m => m.Id == id); public void Delete(Guid id) => _all.RemoveAll(m => m.Id == id);
} }
public class FakeAnnualPlanEvents : IAnnualPlanEventRepository
{
private readonly List<AnnualPlanEvent> _all = [];
public void Add(AnnualPlanEvent e) => _all.Add(e);
public List<AnnualPlanEvent> GetAll() => _all.ToList();
public List<AnnualPlanEvent> GetByRange(DateOnly from, DateOnly to) =>
_all.Where(e => e.StartDate <= to && e.EndDate >= from).ToList();
public AnnualPlanEvent? GetByExternalId(string externalId) =>
_all.FirstOrDefault(e => e.ExternalId == externalId);
public void Save(AnnualPlanEvent entry) { _all.RemoveAll(e => e.Id == entry.Id); _all.Add(entry); }
public void Delete(Guid id) => _all.RemoveAll(e => e.Id == id);
}
public class FakeWorkTasks : IWorkTaskRepository public class FakeWorkTasks : IWorkTaskRepository
{ {
private readonly List<WorkTask> _all = []; private readonly List<WorkTask> _all = [];
@@ -35,6 +35,7 @@ public sealed class SettingsViewModelTests
new PeriodScheduleService(tempPath), supervisionDuties ?? new FakeSupervisionDuties(), new PeriodScheduleService(tempPath), supervisionDuties ?? new FakeSupervisionDuties(),
new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
TestSupport.BuildWebUntisSettingsService(), TestSupport.BuildWebUntisSettingsService(),
TestSupport.BuildAnnualPlanSettingsService(),
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(),
eventQueue ?? TestSupport.BuildEventQueue(), TestSupport.BuildAppLogger(), eventQueue ?? TestSupport.BuildEventQueue(), TestSupport.BuildAppLogger(),
syncKeyStatus ?? TestSupport.BuildSyncKeyStatus(), syncKeyStatus ?? TestSupport.BuildSyncKeyStatus(),
@@ -291,6 +292,7 @@ public sealed class SettingsViewModelTests
new FakeSchoolHolidays(), calendarSettings, new PeriodScheduleService(tempPath), new FakeSchoolHolidays(), calendarSettings, new PeriodScheduleService(tempPath),
new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
TestSupport.BuildWebUntisSettingsService(), TestSupport.BuildWebUntisSettingsService(),
TestSupport.BuildAnnualPlanSettingsService(),
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(), TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
TestSupport.BuildAppLogger(), TestSupport.BuildSyncKeyStatus(), TestSupport.BuildSyncKeyRecoveryService(), TestSupport.BuildAppLogger(), TestSupport.BuildSyncKeyStatus(), TestSupport.BuildSyncKeyRecoveryService(),
TestSupport.BuildAppearanceSettingsService(), TestSupport.BuildTrashViewModel()); TestSupport.BuildAppearanceSettingsService(), TestSupport.BuildTrashViewModel());
@@ -317,6 +319,7 @@ public sealed class SettingsViewModelTests
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule, new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
TestSupport.BuildWebUntisSettingsService(), TestSupport.BuildWebUntisSettingsService(),
TestSupport.BuildAnnualPlanSettingsService(),
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(), TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
TestSupport.BuildAppLogger(), TestSupport.BuildSyncKeyStatus(), TestSupport.BuildSyncKeyRecoveryService(), TestSupport.BuildAppLogger(), TestSupport.BuildSyncKeyStatus(), TestSupport.BuildSyncKeyRecoveryService(),
TestSupport.BuildAppearanceSettingsService(), TestSupport.BuildTrashViewModel()); TestSupport.BuildAppearanceSettingsService(), TestSupport.BuildTrashViewModel());
@@ -347,6 +350,7 @@ public sealed class SettingsViewModelTests
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule, new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
TestSupport.BuildWebUntisSettingsService(), TestSupport.BuildWebUntisSettingsService(),
TestSupport.BuildAnnualPlanSettingsService(),
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(), TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
TestSupport.BuildAppLogger(), TestSupport.BuildSyncKeyStatus(), TestSupport.BuildSyncKeyRecoveryService(), TestSupport.BuildAppLogger(), TestSupport.BuildSyncKeyStatus(), TestSupport.BuildSyncKeyRecoveryService(),
TestSupport.BuildAppearanceSettingsService(), TestSupport.BuildTrashViewModel()); TestSupport.BuildAppearanceSettingsService(), TestSupport.BuildTrashViewModel());
+12
View File
@@ -167,6 +167,7 @@ public static class AppBootstrapper
services.AddSingleton<ISubstitutionEntryRepository, SubstitutionEntryRepository>(); services.AddSingleton<ISubstitutionEntryRepository, SubstitutionEntryRepository>();
services.AddSingleton<IUntisSnapshotRepository, UntisSnapshotRepository>(); services.AddSingleton<IUntisSnapshotRepository, UntisSnapshotRepository>();
services.AddSingleton<IUntisSlotMappingRepository, UntisSlotMappingRepository>(); services.AddSingleton<IUntisSlotMappingRepository, UntisSlotMappingRepository>();
services.AddSingleton<IAnnualPlanEventRepository, AnnualPlanEventRepository>();
// ── Services ────────────────────────────────────────────────────────── // ── Services ──────────────────────────────────────────────────────────
services.AddSingleton<GradingService>(); services.AddSingleton<GradingService>();
@@ -206,6 +207,17 @@ public static class AppBootstrapper
sp.GetRequiredService<AppLogger>())); sp.GetRequiredService<AppLogger>()));
} }
// ── Schulweiter Jahresplan (informativer ClassyPlan-iCal, kein Stundenplan-Abgleich) ──
var annualPlanSettings = new AnnualPlanSettingsService(appData);
services.AddSingleton(annualPlanSettings);
if (annualPlanSettings.Enabled && !string.IsNullOrEmpty(annualPlanSettings.GetIcalUrl()))
{
services.AddSingleton(sp => new AnnualPlanSyncService(
new HttpClient(), annualPlanSettings,
sp.GetRequiredService<IAnnualPlanEventRepository>(),
sp.GetRequiredService<AppLogger>()));
}
// ── Sync (optional nur wenn Server konfiguriert) ──────────────────── // ── Sync (optional nur wenn Server konfiguriert) ────────────────────
var syncSettings = new SyncSettingsService(appData); var syncSettings = new SyncSettingsService(appData);
services.AddSingleton(syncSettings); services.AddSingleton(syncSettings);
@@ -0,0 +1,87 @@
using System.Text.Json;
using LehrerApp.Sync.Crypto;
namespace LehrerApp.Desktop.Services;
internal sealed class AnnualPlanSettingsConfig
{
public bool Enabled { get; set; }
public string? EncryptedIcalUrl { get; set; }
public DateTime? LastSyncAt { get; set; }
public string LastSyncStatus { get; set; } = "";
}
/// <summary>
/// Gerätebezogene Einstellungen für den externen Schuljahresplan. Der eingebettete Feed-Schlüssel
/// wird wie die WebUntis-URL verschlüsselt in einer separaten Datei gespeichert.
/// </summary>
public sealed class AnnualPlanSettingsService
{
private readonly string _configPath;
private readonly byte[] _urlKey;
private AnnualPlanSettingsConfig _config;
public bool Enabled => _config.Enabled;
public bool IsConfigured => _config.EncryptedIcalUrl is not null;
public DateTime? LastSyncAt => _config.LastSyncAt;
public string LastSyncStatus => _config.LastSyncStatus;
public AnnualPlanSettingsService(string appDataPath)
{
_configPath = Path.Combine(appDataPath, "annual-plan-settings.json");
var keyPath = Path.Combine(appDataPath, "annual-plan-url.key");
_urlKey = SyncCrypto.LoadKey(keyPath) ?? GenerateAndSaveKey(keyPath);
_config = Load();
}
public void SetIcalUrl(string url)
{
_config.EncryptedIcalUrl = SyncCrypto.EncryptObject(url, _urlKey);
Save();
}
public string? GetIcalUrl() => _config.EncryptedIcalUrl is null
? null
: SyncCrypto.DecryptObject<string>(_config.EncryptedIcalUrl, _urlKey);
public void SetEnabled(bool enabled)
{
_config.Enabled = enabled;
Save();
}
public void ClearIcalUrl()
{
_config.EncryptedIcalUrl = null;
_config.Enabled = false;
Save();
}
public void SetLastSync(DateTime at, string status)
{
_config.LastSyncAt = at;
_config.LastSyncStatus = status;
Save();
}
private byte[] GenerateAndSaveKey(string keyPath)
{
var key = SyncCrypto.GenerateKey();
SyncCrypto.SaveKey(key, keyPath);
return key;
}
private AnnualPlanSettingsConfig Load()
{
try
{
if (File.Exists(_configPath))
return JsonSerializer.Deserialize<AnnualPlanSettingsConfig>(File.ReadAllText(_configPath))
?? new AnnualPlanSettingsConfig();
}
catch { /* beschädigte lokale Konfiguration -> Standardwerte */ }
return new AnnualPlanSettingsConfig();
}
private void Save() => File.WriteAllText(_configPath, JsonSerializer.Serialize(_config));
}
@@ -0,0 +1,147 @@
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
namespace LehrerApp.Desktop.Services;
public sealed record AnnualPlanPollResult(int EventCount, int Added, int Updated, int Deleted)
{
public int ChangeCount => Added + Updated + Deleted;
}
/// <summary>
/// Periodischer Vollabgleich des ClassyPlan-Jahresplans. Dieser Dienst hat bewusst keinerlei
/// Abhängigkeit zu Stundenplan-, Vertretungs- oder Lerngruppen-Repositories.
/// </summary>
public sealed class AnnualPlanSyncService : IDisposable
{
private static readonly TimeSpan PollInterval = TimeSpan.FromHours(6);
private readonly HttpClient _http;
private readonly AnnualPlanSettingsService _settings;
private readonly IAnnualPlanEventRepository _events;
private readonly AppLogger? _logger;
private readonly SemaphoreSlim _gate = new(1, 1);
private readonly Timer _timer;
public event Action? DataChanged;
public AnnualPlanSyncService(HttpClient http, AnnualPlanSettingsService settings,
IAnnualPlanEventRepository events, AppLogger? logger = null)
{
_http = http;
_settings = settings;
_events = events;
_logger = logger;
_timer = new Timer(async _ => await PollAsync(), null, PollInterval, PollInterval);
}
public async Task PollAsync()
{
if (!await _gate.WaitAsync(0)) return;
try
{
var url = _settings.GetIcalUrl();
if (string.IsNullOrWhiteSpace(url)) return;
string icsText;
try
{
icsText = await _http.GetStringAsync(url);
}
catch (Exception ex)
{
_logger?.Error("Jahresplan: Abruf fehlgeschlagen", ex);
_settings.SetLastSync(DateTime.UtcNow, $"Fehler beim Abruf: {ex.Message}");
return;
}
AnnualPlanPollResult result;
try
{
result = ProcessIcsText(icsText);
}
catch (Exception ex)
{
_logger?.Error("Jahresplan: Verarbeitung fehlgeschlagen", ex);
_settings.SetLastSync(DateTime.UtcNow, $"Fehler bei der Verarbeitung: {ex.Message}");
return;
}
_settings.SetLastSync(DateTime.UtcNow,
$"{result.EventCount} Termine — {result.Added} neu, {result.Updated} geändert, {result.Deleted} entfernt.");
_logger?.Info($"Jahresplan: {result.EventCount} Termine, {result.ChangeCount} Änderung(en).");
if (result.ChangeCount > 0) DataChanged?.Invoke();
}
finally
{
_gate.Release();
}
}
/// <summary>HTTP-freier, vollständig testbarer Snapshot-Abgleich.</summary>
public AnnualPlanPollResult ProcessIcsText(string icsText)
{
// Parse muss vollständig erfolgreich sein, bevor das Repository verändert wird. Der
// strikte Parser verhindert so Teilimporte und fälschliches Löschen des Restbestands.
var imported = AnnualPlanIcsParser.Parse(icsText);
var existing = _events.GetAll();
var existingByExternalId = existing.ToDictionary(e => e.ExternalId, StringComparer.Ordinal);
var importedIds = imported.Select(e => e.ExternalId).ToHashSet(StringComparer.Ordinal);
var added = 0;
var updated = 0;
foreach (var entry in imported)
{
if (!existingByExternalId.TryGetValue(entry.ExternalId, out var previous))
{
_events.Save(entry);
added++;
continue;
}
entry.Id = previous.Id;
if (SameContent(previous, entry)) continue;
_events.Save(entry);
updated++;
}
var stale = existing.Where(e => !importedIds.Contains(e.ExternalId)).ToList();
foreach (var entry in stale) _events.Delete(entry.Id);
return new AnnualPlanPollResult(imported.Count, added, updated, stale.Count);
}
private static bool SameContent(AnnualPlanEvent left, AnnualPlanEvent right) =>
left.ExternalId == right.ExternalId &&
left.Title == right.Title &&
left.Description == right.Description &&
left.Location == right.Location &&
left.StartDate == right.StartDate &&
left.EndDate == right.EndDate &&
left.StartTime == right.StartTime &&
left.EndTime == right.EndTime &&
left.IsAllDay == right.IsAllDay &&
left.CalendarGroup == right.CalendarGroup &&
left.Color == right.Color &&
left.Status == right.Status &&
left.Sequence == right.Sequence &&
SameInstant(left.SourceLastModifiedUtc, right.SourceLastModifiedUtc);
private static bool SameInstant(DateTime? left, DateTime? right) =>
left is null && right is null ||
left is not null && right is not null && left.Value.ToUniversalTime() == right.Value.ToUniversalTime();
public void Clear()
{
var existing = _events.GetAll();
foreach (var entry in existing) _events.Delete(entry.Id);
if (existing.Count > 0) DataChanged?.Invoke();
}
public void Dispose()
{
_timer.Dispose();
_gate.Dispose();
}
}
@@ -3,6 +3,7 @@ using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces; using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models; using LehrerApp.Core.Models;
using LehrerApp.Core.Services; using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Globalization; using System.Globalization;
@@ -34,6 +35,7 @@ public partial class DashboardViewModel : ObservableObject
private readonly PublicHolidayService _publicHolidays; private readonly PublicHolidayService _publicHolidays;
private readonly SchoolCalendarSettingsService _calendarSettings; private readonly SchoolCalendarSettingsService _calendarSettings;
private readonly ISubstitutionEntryRepository _substitutions; private readonly ISubstitutionEntryRepository _substitutions;
private readonly IAnnualPlanEventRepository? _annualPlanEvents;
private const int OpenExcuseMaxAgeDays = 21; private const int OpenExcuseMaxAgeDays = 21;
private const int SupportPlanDueWithinDays = 14; private const int SupportPlanDueWithinDays = 14;
@@ -108,7 +110,8 @@ public partial class DashboardViewModel : ObservableObject
PeriodScheduleService periodSchedule, AttendanceBalanceService attendanceBalance, SchoolYearService sy, PeriodScheduleService periodSchedule, AttendanceBalanceService attendanceBalance, SchoolYearService sy,
DashboardSettingsService dashboardSettings, ISchoolHolidayRepository schoolHolidays, DashboardSettingsService dashboardSettings, ISchoolHolidayRepository schoolHolidays,
PublicHolidayService publicHolidays, SchoolCalendarSettingsService calendarSettings, PublicHolidayService publicHolidays, SchoolCalendarSettingsService calendarSettings,
ISubstitutionEntryRepository substitutions) ISubstitutionEntryRepository substitutions, IAnnualPlanEventRepository? annualPlanEvents = null,
AnnualPlanSyncService? annualPlanSync = null)
{ {
_groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams; _tasks = tasks; _groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams; _tasks = tasks;
_examResults = examResults; _grades = grades; _reportGrades = reportGrades; _memberships = memberships; _examResults = examResults; _grades = grades; _reportGrades = reportGrades; _memberships = memberships;
@@ -118,6 +121,12 @@ public partial class DashboardViewModel : ObservableObject
_attendanceBalance = attendanceBalance; _sy = sy; _dashboardSettings = dashboardSettings; _attendanceBalance = attendanceBalance; _sy = sy; _dashboardSettings = dashboardSettings;
_schoolHolidays = schoolHolidays; _publicHolidays = publicHolidays; _calendarSettings = calendarSettings; _schoolHolidays = schoolHolidays; _publicHolidays = publicHolidays; _calendarSettings = calendarSettings;
_substitutions = substitutions; _substitutions = substitutions;
_annualPlanEvents = annualPlanEvents;
if (annualPlanSync is not null)
{
annualPlanSync.DataChanged += () => Avalonia.Threading.Dispatcher.UIThread.Post(LoadCalendar);
_ = annualPlanSync.PollAsync();
}
LoadDashboardCards(); LoadDashboardCards();
Load(); Load();
} }
@@ -580,6 +589,27 @@ public partial class DashboardViewModel : ObservableObject
} }
} }
// Der Jahresplan wird lediglich in dieselbe Anzeigeprojektion eingemischt. Er nimmt an
// keiner Stundenplan-/Vertretungslogik teil; mehrtägige Termine erscheinen an jedem
// betroffenen Kalendertag.
if (_annualPlanEvents is not null)
{
foreach (var annualEvent in _annualPlanEvents.GetByRange(gridStart, gridEnd)
.Where(e => !string.Equals(e.Status, "CANCELLED", StringComparison.OrdinalIgnoreCase)))
{
var visibleStart = annualEvent.StartDate < gridStart ? gridStart : annualEvent.StartDate;
var visibleEnd = annualEvent.EndDate > gridEnd ? gridEnd : annualEvent.EndDate;
for (var date = visibleStart; date <= visibleEnd; date = date.AddDays(1))
{
var agg = Agg(date);
agg.HasAnnualPlanEvent = true;
agg.Details.Add(new CalendarEventItem(CalendarEventKind.AnnualPlan, date,
annualEvent.Title, FormatAnnualPlanSubtitle(annualEvent), null,
annualEvent.Description));
}
}
}
// "Meine Klasse"-Ring: bisher nur gesetzt, wenn für den Tag schon eine Lesson/Exam/Sitzung // "Meine Klasse"-Ring: bisher nur gesetzt, wenn für den Tag schon eine Lesson/Exam/Sitzung
// existiert — ein Tag, an dem laut Stundenplan (4.3) eine eigene Klasse ansteht, für den // existiert — ein Tag, an dem laut Stundenplan (4.3) eine eigene Klasse ansteht, für den
// aber noch keine Lesson angelegt wurde (z.B. "morgen"), zeigte den Ring fälschlich nicht. // aber noch keine Lesson angelegt wurde (z.B. "morgen"), zeigte den Ring fälschlich nicht.
@@ -615,7 +645,7 @@ public partial class DashboardViewModel : ObservableObject
byDay.TryGetValue(date, out var agg); byDay.TryGetValue(date, out var agg);
CalendarDays.Add(new CalendarDayCell(date, date.Month == firstOfMonth.Month, date == today, CalendarDays.Add(new CalendarDayCell(date, date.Month == firstOfMonth.Month, date == today,
agg?.HasLesson ?? false, agg?.HasExam ?? false, agg?.HasSession ?? false, agg?.HasLesson ?? false, agg?.HasExam ?? false, agg?.HasSession ?? false,
agg?.IsOwnClassDay ?? false, agg?.Details ?? [])); agg?.HasAnnualPlanEvent ?? false, agg?.IsOwnClassDay ?? false, agg?.Details ?? []));
} }
SelectCalendarDay(CalendarDays.FirstOrDefault(d => d.Date == today && d.IsCurrentMonth) SelectCalendarDay(CalendarDays.FirstOrDefault(d => d.Date == today && d.IsCurrentMonth)
?? CalendarDays.First(d => d.IsCurrentMonth)); ?? CalendarDays.First(d => d.IsCurrentMonth));
@@ -635,8 +665,9 @@ public partial class DashboardViewModel : ObservableObject
private void OpenCalendarEvent(CalendarEventItem? item) private void OpenCalendarEvent(CalendarEventItem? item)
{ {
if (item is null) return; if (item is null) return;
if (item.Kind == CalendarEventKind.Exam) OnNavigateToExam?.Invoke(item.GroupId); if (item.GroupId is not { } groupId) return; // Jahresplantermine sind reine Information.
else OnNavigateToLesson?.Invoke(item.GroupId); // auch für ParticipationSession: Tab "Mitarbeit" if (item.Kind == CalendarEventKind.Exam) OnNavigateToExam?.Invoke(groupId);
else OnNavigateToLesson?.Invoke(groupId); // auch für ParticipationSession: Tab "Mitarbeit"
} }
[RelayCommand] [RelayCommand]
@@ -699,9 +730,35 @@ public partial class DashboardViewModel : ObservableObject
public bool HasLesson; public bool HasLesson;
public bool HasExam; public bool HasExam;
public bool HasSession; public bool HasSession;
public bool HasAnnualPlanEvent;
public bool IsOwnClassDay; public bool IsOwnClassDay;
public List<CalendarEventItem> Details { get; } = []; public List<CalendarEventItem> Details { get; } = [];
} }
private static string FormatAnnualPlanSubtitle(AnnualPlanEvent entry)
{
string time;
if (entry.IsAllDay)
{
time = entry.StartDate == entry.EndDate
? "Ganztägig"
: $"{entry.StartDate:dd.MM.}{entry.EndDate:dd.MM.yyyy} · ganztägig";
}
else if (entry.StartDate == entry.EndDate)
{
time = entry.EndTime is { } end
? $"{entry.StartTime:HH\\:mm}{end:HH\\:mm}"
: $"{entry.StartTime:HH\\:mm}";
}
else
{
time = $"{entry.StartDate:dd.MM.} {entry.StartTime:HH\\:mm}" +
$"{entry.EndDate:dd.MM.} {entry.EndTime:HH\\:mm}";
}
return string.Join(" · ", new[] { time, entry.CalendarGroup, entry.Location }
.Where(part => !string.IsNullOrWhiteSpace(part)));
}
} }
public class LessonItem public class LessonItem
@@ -783,12 +840,14 @@ public partial class CalendarDayCell : ObservableObject
public bool HasLesson { get; } public bool HasLesson { get; }
public bool HasExam { get; } public bool HasExam { get; }
public bool HasSession { get; } public bool HasSession { get; }
public bool HasAnnualPlanEvent { get; }
public bool IsOwnClassDay { get; } public bool IsOwnClassDay { get; }
public string Tooltip { get; } public string Tooltip { get; }
public IReadOnlyList<CalendarEventItem> Events { get; } public IReadOnlyList<CalendarEventItem> Events { get; }
internal CalendarDayCell(DateOnly date, bool isCurrentMonth, bool isToday, internal CalendarDayCell(DateOnly date, bool isCurrentMonth, bool isToday,
bool hasLesson, bool hasExam, bool hasSession, bool isOwnClassDay, List<CalendarEventItem> details) bool hasLesson, bool hasExam, bool hasSession, bool hasAnnualPlanEvent,
bool isOwnClassDay, List<CalendarEventItem> details)
{ {
Date = date; Date = date;
DayNumber = date.Day; DayNumber = date.Day;
@@ -797,6 +856,7 @@ public partial class CalendarDayCell : ObservableObject
HasLesson = hasLesson; HasLesson = hasLesson;
HasExam = hasExam; HasExam = hasExam;
HasSession = hasSession; HasSession = hasSession;
HasAnnualPlanEvent = hasAnnualPlanEvent;
IsOwnClassDay = isOwnClassDay; IsOwnClassDay = isOwnClassDay;
Events = details; Events = details;
Tooltip = details.Count == 0 ? date.ToString("dd.MM.yyyy") Tooltip = details.Count == 0 ? date.ToString("dd.MM.yyyy")
@@ -804,20 +864,22 @@ public partial class CalendarDayCell : ObservableObject
} }
} }
public enum CalendarEventKind { Lesson, Exam, ParticipationSession } public enum CalendarEventKind { Lesson, Exam, ParticipationSession, AnnualPlan }
public sealed class CalendarEventItem(CalendarEventKind kind, DateOnly date, string title, public sealed class CalendarEventItem(CalendarEventKind kind, DateOnly date, string title,
string subtitle, Guid groupId) string subtitle, Guid? groupId, string description = "")
{ {
public CalendarEventKind Kind { get; } = kind; public CalendarEventKind Kind { get; } = kind;
public DateOnly Date { get; } = date; public DateOnly Date { get; } = date;
public string Title { get; } = title; public string Title { get; } = title;
public string Subtitle { get; } = subtitle; public string Subtitle { get; } = subtitle;
public Guid GroupId { get; } = groupId; public Guid? GroupId { get; } = groupId;
public string Description { get; } = description;
public string KindLabel => Kind switch public string KindLabel => Kind switch
{ {
CalendarEventKind.Exam => "Klausur", CalendarEventKind.Exam => "Klausur",
CalendarEventKind.ParticipationSession => "Sitzung", CalendarEventKind.ParticipationSession => "Sitzung",
CalendarEventKind.AnnualPlan => "Jahresplan",
_ => "Unterricht" _ => "Unterricht"
}; };
} }
@@ -202,6 +202,14 @@ public partial class SettingsViewModel : ObservableObject
[ObservableProperty] private bool _untisFetchBusy; [ObservableProperty] private bool _untisFetchBusy;
public Func<Task>? OnReviewUntisMapping { get; set; } public Func<Task>? OnReviewUntisMapping { get; set; }
// ── Schulweiter Jahresplan (ClassyPlan-iCal) ─────────────────────────────
[ObservableProperty] private bool _annualPlanIsConfigured;
[ObservableProperty] private string _annualPlanIcalUrlInput = "";
[ObservableProperty] private string _annualPlanUrlError = "";
[ObservableProperty] private string _annualPlanStatusDisplay = "";
[ObservableProperty] private bool _annualPlanFetchBusy;
// ── Synchronisation (Kapitel 10) ────────────────────────────────────────── // ── Synchronisation (Kapitel 10) ──────────────────────────────────────────
[ObservableProperty] private string _syncServerUrl = ""; [ObservableProperty] private string _syncServerUrl = "";
@@ -281,6 +289,8 @@ public partial class SettingsViewModel : ObservableObject
private readonly AiPlanningService _aiPlanning; private readonly AiPlanningService _aiPlanning;
private readonly WebUntisSettingsService _untisSettings; private readonly WebUntisSettingsService _untisSettings;
private readonly UntisSyncService? _untisSync; private readonly UntisSyncService? _untisSync;
private readonly AnnualPlanSettingsService _annualPlanSettings;
private readonly AnnualPlanSyncService? _annualPlanSync;
private readonly SyncSettingsService _syncSettings; private readonly SyncSettingsService _syncSettings;
private readonly SyncAuthService _syncAuth; private readonly SyncAuthService _syncAuth;
private readonly EventQueue _eventQueue; private readonly EventQueue _eventQueue;
@@ -303,11 +313,12 @@ public partial class SettingsViewModel : ObservableObject
ISupervisionDutyRepository supervisionDuties, LetterTemplateService letterTemplates, ISupervisionDutyRepository supervisionDuties, LetterTemplateService letterTemplates,
AiSettingsService aiSettings, AiPlanningService aiPlanning, AiSettingsService aiSettings, AiPlanningService aiPlanning,
WebUntisSettingsService untisSettings, WebUntisSettingsService untisSettings,
AnnualPlanSettingsService annualPlanSettings,
SyncSettingsService syncSettings, SyncAuthService syncAuth, EventQueue eventQueue, SyncSettingsService syncSettings, SyncAuthService syncAuth, EventQueue eventQueue,
AppLogger logger, SyncKeyStatus syncKeyStatus, SyncKeyRecoveryService syncKeyRecovery, AppLogger logger, SyncKeyStatus syncKeyStatus, SyncKeyRecoveryService syncKeyRecovery,
AppearanceSettingsService appearance, TrashViewModel trashTab, AppearanceSettingsService appearance, TrashViewModel trashTab,
SnapshotService? snapshotService = null, SyncEngine? syncEngine = null, SnapshotService? snapshotService = null, SyncEngine? syncEngine = null,
UntisSyncService? untisSync = null) UntisSyncService? untisSync = null, AnnualPlanSyncService? annualPlanSync = null)
{ {
_logger = logger; _logger = logger;
_syncKeyRecovery = syncKeyRecovery; _syncKeyRecovery = syncKeyRecovery;
@@ -337,6 +348,8 @@ public partial class SettingsViewModel : ObservableObject
_aiPlanning = aiPlanning; _aiPlanning = aiPlanning;
_untisSettings = untisSettings; _untisSettings = untisSettings;
_untisSync = untisSync; _untisSync = untisSync;
_annualPlanSettings = annualPlanSettings;
_annualPlanSync = annualPlanSync;
_syncSettings = syncSettings; _syncSettings = syncSettings;
_syncAuth = syncAuth; _syncAuth = syncAuth;
_eventQueue = eventQueue; _eventQueue = eventQueue;
@@ -360,6 +373,7 @@ public partial class SettingsViewModel : ObservableObject
LoadLetterTemplates(); LoadLetterTemplates();
LoadAiSettings(); LoadAiSettings();
LoadUntisSettings(); LoadUntisSettings();
LoadAnnualPlanSettings();
LoadSyncSettings(); LoadSyncSettings();
LoadSyncConflicts(); LoadSyncConflicts();
} }
@@ -557,6 +571,68 @@ public partial class SettingsViewModel : ObservableObject
if (OnReviewUntisMapping is not null) await OnReviewUntisMapping(); if (OnReviewUntisMapping is not null) await OnReviewUntisMapping();
} }
// ── Schulweiter Jahresplan: Laden / Speichern / Entfernen / Jetzt abrufen ─
private void LoadAnnualPlanSettings()
{
AnnualPlanIsConfigured = _annualPlanSettings.IsConfigured;
AnnualPlanStatusDisplay = _annualPlanSettings.LastSyncAt is { } at
? $"Letzter Abgleich: {at.ToLocalTime():dd.MM.yyyy HH:mm} — {_annualPlanSettings.LastSyncStatus}"
: "Noch kein Abgleich durchgeführt.";
}
[RelayCommand]
private void AnnualPlanSaveUrl()
{
AnnualPlanUrlError = "";
if (string.IsNullOrWhiteSpace(AnnualPlanIcalUrlInput))
{
AnnualPlanUrlError = "iCal-URL erforderlich.";
return;
}
if (!Uri.TryCreate(AnnualPlanIcalUrlInput, UriKind.Absolute, out var uri) ||
uri.Scheme is not ("http" or "https"))
{
AnnualPlanUrlError = "Ungültige HTTP(S)-URL.";
return;
}
_annualPlanSettings.SetIcalUrl(AnnualPlanIcalUrlInput.Trim());
_annualPlanSettings.SetEnabled(true);
AnnualPlanIcalUrlInput = "";
AppBootstrapper.RestartApplication();
}
[RelayCommand]
private void AnnualPlanRemove()
{
_annualPlanSync?.Clear();
_annualPlanSettings.ClearIcalUrl();
LoadAnnualPlanSettings();
AppBootstrapper.RestartApplication();
}
[RelayCommand]
private async Task AnnualPlanFetchNow()
{
if (_annualPlanSync is null)
{
AnnualPlanStatusDisplay = "Abgleich nicht aktiv — App neu starten.";
return;
}
AnnualPlanFetchBusy = true;
try
{
await _annualPlanSync.PollAsync();
LoadAnnualPlanSettings();
}
finally
{
AnnualPlanFetchBusy = false;
}
}
// ── Synchronisation: Laden / Anmelden / Abmelden / Verbindungstest ─────── // ── Synchronisation: Laden / Anmelden / Abmelden / Verbindungstest ───────
// //
// Server-URL, Zugangsdaten und Token werden erst nach erfolgreichem Login zusammen // Server-URL, Zugangsdaten und Token werden erst nach erfolgreichem Login zusammen
@@ -214,6 +214,7 @@
HorizontalAlignment="Center" VerticalAlignment="Bottom"> HorizontalAlignment="Center" VerticalAlignment="Bottom">
<Ellipse Width="5" Height="5" Fill="#1E88E5" IsVisible="{Binding HasSession}"/> <Ellipse Width="5" Height="5" Fill="#1E88E5" IsVisible="{Binding HasSession}"/>
<Ellipse Width="5" Height="5" Fill="#E53935" IsVisible="{Binding HasExam}"/> <Ellipse Width="5" Height="5" Fill="#E53935" IsVisible="{Binding HasExam}"/>
<Ellipse Width="5" Height="5" Fill="#FF8A00" IsVisible="{Binding HasAnnualPlanEvent}"/>
</StackPanel> </StackPanel>
</Grid> </Grid>
</Border> </Border>
@@ -222,25 +223,29 @@
</ItemsControl.ItemTemplate> </ItemsControl.ItemTemplate>
</ItemsControl> </ItemsControl>
<StackPanel Orientation="Horizontal" Spacing="14" Margin="0,4,0,0"> <WrapPanel Orientation="Horizontal" Margin="0,4,0,0">
<StackPanel Orientation="Horizontal" Spacing="4"> <StackPanel Orientation="Horizontal" Spacing="4" Margin="0,0,14,4">
<Border Width="10" Height="10" CornerRadius="3" Background="#14808080"/> <Border Width="10" Height="10" CornerRadius="3" Background="#14808080"/>
<TextBlock Text="Unterricht" FontSize="10" Opacity="0.6"/> <TextBlock Text="Unterricht" FontSize="10" Opacity="0.6"/>
</StackPanel> </StackPanel>
<StackPanel Orientation="Horizontal" Spacing="4"> <StackPanel Orientation="Horizontal" Spacing="4" Margin="0,0,14,4">
<Ellipse Width="7" Height="7" Fill="#E53935"/> <Ellipse Width="7" Height="7" Fill="#E53935"/>
<TextBlock Text="Klausur" FontSize="10" Opacity="0.6"/> <TextBlock Text="Klausur" FontSize="10" Opacity="0.6"/>
</StackPanel> </StackPanel>
<StackPanel Orientation="Horizontal" Spacing="4"> <StackPanel Orientation="Horizontal" Spacing="4" Margin="0,0,14,4">
<Ellipse Width="7" Height="7" Fill="#1E88E5"/> <Ellipse Width="7" Height="7" Fill="#1E88E5"/>
<TextBlock Text="Sitzung" FontSize="10" Opacity="0.6"/> <TextBlock Text="Sitzung" FontSize="10" Opacity="0.6"/>
</StackPanel> </StackPanel>
<StackPanel Orientation="Horizontal" Spacing="4"> <StackPanel Orientation="Horizontal" Spacing="4" Margin="0,0,14,4">
<Ellipse Width="7" Height="7" Fill="#FF8A00"/>
<TextBlock Text="Jahresplan" FontSize="10" Opacity="0.6"/>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="4" Margin="0,0,14,4">
<Border Width="10" Height="10" CornerRadius="3" BorderThickness="2" <Border Width="10" Height="10" CornerRadius="3" BorderThickness="2"
BorderBrush="{DynamicResource SystemControlBackgroundAccentBrush}"/> BorderBrush="{DynamicResource SystemControlBackgroundAccentBrush}"/>
<TextBlock Text="Meine Klasse" FontSize="10" Opacity="0.6"/> <TextBlock Text="Meine Klasse" FontSize="10" Opacity="0.6"/>
</StackPanel> </StackPanel>
</StackPanel> </WrapPanel>
<Separator Margin="0,4"/> <Separator Margin="0,4"/>
<TextBlock Text="{Binding SelectedDayLabel}" FontWeight="SemiBold" FontSize="12"/> <TextBlock Text="{Binding SelectedDayLabel}" FontWeight="SemiBold" FontSize="12"/>
@@ -256,6 +261,9 @@
<StackPanel Grid.Column="1"> <StackPanel Grid.Column="1">
<TextBlock Text="{Binding Title}" FontSize="12" FontWeight="SemiBold"/> <TextBlock Text="{Binding Title}" FontSize="12" FontWeight="SemiBold"/>
<TextBlock Text="{Binding Subtitle}" FontSize="10" Opacity="0.6"/> <TextBlock Text="{Binding Subtitle}" FontSize="10" Opacity="0.6"/>
<TextBlock Text="{Binding Description}" FontSize="10" Opacity="0.6"
TextWrapping="Wrap"
IsVisible="{Binding Description, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel> </StackPanel>
</Grid> </Grid>
</Button> </Button>
@@ -921,10 +921,14 @@
</ScrollViewer> </ScrollViewer>
</ContentPage> </ContentPage>
<!-- Tab: WebUntis-iCal-Abgleich (Nutzer-Feedback) --> <!-- Gemeinsamer UI-Tab; beide Importpfade bleiben fachlich und technisch getrennt. -->
<ContentPage Header="Stundenplan-Abgleich"> <ContentPage Header="Untis-Einbettung">
<ScrollViewer> <ScrollViewer>
<StackPanel Margin="32,20,32,28" Spacing="14" MaxWidth="420"> <StackPanel Margin="32,20,32,28" Spacing="14" MaxWidth="460">
<TextBlock Text="Untis-Einbettung" FontSize="18" FontWeight="SemiBold"/>
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
Text="Bindet den persönlichen WebUntis-Stundenplan und den schulweiten Jahresplan als zwei unabhängige iCal-Quellen ein."/>
<TextBlock Text="WebUntis-Stundenplan-Abgleich" FontSize="16" FontWeight="SemiBold"/> <TextBlock Text="WebUntis-Stundenplan-Abgleich" FontSize="16" FontWeight="SemiBold"/>
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap" <TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
@@ -952,6 +956,36 @@
</StackPanel> </StackPanel>
</StackPanel> </StackPanel>
<Separator Margin="0,8"/>
<TextBlock Text="Schulweiter Jahresplan" FontSize="16" FontWeight="SemiBold"/>
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
Text="Importiert Konferenzen, Prüfungszeiträume, Fortbildungen und weitere schulweite Termine aus einem iCal-Feed. Diese Termine werden zusätzlich im Dashboard-Kalender angezeigt und verändern den persönlichen Stundenplan nicht."/>
<StackPanel Spacing="8" IsVisible="{Binding !AnnualPlanIsConfigured}">
<StackPanel Spacing="4">
<TextBlock Text="iCal-URL" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding AnnualPlanIcalUrlInput}" PasswordChar="●"
PlaceholderText="https://…/export.php?type=ics…"/>
</StackPanel>
<TextBlock Text="{Binding AnnualPlanUrlError}" Foreground="Red" FontSize="12"
IsVisible="{Binding AnnualPlanUrlError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Button Content="Speichern und aktivieren" Command="{Binding AnnualPlanSaveUrlCommand}"
HorizontalAlignment="Left"/>
</StackPanel>
<StackPanel Spacing="8" IsVisible="{Binding AnnualPlanIsConfigured}">
<TextBlock Text="Jahresplan-iCal hinterlegt (verschlüsselt gespeichert)."
FontSize="13" FontWeight="SemiBold"/>
<TextBlock Text="{Binding AnnualPlanStatusDisplay}" FontSize="12" Opacity="0.7"
TextWrapping="Wrap"/>
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="Jetzt abrufen" Command="{Binding AnnualPlanFetchNowCommand}"
IsEnabled="{Binding !AnnualPlanFetchBusy}"/>
<Button Content="Entfernen" Command="{Binding AnnualPlanRemoveCommand}"/>
</StackPanel>
</StackPanel>
</StackPanel> </StackPanel>
</ScrollViewer> </ScrollViewer>
</ContentPage> </ContentPage>
@@ -0,0 +1,85 @@
using LehrerApp.Core.Services;
using Xunit;
namespace LehrerApp.Tests;
/// <summary>Synthetische Fixtures nach dem am 23.08.2026 geprüften ClassyPlan-Export.</summary>
public sealed class AnnualPlanIcsParserTests
{
private static string Calendar(params string[] events) =>
"BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:ClassyPlan\n" +
string.Join("", events) + "END:VCALENDAR\n";
[Fact]
public void Parse_Ganztagstermin_NutztExklusivesDtendAlsInklusivesEnddatum()
{
var ics = Calendar(
"BEGIN:VEVENT\nUID:100-ClassyPlan.app\n" +
"DTSTART;VALUE=DATE:20260615\nDTEND;VALUE=DATE:20260620\n" +
"SUMMARY:Klassenfahrt 6a 6b 6d\nX-GROUPNAME:Lehrkräfte\nX-COLOR:#4B9FD8\n" +
"END:VEVENT\n");
var entry = Assert.Single(AnnualPlanIcsParser.Parse(ics));
Assert.True(entry.IsAllDay);
Assert.Equal(new DateOnly(2026, 6, 15), entry.StartDate);
Assert.Equal(new DateOnly(2026, 6, 19), entry.EndDate);
Assert.Null(entry.StartTime);
Assert.Equal("Lehrkräfte", entry.CalendarGroup);
}
[Fact]
public void Parse_UtcTermin_WirdUnterBeruecksichtigungDerSommerzeitNachBerlinKonvertiert()
{
var ics = Calendar(
"BEGIN:VEVENT\nUID:200-ClassyPlan.app\n" +
"DTSTART:20260929T113000Z\nDTEND:20260929T130000Z\n" +
"LAST-MODIFIED:20260814T095236Z\nSEQUENCE:2\nSTATUS:CONFIRMED\n" +
"SUMMARY:Teams 510\nLOCATION:R 101\nX-GROUPNAME:Öffentlich\nEND:VEVENT\n");
var entry = Assert.Single(AnnualPlanIcsParser.Parse(ics));
Assert.False(entry.IsAllDay);
Assert.Equal(new TimeOnly(13, 30), entry.StartTime);
Assert.Equal(new TimeOnly(15, 0), entry.EndTime);
Assert.Equal(2, entry.Sequence);
Assert.Equal(new DateTime(2026, 8, 14, 9, 52, 36, DateTimeKind.Utc),
entry.SourceLastModifiedUtc);
}
[Fact]
public void Parse_EscapingUndZeilenfaltung_WerdenAufgeloest()
{
var ics = Calendar(
"BEGIN:VEVENT\nUID:300-ClassyPlan.app\nDTSTART;VALUE=DATE:20260823\n" +
"DESCRIPTION:Erste Zeile\\nZweite Zeile mit sehr langem \n Text\\, Komma\\; Semikolon\n" +
"SUMMARY:Fortbildung\nEND:VEVENT\n");
var entry = Assert.Single(AnnualPlanIcsParser.Parse(ics));
Assert.Equal("Erste Zeile\nZweite Zeile mit sehr langem Text, Komma; Semikolon",
entry.Description);
}
[Fact]
public void Parse_EinDefektesVevent_LaesstNichtUnbemerktEinenTeilimportZu()
{
var ics = Calendar(
"BEGIN:VEVENT\nUID:gueltig\nDTSTART;VALUE=DATE:20260823\nEND:VEVENT\n",
"BEGIN:VEVENT\nUID:defekt\nSUMMARY:Ohne Datum\nEND:VEVENT\n");
var error = Assert.Throws<FormatException>(() => AnnualPlanIcsParser.Parse(ics));
Assert.Contains("DTSTART fehlt", error.Message);
}
[Fact]
public void Parse_DoppelteUid_WirdAbgelehnt()
{
var ics = Calendar(
"BEGIN:VEVENT\nUID:doppelt\nDTSTART;VALUE=DATE:20260823\nEND:VEVENT\n",
"BEGIN:VEVENT\nUID:doppelt\nDTSTART;VALUE=DATE:20260824\nEND:VEVENT\n");
Assert.Throws<FormatException>(() => AnnualPlanIcsParser.Parse(ics));
}
}
+32
View File
@@ -1160,6 +1160,38 @@ Lerngruppen, die aus mehreren Klassen zusammengesetzt sind." Zwei Ursachen, nach
nur bei diesem konkreten Bugfix, sondern auch bei jeder künftigen Korrektur der Zuordnung durch nur bei diesem konkreten Bugfix, sondern auch bei jeder künftigen Korrektur der Zuordnung durch
den Nutzer selbst. den Nutzer selbst.
**Nachtrag zu 4.3, siebzehnte Iteration (schulweiter ClassyPlan-Jahresplan):** Zusätzlich zum
persönlichen WebUntis-Stundenplan existiert ein schulweiter Jahresplan mit Konferenzen,
Fortbildungen, Abschlussprüfungen, Fahrten und weiteren Terminen. Vor der Implementierung wurden
CSV- und iCal-Export desselben Stands vollständig verglichen: jeweils 562 eindeutige Termine,
keine fehlenden IDs und vollständige Übereinstimmung von Titel, Beschreibung, Ort und Zeit. iCal
ist die Primärquelle, weil es darüber hinaus stabile UIDs, Änderungsmetadaten und für 264 in der
CSV gruppenlose Zeilen die Information `X-GROUPNAME:Öffentlich` liefert. 186 der 562 Einträge sind
echte DATE-Ganztags-/Mehrtagstermine.
- **Strikte fachliche Trennung:** `AnnualPlanEvent` ist reine Kalenderinformation und besitzt
weder `SubstitutionKind` noch Stunden-/Pausennummer oder Lerngruppen-Mapping. Der neue
`AnnualPlanSyncService` greift auf kein Stundenplan-/Vertretungsrepository zu. Erst die
Anzeigeprojektion im Dashboard führt Jahresplan, Unterricht, Klausuren und Sitzungen zusammen.
- **Eigener Parser:** `AnnualPlanIcsParser` unterstützt DATE-Werte mit exklusivem RFC-5545-DTEND,
mehrtägige Termine, UTC→Europe/Berlin, Escaping und Line-Folding. Anders als der fail-soft
WebUntis-Parser bricht er bei jedem ungültigen VEVENT den gesamten Import ab; dadurch kann ein
Teilabruf niemals den übrigen lokalen Jahresplan als "verschwunden" löschen.
- **Lokaler Vollabgleich:** `AnnualPlanEventRepository` wird idempotent über iCal-UID aktualisiert;
erst nach erfolgreichem Vollparse werden nicht mehr enthaltene Termine entfernt. Wie die
Untis-Snapshots feuert der wiederherstellbare externe Cache bewusst kein `db.OnChange` und
erzeugt damit keine hunderte App-Sync-Ereignisse je Abruf. Automatischer Abruf alle sechs
Stunden plus initialer Abruf beim Aufbau des Dashboards.
- **Einstellungen/Anzeige:** Der gemeinsame Tab "Untis-Einbettung" bündelt zwei klar getrennte
Bereiche für persönlichen Stundenplan-Abgleich und schulweiten Jahresplan, damit die
Einstellungsnavigation kompakt bleibt. Der Jahresplanbereich bietet verschlüsselt gespeicherte
iCal-URL, Status, manuellen Abruf und Entfernen inklusive Cache-Bereinigung. Im Dashboard orange
markiert; Mehrtagstermine erscheinen an jedem betroffenen Tag, Detailzeilen zeigen Uhrzeit,
Gruppe, Ort und Beschreibung. Jahresplantermine laufen parallel zum Unterricht und ersetzen
ihn nie.
- Tests decken Parservarianten, strikten Teilimport-Schutz, Snapshot-Idempotenz/-Bereinigung,
verschlüsselte Einstellungen, Bereichsabfragen und die parallele Dashboard-Anzeige ab.
### 4.4 Wochen-/Tagesansicht ### 4.4 Wochen-/Tagesansicht
- [x] **4.4.1** Kalenderansicht über alle Gruppen: Woche und Tag — siehe Nachtrag zu 4.3 - [x] **4.4.1** Kalenderansicht über alle Gruppen: Woche und Tag — siehe Nachtrag zu 4.3
("Heute"-Tab: Tagesliste unten angedockt, gruppenübergreifendes Wochenraster darüber, inkl. ("Heute"-Tab: Tagesliste unten angedockt, gruppenübergreifendes Wochenraster darüber, inkl.