Stundenplan: Wochenraster, Wochennavigation, Ferien/Feiertage (Kapitel 4.3)

Neuer Stundenplan mit "Heute"-Standardansicht (Tagesliste unten angedockt,
gruppenübergreifendes Wochenraster mit Fach/Klasse/Raum/Thema, Vor-/Zurück-
Navigation zwischen Kalenderwochen) und separatem Bearbeiten-Raster für die
wöchentliche Zuordnung. Badges für Ferien-/Klausur-Nähe und ausgegraute
Ferientage direkt im Plan statt einer separaten Liste. Ferien-/Feiertage-
Pflege (Bundesland, Schulferien) sitzt jetzt in den Einstellungen statt im
Stundenplan selbst.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-15 02:29:18 +02:00
co-authored by Claude Sonnet 5
parent ee1a47641e
commit 99b2de6519
26 changed files with 2154 additions and 18 deletions
@@ -85,6 +85,19 @@ public interface ILessonRepository
void Save(Lesson lesson);
void Delete(Guid id);
}
public interface ITimetableSlotRepository
{
List<TimetableSlot> GetAll();
List<TimetableSlot> GetByGroup(Guid groupId);
void Save(TimetableSlot slot);
void Delete(Guid id);
}
public interface ISchoolHolidayRepository
{
List<SchoolHoliday> GetAll();
void Save(SchoolHoliday holiday);
void Delete(Guid id);
}
public interface IDocumentationRepository
{
List<Documentation> GetByStudent(Guid studentId);
+33
View File
@@ -110,6 +110,39 @@ public class AlternativeLessonPath
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
/// <summary>
/// Ein fester Termin im wöchentlichen Stundenplan (4.3): eine Lerngruppe trifft sich an einem
/// Wochentag zu einer bestimmten Stunde. Wiederkehrendes Muster, kein konkretes Datum — für
/// tatsächlich gehaltene Einzelstunden siehe <see cref="Lesson"/>. Pro Wochentag/Stunde ist
/// höchstens eine Gruppe eingetragen (ein Lehrer kann nicht gleichzeitig an zwei Orten sein).
/// </summary>
public class TimetableSlot
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid GroupId { get; set; }
public DayOfWeek Weekday { get; set; }
public int PeriodNumber { get; set; }
public string? Room { get; set; }
}
/// <summary>
/// Unterrichtsfreier Zeitraum (4.3.5): Schulferien werden manuell gepflegt (nicht algorithmisch
/// herleitbar, jährlich neu von den Bundesländern festgelegt). Gesetzliche Feiertage werden
/// dagegen berechnet (PublicHolidayService) und nicht in der Datenbank gespeichert.
/// </summary>
public class SchoolHoliday
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Name { get; set; } = "";
public DateOnly StartDate { get; set; }
public DateOnly EndDate { get; set; }
}
public enum GermanState
{
BW, BY, BE, BB, HB, HH, HE, MV, NI, NW, RP, SL, SN, ST, SH, TH
}
/// <summary>
/// Zeugnisnote eines Schülers in einer Lerngruppe für einen Zeitraum (Halbjahr/Gesamtjahr).
/// <see cref="CalculatedValue"/> ist das zuletzt berechnete Ergebnis; <see cref="OverrideValue"/>
@@ -0,0 +1,79 @@
using LehrerApp.Core.Models;
namespace LehrerApp.Core.Services;
public record PublicHoliday(DateOnly Date, string Name);
/// <summary>
/// Berechnet die gesetzlichen Feiertage eines Bundeslands für ein Kalenderjahr (4.3.5).
/// Im Gegensatz zu Schulferien sind Feiertage über eine feste Regel je Bundesland herleitbar und
/// werden deshalb nicht in der Datenbank gespeichert.
/// </summary>
public class PublicHolidayService
{
public List<PublicHoliday> GetHolidays(int year, GermanState state)
{
var easterSunday = EasterSunday(year);
var holidays = new List<PublicHoliday>
{
new(new DateOnly(year, 1, 1), "Neujahr"),
new(easterSunday.AddDays(-2), "Karfreitag"),
new(easterSunday.AddDays(1), "Ostermontag"),
new(new DateOnly(year, 5, 1), "Tag der Arbeit"),
new(easterSunday.AddDays(39), "Christi Himmelfahrt"),
new(easterSunday.AddDays(50), "Pfingstmontag"),
new(new DateOnly(year, 10, 3), "Tag der Deutschen Einheit"),
new(new DateOnly(year, 12, 25), "1. Weihnachtstag"),
new(new DateOnly(year, 12, 26), "2. Weihnachtstag"),
};
if (state is GermanState.BW or GermanState.BY or GermanState.ST)
holidays.Add(new(new DateOnly(year, 1, 6), "Heilige Drei Könige"));
if (state is GermanState.BE)
holidays.Add(new(new DateOnly(year, 3, 8), "Internationaler Frauentag"));
if (state is GermanState.BW or GermanState.BY or GermanState.HE or GermanState.NW
or GermanState.RP or GermanState.SL)
holidays.Add(new(easterSunday.AddDays(60), "Fronleichnam"));
if (state is GermanState.SL)
holidays.Add(new(new DateOnly(year, 8, 15), "Mariä Himmelfahrt"));
if (state is GermanState.BB or GermanState.MV or GermanState.SN or GermanState.ST
or GermanState.TH or GermanState.HB or GermanState.HH or GermanState.NI
or GermanState.SH)
holidays.Add(new(new DateOnly(year, 10, 31), "Reformationstag"));
if (state is GermanState.BW or GermanState.BY or GermanState.NW or GermanState.RP
or GermanState.SL)
holidays.Add(new(new DateOnly(year, 11, 1), "Allerheiligen"));
if (state is GermanState.SN)
holidays.Add(new(BussUndBettag(year), "Buß- und Bettag"));
return holidays.OrderBy(h => h.Date).ToList();
}
/// Gauß'sche Osterformel.
private static DateOnly EasterSunday(int year)
{
int a = year % 19, b = year / 100, c = year % 100;
int d = b / 4, e = b % 4, f = (b + 8) / 25, g = (b - f + 1) / 3;
int h = (19 * a + b - d - g + 15) % 30;
int i = c / 4, k = c % 4, l = (32 + 2 * e + 2 * i - h - k) % 7;
int m = (a + 11 * h + 22 * l) / 451;
int month = (h + l - 7 * m + 114) / 31;
int day = (h + l - 7 * m + 114) % 31 + 1;
return new DateOnly(year, month, day);
}
/// Buß- und Bettag: Mittwoch vor dem 23. November (entspricht dem letzten Mittwoch vor dem
/// ersten Advent).
private static DateOnly BussUndBettag(int year)
{
var date = new DateOnly(year, 11, 23);
while (date.DayOfWeek != DayOfWeek.Wednesday) date = date.AddDays(-1);
return date;
}
}
@@ -0,0 +1,42 @@
using System.Text.Json;
using LehrerApp.Core.Models;
namespace LehrerApp.Core.Services;
internal class SchoolCalendarConfig
{
public GermanState State { get; set; } = GermanState.NW;
}
/// <summary>Welches Bundesland für die Feiertagsberechnung (4.3.5) gilt.</summary>
public class SchoolCalendarSettingsService
{
private readonly string _configPath;
private SchoolCalendarConfig _config;
public GermanState State => _config.State;
public SchoolCalendarSettingsService(string appDataPath)
{
_configPath = Path.Combine(appDataPath, "schoolcalendar.json");
_config = Load();
}
public void SetState(GermanState state)
{
_config.State = state;
File.WriteAllText(_configPath, JsonSerializer.Serialize(_config));
}
private SchoolCalendarConfig Load()
{
try
{
if (File.Exists(_configPath))
return JsonSerializer.Deserialize<SchoolCalendarConfig>(File.ReadAllText(_configPath))
?? new SchoolCalendarConfig();
}
catch { /* beschädigte Konfiguration -> Standardwert */ }
return new SchoolCalendarConfig();
}
}
+59
View File
@@ -529,4 +529,63 @@ public sealed class RepositoryTests
Assert.Empty(repo.GetAll());
}
// ── TimetableSlotRepository ───────────────────────────────────────────────
[Fact]
public void TimetableSlotRepository_GetByGroup_FindetNurSlotsDerGruppe()
{
using var db = NewInMemoryContext();
var repo = new TimetableSlotRepository(db);
var groupA = Guid.NewGuid();
var groupB = Guid.NewGuid();
repo.Save(new TimetableSlot { GroupId = groupA, Weekday = DayOfWeek.Monday, PeriodNumber = 1 });
repo.Save(new TimetableSlot { GroupId = groupB, Weekday = DayOfWeek.Monday, PeriodNumber = 2 });
var result = repo.GetByGroup(groupA);
Assert.Single(result);
Assert.Equal(groupA, result[0].GroupId);
}
[Fact]
public void TimetableSlotRepository_Save_LehntDoppelbelegungDerselbenStundeAb()
{
using var db = NewInMemoryContext();
var repo = new TimetableSlotRepository(db);
repo.Save(new TimetableSlot { GroupId = Guid.NewGuid(), Weekday = DayOfWeek.Tuesday, PeriodNumber = 3 });
Assert.Throws<InvalidOperationException>(() =>
repo.Save(new TimetableSlot { GroupId = Guid.NewGuid(), Weekday = DayOfWeek.Tuesday, PeriodNumber = 3 }));
}
[Fact]
public void TimetableSlotRepository_Save_AktualisierenDesselbenSlotsIstErlaubt()
{
using var db = NewInMemoryContext();
var repo = new TimetableSlotRepository(db);
var slot = new TimetableSlot { GroupId = Guid.NewGuid(), Weekday = DayOfWeek.Tuesday, PeriodNumber = 3 };
repo.Save(slot);
slot.Room = "R204";
repo.Save(slot);
Assert.Equal("R204", repo.GetAll().Single().Room);
}
// ── SchoolHolidayRepository ───────────────────────────────────────────────
[Fact]
public void SchoolHolidayRepository_GetAll_SortiertNachStartdatum()
{
using var db = NewInMemoryContext();
var repo = new SchoolHolidayRepository(db);
repo.Save(new SchoolHoliday { Name = "Sommerferien", StartDate = new DateOnly(2026, 7, 1), EndDate = new DateOnly(2026, 8, 10) });
repo.Save(new SchoolHoliday { Name = "Osterferien", StartDate = new DateOnly(2026, 3, 30), EndDate = new DateOnly(2026, 4, 10) });
var result = repo.GetAll();
Assert.Equal("Osterferien", result[0].Name);
Assert.Equal("Sommerferien", result[1].Name);
}
}
+5
View File
@@ -57,6 +57,8 @@ public class LiteDbContext : IDisposable
public ILiteCollection<CompetencyDomain> CompetencyDomains => _db.GetCollection<CompetencyDomain>("competency_domains");
public ILiteCollection<ShorthandCode> ShorthandCodes => _db.GetCollection<ShorthandCode>("shorthand_codes");
public ILiteCollection<AlternativeLessonPath> AlternativeLessonPaths => _db.GetCollection<AlternativeLessonPath>("alternative_lesson_paths");
public ILiteCollection<TimetableSlot> TimetableSlots => _db.GetCollection<TimetableSlot>("timetable_slots");
public ILiteCollection<SchoolHoliday> SchoolHolidays => _db.GetCollection<SchoolHoliday>("school_holidays");
public void Checkpoint() => _db.Checkpoint();
@@ -369,6 +371,9 @@ public class LiteDbContext : IDisposable
CompetencyDomains.EnsureIndex(x => x.GradeLevel);
ShorthandCodes.EnsureIndex("ux_shorthand_code", BsonExpression.Create("LOWER(TRIM($.Code))"), unique: true);
AlternativeLessonPaths.EnsureIndex("ux_alt_lesson_path_name", BsonExpression.Create("LOWER(TRIM($.Name))"), unique: true);
TimetableSlots.EnsureIndex(x => x.GroupId);
TimetableSlots.EnsureIndex("ux_timetable_weekday_period",
BsonExpression.Create("STRING($.Weekday) + ':' + STRING($.PeriodNumber)"), unique: true);
}
public void Dispose() => _db.Dispose();
@@ -386,6 +386,30 @@ public class AlternativeLessonPathRepository(LiteDbContext db) : IAlternativeLes
public void Delete(Guid id) => db.AlternativeLessonPaths.Delete(id);
}
public class TimetableSlotRepository(LiteDbContext db) : ITimetableSlotRepository
{
public List<TimetableSlot> GetAll() =>
db.TimetableSlots.FindAll().OrderBy(s => s.Weekday).ThenBy(s => s.PeriodNumber).ToList();
public List<TimetableSlot> GetByGroup(Guid groupId) =>
db.TimetableSlots.Find(s => s.GroupId == groupId).OrderBy(s => s.Weekday).ThenBy(s => s.PeriodNumber).ToList();
public void Save(TimetableSlot slot)
{
var occupied = db.TimetableSlots.FindAll()
.FirstOrDefault(s => s.Weekday == slot.Weekday && s.PeriodNumber == slot.PeriodNumber);
if (occupied is not null && occupied.Id != slot.Id)
throw new InvalidOperationException("Diese Stunde ist bereits belegt.");
db.TimetableSlots.Upsert(slot);
}
public void Delete(Guid id) => db.TimetableSlots.Delete(id);
}
public class SchoolHolidayRepository(LiteDbContext db) : ISchoolHolidayRepository
{
public List<SchoolHoliday> GetAll() => db.SchoolHolidays.FindAll().OrderBy(h => h.StartDate).ToList();
public void Save(SchoolHoliday holiday) => db.SchoolHolidays.Upsert(holiday);
public void Delete(Guid id) => db.SchoolHolidays.Delete(id);
}
public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRepository
{
public List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel) =>
+37
View File
@@ -98,6 +98,17 @@ public class FakeResults : IExamResultRepository
public void SaveMany(List<ExamResult> results) { }
}
public class FakeGradingKeyTemplates : IGradingKeyTemplateRepository
{
private readonly List<GradingKeyTemplate> _all = [];
public List<GradingKeyTemplate> GetAll() => _all;
public List<GradingKeyTemplate> GetByGradingSystem(GradingSystem system) =>
_all.Where(t => t.GradingSystem == system).ToList();
public GradingKeyTemplate? GetById(Guid id) => _all.FirstOrDefault(t => t.Id == id);
public void Save(GradingKeyTemplate template) { _all.RemoveAll(t => t.Id == template.Id); _all.Add(template); }
public void Delete(Guid id) => _all.RemoveAll(t => t.Id == id);
}
public class FakeSchemes : IGradingSchemeRepository
{
private readonly Dictionary<Guid, GradingScheme> _byGroup = [];
@@ -206,6 +217,32 @@ public class FakeAlternativeLessonPaths(List<AlternativeLessonPath> all) : IAlte
public void Delete(Guid id) => all.RemoveAll(p => p.Id == id);
}
public class FakeTimetableSlots : ITimetableSlotRepository
{
private readonly List<TimetableSlot> _all = [];
public void Add(TimetableSlot s) => _all.Add(s);
public List<TimetableSlot> GetAll() => _all.ToList();
public List<TimetableSlot> GetByGroup(Guid groupId) => _all.Where(s => s.GroupId == groupId).ToList();
public void Save(TimetableSlot slot)
{
var occupied = _all.FirstOrDefault(s => s.Weekday == slot.Weekday && s.PeriodNumber == slot.PeriodNumber);
if (occupied is not null && occupied.Id != slot.Id)
throw new InvalidOperationException("Diese Stunde ist bereits belegt.");
_all.RemoveAll(s => s.Id == slot.Id);
_all.Add(slot);
}
public void Delete(Guid id) => _all.RemoveAll(s => s.Id == id);
}
public class FakeSchoolHolidays : ISchoolHolidayRepository
{
private readonly List<SchoolHoliday> _all = [];
public void Add(SchoolHoliday h) => _all.Add(h);
public List<SchoolHoliday> GetAll() => _all.ToList();
public void Save(SchoolHoliday holiday) { _all.RemoveAll(h => h.Id == holiday.Id); _all.Add(holiday); }
public void Delete(Guid id) => _all.RemoveAll(h => h.Id == id);
}
public class FakeReportGrades : IReportGradeRepository
{
private readonly List<ReportGrade> _all = [];
@@ -0,0 +1,106 @@
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Data;
using LehrerApp.Desktop.ViewModels.Settings;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class SettingsViewModelTests
{
private static SettingsViewModel BuildViewModel(FakeSchoolHolidays? holidays = null)
{
// Bewusst kein "using": SchoolCalendarSettingsService liest den Pfad erst bei SetState,
// das Verzeichnis muss über die Lebensdauer des ViewModels bestehen bleiben.
var tempPath = System.IO.Path.Combine(
System.IO.Path.GetTempPath(), $"lehrerapp-settingsvm-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempPath);
return new SettingsViewModel(
new FakeSubjects([]), new FakeCompetencyDomains(), new FakeGradingKeyTemplates(),
new FakeSchemes(), new GradingService(), new BackupService(tempPath),
new DatabaseEncryptionService(), new AppLockService(tempPath),
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
holidays ?? new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath));
}
[Fact]
public void AddSchoolHoliday_GueltigeEingabe_WirdGespeichertUndInListeAngezeigt()
{
var holidays = new FakeSchoolHolidays();
var vm = BuildViewModel(holidays);
vm.NewHolidayName = "Sommerferien";
vm.NewHolidayStartText = "01.07.2026";
vm.NewHolidayEndText = "10.08.2026";
vm.AddSchoolHolidayCommand.Execute(null);
Assert.Single(holidays.GetAll());
Assert.Single(vm.SchoolHolidayEntries);
}
[Fact]
public void AddSchoolHoliday_EndeVorBeginn_SetztFehlerUndSpeichertNicht()
{
var holidays = new FakeSchoolHolidays();
var vm = BuildViewModel(holidays);
vm.NewHolidayName = "Ungültig";
vm.NewHolidayStartText = "10.08.2026";
vm.NewHolidayEndText = "01.07.2026";
vm.AddSchoolHolidayCommand.Execute(null);
Assert.Empty(holidays.GetAll());
Assert.NotEqual("", vm.HolidayDateError);
}
[Fact]
public void AddSchoolHoliday_FehlenderName_SetztFehlerUndSpeichertNicht()
{
var holidays = new FakeSchoolHolidays();
var vm = BuildViewModel(holidays);
vm.NewHolidayStartText = "01.07.2026";
vm.NewHolidayEndText = "10.08.2026";
vm.AddSchoolHolidayCommand.Execute(null);
Assert.Empty(holidays.GetAll());
Assert.NotEqual("", vm.HolidayNameError);
}
[Fact]
public void RemoveSchoolHoliday_EntferntEintragAusRepositoryUndListe()
{
var holidays = new FakeSchoolHolidays();
var holiday = new SchoolHoliday { Name = "Herbstferien", StartDate = new DateOnly(2026, 10, 12), EndDate = new DateOnly(2026, 10, 24) };
holidays.Add(holiday);
var vm = BuildViewModel(holidays);
vm.RemoveSchoolHolidayCommand.Execute(vm.SchoolHolidayEntries[0]);
Assert.Empty(holidays.GetAll());
Assert.Empty(vm.SchoolHolidayEntries);
}
[Fact]
public void SelectedStateName_Aendern_PersistiertUeberSchoolCalendarSettings()
{
var tempPath = System.IO.Path.Combine(
System.IO.Path.GetTempPath(), $"lehrerapp-settingsvm-state-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempPath);
var calendarSettings = new SchoolCalendarSettingsService(tempPath);
var vm = new SettingsViewModel(
new FakeSubjects([]), new FakeCompetencyDomains(), new FakeGradingKeyTemplates(),
new FakeSchemes(), new GradingService(), new BackupService(tempPath),
new DatabaseEncryptionService(), new AppLockService(tempPath),
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
new FakeSchoolHolidays(), calendarSettings);
vm.SelectedStateName = "Bayern";
Assert.Equal(GermanState.BY, calendarSettings.State);
}
}
@@ -0,0 +1,78 @@
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels.Planning;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class TimetableSlotDialogViewModelTests
{
private static SchoolYearService SchoolYear() => new();
[Fact]
public void Save_OhneAusgewaehlteGruppe_SetztFehlerUndSpeichertNicht()
{
var slots = new FakeTimetableSlots();
var groups = new FakeGroups([]);
var vm = new TimetableSlotDialogViewModel(slots, groups, SchoolYear(), DayOfWeek.Monday, 1, null);
vm.SaveCommand.Execute(null);
Assert.Null(vm.Result);
Assert.NotEqual("", vm.GroupError);
}
[Fact]
public void Save_MitAusgewaehlterGruppe_LegtNeuenSlotAn()
{
var group = new LearningGroup { Name = "Q1 Chemie" };
var slots = new FakeTimetableSlots();
var groups = new FakeGroups([group]);
var vm = new TimetableSlotDialogViewModel(slots, groups, SchoolYear(), DayOfWeek.Monday, 1, null)
{
SelectedGroupName = "Q1 Chemie", Room = "R204",
};
vm.SaveCommand.Execute(null);
Assert.NotNull(vm.Result);
Assert.Equal(group.Id, vm.Result!.GroupId);
Assert.Equal("R204", vm.Result.Room);
Assert.Single(slots.GetAll());
}
[Fact]
public void Save_BelegteStunde_ZeigtFreundlicheFehlermeldungStattAbsturz()
{
var groupA = new LearningGroup { Name = "Q1 Chemie" };
var groupB = new LearningGroup { Name = "Q1 Physik" };
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = groupA.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 1 });
var groups = new FakeGroups([groupA, groupB]);
var vm = new TimetableSlotDialogViewModel(slots, groups, SchoolYear(), DayOfWeek.Monday, 1, null)
{
SelectedGroupName = "Q1 Physik",
};
vm.SaveCommand.Execute(null);
Assert.Null(vm.Result);
Assert.Equal("Diese Stunde ist bereits belegt.", vm.GroupError);
}
[Fact]
public void Delete_BeimBearbeitenEinesVorhandenenSlots_EntferntIhn()
{
var group = new LearningGroup { Name = "Q1 Chemie" };
var slot = new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 1 };
var slots = new FakeTimetableSlots();
slots.Add(slot);
var groups = new FakeGroups([group]);
var vm = new TimetableSlotDialogViewModel(slots, groups, SchoolYear(), DayOfWeek.Monday, 1, slot);
vm.DeleteCommand.Execute(null);
Assert.True(vm.Deleted);
Assert.Empty(slots.GetAll());
}
}
@@ -0,0 +1,413 @@
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels.Planning;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class TimetableViewModelTests
{
private static TimetableViewModel BuildViewModel(
FakeTimetableSlots slots, FakeGroups groups, FakeSchoolHolidays? holidays = null,
FakeSubjects? subjects = null, FakeLessons? lessons = null, FakeExams? exams = null,
SchoolCalendarSettingsService? calendarSettings = null)
{
// Bewusst kein "using": SchoolCalendarSettingsService liest den Pfad nur bei Bedarf
// (SetState), das Verzeichnis muss über die Lebensdauer des ViewModels bestehen bleiben.
var tempPath = System.IO.Path.Combine(
System.IO.Path.GetTempPath(), $"lehrerapp-timetablevm-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempPath);
return new TimetableViewModel(
slots, groups, subjects ?? new FakeSubjects([]),
lessons ?? new FakeLessons(), exams ?? new FakeExams([]),
holidays ?? new FakeSchoolHolidays(),
calendarSettings ?? new SchoolCalendarSettingsService(tempPath),
new PublicHolidayService(), new SchoolYearService());
}
/// Nächstes Datum ab (inkl.) <paramref name="from"/>, das auf einen Wochentag Mo-Fr fällt —
/// nur diese Tage haben eine Spalte im Raster. Für Badge-Tests reicht ein beliebiger
/// Mo-Fr-Wochentag: die Badges hängen nur am Wochentag (nicht am konkreten Kalenderdatum),
/// da `TimetableSlot` ein wiederkehrendes Muster ohne Datum ist.
private static DateOnly NextGridWeekday(DateOnly from)
{
var d = from;
while (d.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday) d = d.AddDays(1);
return d;
}
/// Datum des angegebenen Wochentags in der laufenden Kalenderwoche — exakt wie
/// `TimetableViewModel.BuildWeekOverview` es berechnet, für Tests der Wochenkachel-Inhalte
/// (Thema, Klausur am Tag), die anders als die Badges an ein konkretes Datum gebunden sind.
private static DateOnly DateInCurrentWeek(DayOfWeek weekday)
{
var today = DateOnly.FromDateTime(DateTime.Today);
var monday = today.AddDays(-((int)today.DayOfWeek + 6) % 7);
return monday.AddDays((int)weekday - (int)DayOfWeek.Monday);
}
[Fact]
public void Load_BautEineZelleProWochentagUndStundePlusKopfzeilen()
{
var vm = BuildViewModel(new FakeTimetableSlots(), new FakeGroups([]));
// 1 Ecke + 5 Wochentage + 10 Stunden × (1 Label + 5 Zellen)
Assert.Equal(6 + 10 * 6, vm.Cells.Count);
Assert.Equal(6 + 10 * 6, vm.WeekItems.Count);
}
[Fact]
public void Load_ZeigtZugewieseneGruppeInDerPassendenZelle()
{
var weekday = NextGridWeekday(DateOnly.FromDateTime(DateTime.Today));
var group = new LearningGroup { Name = "Q1 Chemie" };
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = weekday.DayOfWeek, PeriodNumber = 1, Room = "R204" });
var vm = BuildViewModel(slots, new FakeGroups([group]));
var cell = vm.Cells.Single(c => c.IsSlotCell && c.Weekday == weekday.DayOfWeek && c.PeriodNumber == 1);
Assert.True(cell.IsAssigned);
Assert.Equal("Q1 Chemie · R204", cell.Text);
}
[Fact]
public void HoursWarnings_WeichtDieZugewieseneStundenzahlAb_ZeigtWarnung()
{
var group = new LearningGroup { Name = "Q1 Chemie", SchoolYear = new SchoolYearService().CurrentSchoolYear(), HoursPerWeek = 3 };
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 1 });
var vm = BuildViewModel(slots, new FakeGroups([group]));
Assert.Single(vm.HoursWarnings);
Assert.Contains("1 von 3", vm.HoursWarnings[0].Text);
}
[Fact]
public void HoursWarnings_PassendeStundenzahl_KeineWarnung()
{
var group = new LearningGroup { Name = "Q1 Chemie", SchoolYear = new SchoolYearService().CurrentSchoolYear(), HoursPerWeek = 1 };
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 1 });
var vm = BuildViewModel(slots, new FakeGroups([group]));
Assert.Empty(vm.HoursWarnings);
}
// ── "Heute"-Ansicht: Tagesliste (Nutzer-Feedback: nicht-editierende Standardansicht) ────
[Fact]
public void Load_ZeigtHeutigeStundeMitGruppeUndRaum()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var group = new LearningGroup { Name = "Q1 Chemie" };
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 3, Room = "R204" });
var vm = BuildViewModel(slots, new FakeGroups([group]));
var item = Assert.Single(vm.TodayItems);
Assert.Equal("Q1 Chemie", item.GroupName);
Assert.Equal("R204", item.Room);
Assert.Equal(3, item.PeriodNumber);
}
[Fact]
public void Load_HeutigeStundeMitLektionUndKlausur_ZeigtBeides()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var group = new LearningGroup { Name = "Q1 Chemie" };
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 });
var lessons = new FakeLessons();
lessons.Add(new Lesson { GroupId = group.Id, Date = today, Topic = "Redoxreaktionen" });
var exams = new FakeExams([new Exam { GroupId = group.Id, Date = today, Title = "Klausur Nr. 2" }]);
var vm = BuildViewModel(slots, new FakeGroups([group]), lessons: lessons, exams: exams);
var item = Assert.Single(vm.TodayItems);
Assert.Equal("Redoxreaktionen", item.LessonTopic);
Assert.Equal("Klausur Nr. 2", item.ExamTitle);
Assert.True(item.HasExam);
}
[Fact]
public void OpenGroup_RuftOnNavigateToGroupMitDerGroupIdAuf()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var group = new LearningGroup { Name = "Q1 Chemie" };
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 });
var vm = BuildViewModel(slots, new FakeGroups([group]));
Guid? navigatedTo = null;
vm.OnNavigateToGroup = id => navigatedTo = id;
vm.OpenGroupCommand.Execute(vm.TodayItems[0].GroupId);
Assert.Equal(group.Id, navigatedTo);
}
[Fact]
public void ShowEditor_SchaltetAufBearbeiten_Tab()
{
var vm = BuildViewModel(new FakeTimetableSlots(), new FakeGroups([]));
vm.ShowEditorCommand.Execute(null);
Assert.Equal(1, vm.ActiveTabIndex);
}
// ── "Heute"-Ansicht: Wochenraster (Nutzer-Feedback, zweite Iteration) ────────────────────
[Fact]
public void Load_Wochenkachel_ZeigtFachKlasseRaumUndThema()
{
var subject = new Subject { Name = "Chemie", ShortName = "Ch" };
var group = new LearningGroup { Name = "Q1 Chemie", SubjectId = subject.Id };
var date = DateInCurrentWeek(DayOfWeek.Monday);
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 1, Room = "R204" });
var lessons = new FakeLessons();
lessons.Add(new Lesson { GroupId = group.Id, Date = date, Topic = "Redoxreaktionen" });
var vm = BuildViewModel(slots, new FakeGroups([group]), subjects: new FakeSubjects([subject]), lessons: lessons);
var cell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == DayOfWeek.Monday && c.PeriodNumber == 1);
Assert.Equal("Ch", cell.SubjectLabel);
Assert.Equal("Q1 Chemie", cell.GroupName);
Assert.Equal("R204", cell.Room);
Assert.Equal("Redoxreaktionen", cell.Topic);
}
[Fact]
public void Load_Wochenkachel_UnbelegteZelle_IstNichtZugewiesen()
{
var vm = BuildViewModel(new FakeTimetableSlots(), new FakeGroups([]));
var cell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == DayOfWeek.Monday && c.PeriodNumber == 1);
Assert.False(cell.IsAssigned);
}
[Fact]
public void Load_Wochenkachel_KlausurAmTag_ZeigtKlausurIcon()
{
var group = new LearningGroup { Name = "Q1 Chemie" };
var date = DateInCurrentWeek(DayOfWeek.Monday);
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 1 });
var exams = new FakeExams([new Exam { GroupId = group.Id, Date = date, Title = "Klausur" }]);
var vm = BuildViewModel(slots, new FakeGroups([group]), exams: exams);
var cell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == DayOfWeek.Monday && c.PeriodNumber == 1);
Assert.True(cell.HasExam);
}
[Fact]
public void Load_Wochenkachel_TagInSchulferien_WirdAusgegraut()
{
var group = new LearningGroup { Name = "Q1 Chemie" };
var date = DateInCurrentWeek(DayOfWeek.Monday);
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 1 });
var holidays = new FakeSchoolHolidays();
holidays.Add(new SchoolHoliday { Name = "Ferien", StartDate = date, EndDate = date.AddDays(5) });
var vm = BuildViewModel(slots, new FakeGroups([group]), holidays);
var cell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == DayOfWeek.Monday && c.PeriodNumber == 1);
Assert.True(cell.IsHoliday);
Assert.Equal("#BDBDBD", cell.ColorHex);
}
[Fact]
public void Load_Wochenkachel_TagAusserhalbVonFerien_WirdNichtAusgegraut()
{
var group = new LearningGroup { Name = "Q1 Chemie" };
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 1 });
var vm = BuildViewModel(slots, new FakeGroups([group]));
var cell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == DayOfWeek.Monday && c.PeriodNumber == 1);
Assert.False(cell.IsHoliday);
}
[Fact]
public void Load_Wochenkachel_ExperimentInPhaseGeplant_ZeigtExperimentIcon()
{
var group = new LearningGroup { Name = "Q1 Chemie" };
var date = DateInCurrentWeek(DayOfWeek.Monday);
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 1 });
var lessons = new FakeLessons();
lessons.Add(new Lesson { GroupId = group.Id, Date = date, Phases = [new LessonPhaseStep { Activity = "Experiment: Redoxreihe" }] });
var vm = BuildViewModel(slots, new FakeGroups([group]), lessons: lessons);
var cell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == DayOfWeek.Monday && c.PeriodNumber == 1);
Assert.True(cell.HasExperiment);
}
[Fact]
public void Load_Wochenkachel_OhneExperimentErwaehnung_KeinExperimentIcon()
{
var group = new LearningGroup { Name = "Q1 Chemie" };
var date = DateInCurrentWeek(DayOfWeek.Monday);
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 1 });
var lessons = new FakeLessons();
lessons.Add(new Lesson { GroupId = group.Id, Date = date, Phases = [new LessonPhaseStep { Activity = "Stillarbeit" }] });
var vm = BuildViewModel(slots, new FakeGroups([group]), lessons: lessons);
var cell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == DayOfWeek.Monday && c.PeriodNumber == 1);
Assert.False(cell.HasExperiment);
}
[Fact]
public void Load_Wochenkachel_LetzteStundeVorKlausur_ZeigtIcon()
{
var date = DateInCurrentWeek(DayOfWeek.Monday);
var group = new LearningGroup { Name = "Q1 Chemie" };
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 1 });
var exams = new FakeExams([new Exam { GroupId = group.Id, Date = date.AddDays(1), Title = "Klausur" }]);
var vm = BuildViewModel(slots, new FakeGroups([group]), exams: exams);
var cell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == DayOfWeek.Monday && c.PeriodNumber == 1);
Assert.True(cell.IsLastBeforeExam);
}
[Fact]
public void Load_Wochenkachel_KeineAnstehendeKlausur_KeinIcon()
{
var group = new LearningGroup { Name = "Q1 Chemie" };
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 1 });
var vm = BuildViewModel(slots, new FakeGroups([group]));
var cell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == DayOfWeek.Monday && c.PeriodNumber == 1);
Assert.False(cell.IsLastBeforeExam);
}
[Fact]
public void PreviousWeek_ZeigtVorherigeKalenderwoche()
{
var vm = BuildViewModel(new FakeTimetableSlots(), new FakeGroups([]));
var initialLabel = vm.WeekRangeLabel;
vm.PreviousWeekCommand.Execute(null);
Assert.NotEqual(initialLabel, vm.WeekRangeLabel);
Assert.False(vm.IsCurrentWeek);
}
[Fact]
public void NextWeek_GefolgtVonCurrentWeek_KehrtZurLaufendenWocheZurueck()
{
var vm = BuildViewModel(new FakeTimetableSlots(), new FakeGroups([]));
var initialLabel = vm.WeekRangeLabel;
vm.NextWeekCommand.Execute(null);
Assert.NotEqual(initialLabel, vm.WeekRangeLabel);
vm.CurrentWeekCommand.Execute(null);
Assert.Equal(initialLabel, vm.WeekRangeLabel);
Assert.True(vm.IsCurrentWeek);
}
[Fact]
public void NextWeek_BadgeGehoertZurAngezeigtenWoche_NichtZuHeute()
{
// Klausur liegt in der übernächsten Woche relativ zu "heute" — im Wochenraster einer
// Woche dahinter (also der übernächsten Woche selbst) muss "letzte Stunde vor Klausur"
// erscheinen, nicht bereits in der aktuellen Woche.
var group = new LearningGroup { Name = "Q1 Chemie" };
var currentWeekMonday = DateInCurrentWeek(DayOfWeek.Monday);
var examWeekMonday = currentWeekMonday.AddDays(14);
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 1 });
var exams = new FakeExams([new Exam { GroupId = group.Id, Date = examWeekMonday.AddDays(1), Title = "Klausur" }]);
var vm = BuildViewModel(slots, new FakeGroups([group]), exams: exams);
var currentWeekCell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == DayOfWeek.Monday && c.PeriodNumber == 1);
Assert.False(currentWeekCell.IsLastBeforeExam);
vm.NextWeekCommand.Execute(null);
vm.NextWeekCommand.Execute(null);
var examWeekCell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == DayOfWeek.Monday && c.PeriodNumber == 1);
Assert.True(examWeekCell.IsLastBeforeExam);
}
// ── Badge: letzte/vorletzte Stunde vor Ferien (Bearbeiten-Raster) ────────────────────────
[Fact]
public void Load_LetzteStundeVorFerien_ZeigtBadge1()
{
var weekday = NextGridWeekday(DateOnly.FromDateTime(DateTime.Today));
var group = new LearningGroup { Name = "Q1 Chemie" };
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = weekday.DayOfWeek, PeriodNumber = 1 });
var holidays = new FakeSchoolHolidays();
holidays.Add(new SchoolHoliday { Name = "Ferien", StartDate = weekday.AddDays(1), EndDate = weekday.AddDays(10) });
var vm = BuildViewModel(slots, new FakeGroups([group]), holidays);
var cell = vm.Cells.Single(c => c.IsSlotCell && c.Weekday == weekday.DayOfWeek && c.PeriodNumber == 1);
Assert.Equal("1", cell.BadgeText);
}
[Fact]
public void Load_Wochenkachel_LetzteStundeVorFerien_ZeigtBadge1()
{
var date = DateInCurrentWeek(DayOfWeek.Monday);
var group = new LearningGroup { Name = "Q1 Chemie" };
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 1 });
var holidays = new FakeSchoolHolidays();
holidays.Add(new SchoolHoliday { Name = "Ferien", StartDate = date.AddDays(1), EndDate = date.AddDays(10) });
var vm = BuildViewModel(slots, new FakeGroups([group]), holidays);
var weekCell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == DayOfWeek.Monday && c.PeriodNumber == 1);
Assert.Equal("1", weekCell.HolidayBadge);
}
[Fact]
public void Load_VorletzteStundeVorFerien_ZeigtBadge2()
{
var firstOccurrence = NextGridWeekday(DateOnly.FromDateTime(DateTime.Today));
var secondOccurrence = firstOccurrence.AddDays(7);
var group = new LearningGroup { Name = "Q1 Chemie" };
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = firstOccurrence.DayOfWeek, PeriodNumber = 1 });
var holidays = new FakeSchoolHolidays();
holidays.Add(new SchoolHoliday { Name = "Ferien", StartDate = secondOccurrence.AddDays(1), EndDate = secondOccurrence.AddDays(10) });
var vm = BuildViewModel(slots, new FakeGroups([group]), holidays);
var cell = vm.Cells.Single(c => c.IsSlotCell && c.Weekday == firstOccurrence.DayOfWeek && c.PeriodNumber == 1);
Assert.Equal("2", cell.BadgeText);
}
[Fact]
public void Load_Doppelstunde_BeideStundenZeigenDasselbeBadge()
{
var weekday = NextGridWeekday(DateOnly.FromDateTime(DateTime.Today));
var group = new LearningGroup { Name = "Q1 Chemie" };
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = weekday.DayOfWeek, PeriodNumber = 1 });
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = weekday.DayOfWeek, PeriodNumber = 2 });
var holidays = new FakeSchoolHolidays();
holidays.Add(new SchoolHoliday { Name = "Ferien", StartDate = weekday.AddDays(1), EndDate = weekday.AddDays(10) });
var vm = BuildViewModel(slots, new FakeGroups([group]), holidays);
var period1 = vm.Cells.Single(c => c.IsSlotCell && c.Weekday == weekday.DayOfWeek && c.PeriodNumber == 1);
var period2 = vm.Cells.Single(c => c.IsSlotCell && c.Weekday == weekday.DayOfWeek && c.PeriodNumber == 2);
Assert.Equal("1", period1.BadgeText);
Assert.Equal("1", period2.BadgeText);
}
[Fact]
public void Load_KeineAnstehendenFerien_KeinBadge()
{
var weekday = NextGridWeekday(DateOnly.FromDateTime(DateTime.Today));
var group = new LearningGroup { Name = "Q1 Chemie" };
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = weekday.DayOfWeek, PeriodNumber = 1 });
var vm = BuildViewModel(slots, new FakeGroups([group]));
var cell = vm.Cells.Single(c => c.IsSlotCell && c.Weekday == weekday.DayOfWeek && c.PeriodNumber == 1);
Assert.False(cell.HasBadge);
}
}
+5
View File
@@ -6,6 +6,7 @@ using LehrerApp.Data;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels;
using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Desktop.ViewModels.Planning;
using LehrerApp.Desktop.ViewModels.Students;
using LehrerApp.Desktop.Views;
using Microsoft.Extensions.DependencyInjection;
@@ -103,6 +104,10 @@ public class App : Application
var sl = Services.GetRequiredService<StudentListViewModel>();
sl.OnNavigateToDetail = id => main.NavigateToStudent(id);
sl.OnAddStudent = () => ShowAddStudentDialog();
// Stundenplan "Heute" → GroupDetail (Tab "Planung")
var timetable = Services.GetRequiredService<TimetableViewModel>();
timetable.OnNavigateToGroup = id => main.NavigateToGroupDetail(id, 5);
}
private static async Task ShowAddStudentDialog()
+6
View File
@@ -5,6 +5,7 @@ using LehrerApp.Data.Repositories;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels;
using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Desktop.ViewModels.Planning;
using LehrerApp.Desktop.ViewModels.Settings;
using LehrerApp.Desktop.ViewModels.Students;
using LehrerApp.Sync;
@@ -130,10 +131,14 @@ public static class AppBootstrapper
services.AddSingleton<IShorthandCodeRepository, ShorthandCodeRepository>();
services.AddSingleton<IAlternativeLessonPathRepository, AlternativeLessonPathRepository>();
services.AddSingleton<IAttachmentStorage, LiteAttachmentStorage>();
services.AddSingleton<ITimetableSlotRepository, TimetableSlotRepository>();
services.AddSingleton<ISchoolHolidayRepository, SchoolHolidayRepository>();
// ── Services ──────────────────────────────────────────────────────────
services.AddSingleton<GradingService>();
services.AddSingleton<SchoolYearService>();
services.AddSingleton<PublicHolidayService>();
services.AddSingleton(_ => new SchoolCalendarSettingsService(appData));
// ── Sync (optional nur wenn Server konfiguriert) ────────────────────
services.AddSingleton(_ => new EventQueue(queuePath));
@@ -178,6 +183,7 @@ public static class AppBootstrapper
new SyncStatusViewModel(sp.GetService<SyncEngine>()));
services.AddSingleton<GroupListViewModel>();
services.AddSingleton<StudentListViewModel>();
services.AddSingleton<TimetableViewModel>();
// Transient: neue Instanz pro Navigation (für Detailseiten)
services.AddTransient<GroupDetailViewModel>();
@@ -3,6 +3,7 @@ using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Desktop.ViewModels.Planning;
using LehrerApp.Desktop.ViewModels.Settings;
using LehrerApp.Desktop.ViewModels.Students;
using Microsoft.Extensions.DependencyInjection;
@@ -64,7 +65,7 @@ public partial class MainWindowViewModel : ObservableObject
NavItem.Groups => _services.GetRequiredService<GroupListViewModel>(),
NavItem.Students => _services.GetRequiredService<StudentListViewModel>(),
NavItem.Exams => new PlaceholderViewModel { Title = "Klausuren", Icon = "📝" },
NavItem.Planner => new PlaceholderViewModel { Title = "Unterrichtsplanung", Icon = "📅" },
NavItem.Planner => GetTimetable(),
NavItem.Workload => new PlaceholderViewModel { Title = "Arbeitszeit", Icon = "⏱" },
NavItem.Settings => _services.GetRequiredService<SettingsViewModel>(),
_ => CurrentPage,
@@ -78,6 +79,15 @@ public partial class MainWindowViewModel : ObservableObject
return dashboard;
}
private TimetableViewModel GetTimetable()
{
var timetable = _services.GetRequiredService<TimetableViewModel>();
timetable.WeekOffset = 0;
timetable.Load();
timetable.ActiveTabIndex = 0;
return timetable;
}
public void NavigateToGroupDetail(Guid groupId, int initialTab = 0)
{
ActiveNavItem = NavItem.Groups;
@@ -0,0 +1,90 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
namespace LehrerApp.Desktop.ViewModels.Planning;
/// <summary>Zuweisen/Bearbeiten/Entfernen eines Stundenplan-Termins (4.3.3).</summary>
public partial class TimetableSlotDialogViewModel : ObservableObject
{
private readonly ITimetableSlotRepository _slots;
private readonly Dictionary<string, Guid> _groupIdsByName;
private readonly TimetableSlot? _editing;
public DayOfWeek Weekday { get; }
public int PeriodNumber { get; }
public string WeekdayLabel { get; }
public string DialogTitle { get; }
public bool IsEditing => _editing is not null;
[ObservableProperty] private string _selectedGroupName = "";
[ObservableProperty] private string _room = "";
[ObservableProperty] private string _groupError = "";
public string[] GroupOptions { get; }
/// null = unverändert/Abbruch, sonst das neue/aktualisierte Ergebnis.
public TimetableSlot? Result { get; private set; }
public bool Deleted { get; private set; }
public TimetableSlotDialogViewModel(ITimetableSlotRepository slots, IGroupRepository groups,
SchoolYearService schoolYear, DayOfWeek weekday, int periodNumber, TimetableSlot? editing)
{
_slots = slots;
_editing = editing;
Weekday = weekday;
PeriodNumber = periodNumber;
WeekdayLabel = weekday switch
{
DayOfWeek.Monday => "Montag", DayOfWeek.Tuesday => "Dienstag",
DayOfWeek.Wednesday => "Mittwoch", DayOfWeek.Thursday => "Donnerstag",
DayOfWeek.Friday => "Freitag", _ => weekday.ToString(),
};
DialogTitle = $"{WeekdayLabel}, {periodNumber}. Stunde";
var availableGroups = groups.GetBySchoolYear(schoolYear.CurrentSchoolYear()).OrderBy(g => g.Name).ToList();
_groupIdsByName = availableGroups.ToDictionary(g => g.Name, g => g.Id);
GroupOptions = availableGroups.Select(g => g.Name).ToArray();
if (editing is not null)
{
SelectedGroupName = availableGroups.FirstOrDefault(g => g.Id == editing.GroupId)?.Name ?? "";
Room = editing.Room ?? "";
}
}
[RelayCommand]
private void Save()
{
GroupError = "";
if (string.IsNullOrWhiteSpace(SelectedGroupName) || !_groupIdsByName.TryGetValue(SelectedGroupName, out var groupId))
{
GroupError = "Bitte eine Gruppe auswählen.";
return;
}
var slot = _editing ?? new TimetableSlot { Weekday = Weekday, PeriodNumber = PeriodNumber };
slot.GroupId = groupId;
slot.Room = string.IsNullOrWhiteSpace(Room) ? null : Room.Trim();
try
{
_slots.Save(slot);
Result = slot;
}
catch (InvalidOperationException ex)
{
GroupError = ex.Message;
}
}
[RelayCommand]
private void Delete()
{
if (_editing is null) return;
_slots.Delete(_editing.Id);
Deleted = true;
}
}
@@ -0,0 +1,456 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using System.Collections.ObjectModel;
namespace LehrerApp.Desktop.ViewModels.Planning;
// ── Bundesland: deutsche Anzeige (4.3.5) ─────────────────────────────────────
// Wird sowohl vom Stundenplan (Anzeige) als auch von den Einstellungen (Pflege) verwendet.
public static class GermanStateDisplay
{
private static readonly (GermanState State, string Name)[] Entries =
[
(GermanState.BW, "Baden-Württemberg"),
(GermanState.BY, "Bayern"),
(GermanState.BE, "Berlin"),
(GermanState.BB, "Brandenburg"),
(GermanState.HB, "Bremen"),
(GermanState.HH, "Hamburg"),
(GermanState.HE, "Hessen"),
(GermanState.MV, "Mecklenburg-Vorpommern"),
(GermanState.NI, "Niedersachsen"),
(GermanState.NW, "Nordrhein-Westfalen"),
(GermanState.RP, "Rheinland-Pfalz"),
(GermanState.SL, "Saarland"),
(GermanState.SN, "Sachsen"),
(GermanState.ST, "Sachsen-Anhalt"),
(GermanState.SH, "Schleswig-Holstein"),
(GermanState.TH, "Thüringen"),
];
public static string[] Options { get; } = Entries.Select(e => e.Name).ToArray();
public static string Label(GermanState s) => Entries.First(e => e.State == s).Name;
public static GermanState FromLabel(string label) =>
Entries.FirstOrDefault(e => e.Name == label).State;
}
// ── Stundenplan: "Heute"-Übersicht (Standardansicht, Wochenraster + Tagesliste) + Bearbeiten (4.3) ──
public partial class TimetableViewModel : ObservableObject
{
private const int FirstPeriod = 1;
private const int LastPeriod = 10;
private static readonly DayOfWeek[] Weekdays =
[DayOfWeek.Monday, DayOfWeek.Tuesday, DayOfWeek.Wednesday, DayOfWeek.Thursday, DayOfWeek.Friday];
private static readonly string[] WeekdayColorPalette =
["#7F77DD", "#1D9E75", "#D85A30", "#D4537E", "#378ADD", "#639922", "#EF9F27", "#4C86A8"];
private readonly ITimetableSlotRepository _slots;
private readonly IGroupRepository _groups;
private readonly ISubjectRepository _subjects;
private readonly ILessonRepository _lessons;
private readonly IExamRepository _exams;
private readonly ISchoolHolidayRepository _schoolHolidays;
private readonly SchoolCalendarSettingsService _calendarSettings;
private readonly PublicHolidayService _publicHolidays;
private readonly SchoolYearService _schoolYear;
public ObservableCollection<TimetableCellItem> Cells { get; } = [];
public ObservableCollection<WeekCellItem> WeekItems { get; } = [];
public ObservableCollection<HoursWarningItem> HoursWarnings { get; } = [];
public ObservableCollection<TodayLessonItem> TodayItems { get; } = [];
[ObservableProperty] private int _activeTabIndex;
[ObservableProperty] private string _todayLabel = "";
[ObservableProperty] private string _weekRangeLabel = "";
[ObservableProperty] private int _weekOffset;
public bool IsCurrentWeek => WeekOffset == 0;
public Func<TimetableCellItem, Task>? OnEditSlot { get; set; }
public Action<Guid>? OnNavigateToGroup { get; set; }
public TimetableViewModel(ITimetableSlotRepository slots, IGroupRepository groups,
ISubjectRepository subjects, ILessonRepository lessons, IExamRepository exams,
ISchoolHolidayRepository schoolHolidays, SchoolCalendarSettingsService calendarSettings,
PublicHolidayService publicHolidays, SchoolYearService schoolYear)
{
_slots = slots; _groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams;
_schoolHolidays = schoolHolidays; _calendarSettings = calendarSettings;
_publicHolidays = publicHolidays; _schoolYear = schoolYear;
Load();
}
partial void OnWeekOffsetChanged(int value) => OnPropertyChanged(nameof(IsCurrentWeek));
[RelayCommand]
private void PreviousWeek() { WeekOffset--; Load(); }
[RelayCommand]
private void NextWeek() { WeekOffset++; Load(); }
[RelayCommand]
private void CurrentWeek() { WeekOffset = 0; Load(); }
public void Load()
{
var today = DateOnly.FromDateTime(DateTime.Today);
TodayLabel = today.ToString("dddd, dd.MM.yyyy", System.Globalization.CultureInfo.GetCultureInfo("de-DE"));
var publicHolidayDates = new HashSet<DateOnly>();
foreach (var year in new[] { today.Year - 1, today.Year, today.Year + 1 })
foreach (var h in _publicHolidays.GetHolidays(year, _calendarSettings.State)) publicHolidayDates.Add(h.Date);
var holidayBadges = ComputeHolidayBadges(today, publicHolidayDates);
var examProximity = ComputeExamProximity(today, publicHolidayDates);
BuildGrid(holidayBadges);
BuildWeekOverview(today, publicHolidayDates);
BuildToday(today);
BuildHoursWarnings();
}
// ── "Heute": Tagesliste ──────────────────────────────────────────────────
private void BuildToday(DateOnly today)
{
TodayItems.Clear();
var slotsToday = _slots.GetAll().Where(s => s.Weekday == today.DayOfWeek).OrderBy(s => s.PeriodNumber).ToList();
foreach (var slot in slotsToday)
{
var group = _groups.GetById(slot.GroupId);
if (group is null) continue;
var lesson = _lessons.GetByGroupAndDate(slot.GroupId, today).FirstOrDefault();
var exam = _exams.GetByGroup(slot.GroupId).FirstOrDefault(e => e.Date == today);
TodayItems.Add(new TodayLessonItem(slot.GroupId, slot.PeriodNumber, group.Name,
slot.Room ?? "", ColorFor(group.Name), lesson?.Topic, exam?.Title));
}
}
[RelayCommand]
private void OpenGroup(Guid groupId) => OnNavigateToGroup?.Invoke(groupId);
[RelayCommand]
private void ShowEditor() => ActiveTabIndex = 1;
// ── "Heute": Wochenraster (Nutzer-Feedback) — wie das Bearbeiten-Raster, aber nur Anzeige ──
/// <summary>
/// Zeigt Fach, Klasse, Raum und (falls für den Tag hinterlegt) das Thema der Stunde für die
/// per <see cref="WeekOffset"/> gewählte Kalenderwoche (MoFr, wie das Bearbeiten-Raster) —
/// anders als die Tagesliste auch für Tage, die noch nicht "heute" sind, damit z.B. der
/// parallele Kurs oder die nächste Stunde in der Woche auf einen Blick sichtbar sind. Die
/// Badges (Ferien-Nähe, Klausur-Nähe) werden je Zelle am dort angezeigten Datum ausgerichtet,
/// nicht am realen "heute" — sonst würde beim Blättern in andere Wochen ein Badge angezeigt,
/// das eigentlich zu einer ganz anderen Woche gehört.
/// </summary>
private void BuildWeekOverview(DateOnly today, HashSet<DateOnly> publicHolidayDates)
{
WeekItems.Clear();
var currentWeekMonday = today.AddDays(-((int)today.DayOfWeek + 6) % 7);
var monday = currentWeekMonday.AddDays(WeekOffset * 7);
var friday = monday.AddDays(4);
WeekRangeLabel = $"{monday:dd.MM.} {friday:dd.MM.yyyy}";
var allSlots = _slots.GetAll();
var groups = _groups.GetAll(includeInactive: true).ToDictionary(g => g.Id);
var schoolHolidays = _schoolHolidays.GetAll();
var dateByWeekday = Weekdays.ToDictionary(w => w, w => monday.AddDays((int)w - (int)DayOfWeek.Monday));
WeekItems.Add(WeekCellItem.Corner());
foreach (var weekday in Weekdays)
WeekItems.Add(WeekCellItem.WeekdayHeader(weekday, dateByWeekday[weekday], dateByWeekday[weekday] == today));
for (var period = FirstPeriod; period <= LastPeriod; period++)
{
WeekItems.Add(WeekCellItem.PeriodLabel(period));
foreach (var weekday in Weekdays)
{
var slot = allSlots.FirstOrDefault(s => s.Weekday == weekday && s.PeriodNumber == period);
if (slot is null) { WeekItems.Add(WeekCellItem.Empty(weekday, period)); continue; }
var date = dateByWeekday[weekday];
var group = groups.GetValueOrDefault(slot.GroupId);
var subject = group?.SubjectId is { } subjectId ? _subjects.GetById(subjectId) : null;
var lesson = _lessons.GetByGroupAndDate(slot.GroupId, date).FirstOrDefault();
var hasExam = _exams.GetByGroup(slot.GroupId).Any(e => e.Date == date);
var isHoliday = IsFreeDay(date, schoolHolidays, publicHolidayDates);
var colorHex = isHoliday ? "#BDBDBD" : ColorFor(group?.Name ?? "");
var holidayBadge = HolidayBadgeFor(date, weekday, schoolHolidays, publicHolidayDates);
var isLastBeforeExam = IsLastBeforeExamFor(date, weekday, slot.GroupId, publicHolidayDates);
WeekItems.Add(WeekCellItem.ForSlot(weekday, period, date == today,
subject?.ShortName is { Length: > 0 } sn ? sn : subject?.Name ?? "",
group?.Name ?? "?", slot.Room ?? "", lesson?.Topic ?? "",
colorHex, holidayBadge, hasExam, isLastBeforeExam,
MentionsExperiment(lesson), slot.GroupId, isHoliday));
}
}
}
/// <summary>Badge "1"/"2" für die Zelle mit Datum <paramref name="date"/> selbst, analog zu
/// <see cref="ComputeHolidayBadges"/>, aber an diesem konkreten Datum statt an "heute"
/// ausgerichtet — nötig, damit das Badge beim Blättern durch die Wochen zur richtigen Woche
/// gehört.</summary>
private string HolidayBadgeFor(DateOnly date, DayOfWeek weekday, List<SchoolHoliday> schoolHolidays,
HashSet<DateOnly> publicHolidayDates)
{
var nextHoliday = schoolHolidays.Where(h => h.StartDate > date).MinBy(h => h.StartDate);
if (nextHoliday is null) return "";
var count = CountOccurrences(weekday, date, nextHoliday.StartDate, publicHolidayDates);
return count is 1 or 2 ? count.ToString() : "";
}
/// <summary>Analog zu <see cref="ComputeExamProximity"/>, aber am Zelldatum statt an "heute"
/// ausgerichtet.</summary>
private bool IsLastBeforeExamFor(DateOnly date, DayOfWeek weekday, Guid groupId, HashSet<DateOnly> publicHolidayDates)
{
var nextExam = _exams.GetByGroup(groupId).Where(e => e.Date >= date).MinBy(e => e.Date);
if (nextExam is null) return false;
return CountOccurrences(weekday, date, nextExam.Date, publicHolidayDates) == 1;
}
private static bool MentionsExperiment(Lesson? lesson) =>
lesson is not null && lesson.Phases.Any(p =>
p.Name.Contains("Experiment", StringComparison.OrdinalIgnoreCase) ||
p.Activity.Contains("Experiment", StringComparison.OrdinalIgnoreCase) ||
p.Material.Contains("Experiment", StringComparison.OrdinalIgnoreCase));
/// <summary>Fällt <paramref name="date"/> auf einen gesetzlichen Feiertag oder in Schulferien?</summary>
private static bool IsFreeDay(DateOnly date, List<SchoolHoliday> schoolHolidays, HashSet<DateOnly> publicHolidayDates) =>
publicHolidayDates.Contains(date) || schoolHolidays.Any(h => date >= h.StartDate && date <= h.EndDate);
// ── Bearbeiten-Raster ─────────────────────────────────────────────────────
private void BuildGrid(Dictionary<(DayOfWeek Weekday, Guid GroupId), string> badges)
{
Cells.Clear();
var allSlots = _slots.GetAll();
var groupNames = _groups.GetAll(includeInactive: true).ToDictionary(g => g.Id, g => g.Name);
Cells.Add(TimetableCellItem.Corner());
foreach (var weekday in Weekdays)
Cells.Add(TimetableCellItem.WeekdayHeader(weekday));
for (var period = FirstPeriod; period <= LastPeriod; period++)
{
Cells.Add(TimetableCellItem.PeriodLabel(period));
foreach (var weekday in Weekdays)
{
var slot = allSlots.FirstOrDefault(s => s.Weekday == weekday && s.PeriodNumber == period);
var groupName = slot is not null ? groupNames.GetValueOrDefault(slot.GroupId, "?") : "";
var badge = slot is not null ? badges.GetValueOrDefault((weekday, slot.GroupId), "") : "";
Cells.Add(TimetableCellItem.ForSlot(weekday, period, slot, groupName, ColorFor(groupName), badge));
}
}
}
private static string ColorFor(string name)
{
if (name.Length == 0) return "#9E9E9E";
var hash = 0;
foreach (var c in name) hash = hash * 31 + c;
return WeekdayColorPalette[Math.Abs(hash) % WeekdayColorPalette.Length];
}
[RelayCommand]
private async Task EditCell(TimetableCellItem? cell)
{
if (cell is null || !cell.IsSlotCell || OnEditSlot is null) return;
await OnEditSlot(cell);
Load();
}
// ── Abgleich mit Wochenstunden (4.3.4) ────────────────────────────────────
private void BuildHoursWarnings()
{
HoursWarnings.Clear();
var currentYear = _schoolYear.CurrentSchoolYear();
var slotCounts = _slots.GetAll().GroupBy(s => s.GroupId).ToDictionary(g => g.Key, g => g.Count());
foreach (var group in _groups.GetBySchoolYear(currentYear).Where(g => g.HoursPerWeek.HasValue))
{
var assigned = slotCounts.GetValueOrDefault(group.Id, 0);
if (assigned != group.HoursPerWeek!.Value)
HoursWarnings.Add(new HoursWarningItem(group.Name, assigned, group.HoursPerWeek.Value));
}
}
// ── Badges: letzte/vorletzte Stunde vor Ferien, letzte Stunde vor Klausur ────────────────
/// <summary>
/// Zählt, wie oft <paramref name="weekday"/> zwischen (inkl.) <paramref name="from"/> und
/// (exkl.) <paramref name="until"/> eintritt — gesetzliche Feiertage werden übersprungen, da
/// an ihnen ohnehin kein Unterricht stattfindet.
/// </summary>
private static int CountOccurrences(DayOfWeek weekday, DateOnly from, DateOnly until, HashSet<DateOnly> publicHolidays)
{
var count = 0;
for (var date = from; date < until; date = date.AddDays(1))
if (date.DayOfWeek == weekday && !publicHolidays.Contains(date)) count++;
return count;
}
/// <summary>
/// Badge "1"/"2" für die letzte bzw. vorletzte Stunde eines Wochentags vor den nächsten
/// anstehenden Schulferien. Pro (Wochentag, Gruppe) statt pro einzelnem <see cref="TimetableSlot"/>
/// berechnet: eine Doppelstunde besteht aus zwei Slots mit demselben Wochentag/derselben
/// Gruppe und bekommt dadurch automatisch dasselbe Badge, ohne gesonderte Blockerkennung.
/// </summary>
private Dictionary<(DayOfWeek Weekday, Guid GroupId), string> ComputeHolidayBadges(
DateOnly today, HashSet<DateOnly> publicHolidayDates)
{
var result = new Dictionary<(DayOfWeek, Guid), string>();
var nextHoliday = _schoolHolidays.GetAll().Where(h => h.StartDate > today).MinBy(h => h.StartDate);
if (nextHoliday is null) return result;
foreach (var group in _slots.GetAll().GroupBy(s => (s.Weekday, s.GroupId)))
{
var count = CountOccurrences(group.Key.Weekday, today, nextHoliday.StartDate, publicHolidayDates);
if (count is 1 or 2) result[group.Key] = count.ToString();
}
return result;
}
/// <summary>
/// Markiert die letzte Stunde eines Wochentags vor der nächsten anstehenden Klausur derselben
/// Gruppe (sofern eine ansteht) — analog zum Ferien-Badge, aber je Gruppe an deren eigenem
/// nächsten Klausurtermin statt an einem gemeinsamen Ferientermin ausgerichtet.
/// </summary>
private Dictionary<(DayOfWeek Weekday, Guid GroupId), bool> ComputeExamProximity(
DateOnly today, HashSet<DateOnly> publicHolidayDates)
{
var result = new Dictionary<(DayOfWeek, Guid), bool>();
foreach (var groupSlots in _slots.GetAll().GroupBy(s => s.GroupId))
{
var nextExam = _exams.GetByGroup(groupSlots.Key).Where(e => e.Date >= today).MinBy(e => e.Date);
if (nextExam is null) continue;
foreach (var weekday in groupSlots.Select(s => s.Weekday).Distinct())
{
var count = CountOccurrences(weekday, today, nextExam.Date, publicHolidayDates);
if (count == 1) result[(weekday, groupSlots.Key)] = true;
}
}
return result;
}
}
public class TimetableCellItem
{
public bool IsHeader { get; private init; }
public bool IsPeriodLabel { get; private init; }
public string Text { get; private init; } = "";
public DayOfWeek? Weekday { get; private init; }
public int PeriodNumber { get; private init; }
public TimetableSlot? Slot { get; private init; }
public string GroupName { get; private init; } = "";
public string ColorHex { get; private init; } = "#9E9E9E";
public string BadgeText { get; private init; } = "";
public bool HasBadge => BadgeText.Length > 0;
public bool IsAssigned => Slot is not null;
public bool IsSlotCell => !IsHeader && !IsPeriodLabel;
public static TimetableCellItem Corner() => new() { IsHeader = true, Text = "" };
public static TimetableCellItem WeekdayHeader(DayOfWeek day) => new()
{
IsHeader = true,
Text = day.ToString() switch
{
"Monday" => "Mo", "Tuesday" => "Di", "Wednesday" => "Mi",
"Thursday" => "Do", "Friday" => "Fr", _ => day.ToString(),
},
};
public static TimetableCellItem PeriodLabel(int period) => new() { IsPeriodLabel = true, Text = period.ToString() };
public static TimetableCellItem ForSlot(DayOfWeek day, int period, TimetableSlot? slot, string groupName,
string colorHex, string badgeText) => new()
{
Weekday = day, PeriodNumber = period, Slot = slot, GroupName = groupName, ColorHex = colorHex,
BadgeText = badgeText,
Text = slot is null ? "" : groupName + (string.IsNullOrWhiteSpace(slot.Room) ? "" : $" · {slot.Room}"),
};
}
/// <summary>Zelle im schreibgeschützten Wochenraster der "Heute"-Ansicht.</summary>
public class WeekCellItem
{
public bool IsHeader { get; private init; }
public bool IsPeriodLabel { get; private init; }
public bool IsSlotCell => !IsHeader && !IsPeriodLabel;
public bool IsAssigned { get; private init; }
public string Text { get; private init; } = "";
public DayOfWeek? Weekday { get; private init; }
public int PeriodNumber { get; private init; }
public bool IsToday { get; private init; }
public Guid GroupId { get; private init; }
public string SubjectLabel { get; private init; } = "";
public string GroupName { get; private init; } = "";
public string Room { get; private init; } = "";
public string Topic { get; private init; } = "";
public string ColorHex { get; private init; } = "#9E9E9E";
public string HolidayBadge { get; private init; } = "";
public bool HasHolidayBadge => HolidayBadge.Length > 0;
public bool HasExam { get; private init; }
public bool IsLastBeforeExam { get; private init; }
public bool HasExperiment { get; private init; }
public bool IsHoliday { get; private init; }
public bool HasRoom => !string.IsNullOrWhiteSpace(Room);
public bool HasTopic => !string.IsNullOrWhiteSpace(Topic);
public static WeekCellItem Corner() => new() { IsHeader = true };
public static WeekCellItem WeekdayHeader(DayOfWeek day, DateOnly date, bool isToday) => new()
{
IsHeader = true,
IsToday = isToday,
Text = (day switch
{
DayOfWeek.Monday => "Mo", DayOfWeek.Tuesday => "Di", DayOfWeek.Wednesday => "Mi",
DayOfWeek.Thursday => "Do", DayOfWeek.Friday => "Fr", _ => day.ToString(),
}) + $" {date:dd.MM.}",
};
public static WeekCellItem PeriodLabel(int period) => new() { IsPeriodLabel = true, Text = period.ToString() };
public static WeekCellItem Empty(DayOfWeek day, int period) => new() { Weekday = day, PeriodNumber = period };
public static WeekCellItem ForSlot(DayOfWeek day, int period, bool isToday, string subjectLabel,
string groupName, string room, string topic, string colorHex, string holidayBadge,
bool hasExam, bool isLastBeforeExam, bool hasExperiment, Guid groupId, bool isHoliday) => new()
{
Weekday = day, PeriodNumber = period, IsAssigned = true, IsToday = isToday,
SubjectLabel = subjectLabel, GroupName = groupName, Room = room, Topic = topic,
ColorHex = colorHex, HolidayBadge = holidayBadge, HasExam = hasExam,
IsLastBeforeExam = isLastBeforeExam, HasExperiment = hasExperiment, GroupId = groupId,
IsHoliday = isHoliday,
};
}
public class HoursWarningItem(string groupName, int assigned, int expected)
{
public string GroupName { get; } = groupName;
public string Text { get; } = $"{groupName}: {assigned} von {expected} Wochenstunden eingetragen";
}
public class TodayLessonItem(Guid groupId, int periodNumber, string groupName, string room,
string colorHex, string? lessonTopic, string? examTitle)
{
public Guid GroupId { get; } = groupId;
public int PeriodNumber { get; } = periodNumber;
public string GroupName { get; } = groupName;
public string Room { get; } = room;
public string ColorHex { get; } = colorHex;
public string? LessonTopic { get; } = lessonTopic;
public string? ExamTitle { get; } = examTitle;
public bool HasRoom => !string.IsNullOrWhiteSpace(Room);
public bool HasLessonTopic => !string.IsNullOrWhiteSpace(LessonTopic);
public bool HasExam => ExamTitle is not null;
}
@@ -4,7 +4,9 @@ using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Data;
using LehrerApp.Desktop.ViewModels.Planning;
using System.Collections.ObjectModel;
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
@@ -113,14 +115,30 @@ public partial class SettingsViewModel : ObservableObject
/// Vom Code-Behind gesetzt: zeigt einen Bestätigungsdialog vor dem endgültigen Löschen.
public Func<ExpiredDocumentItem, Task<bool>>? OnConfirmHardDelete { get; set; }
// ── Ferien & Feiertage (4.3.5, aus dem Stundenplan hierher verschoben) ───
[ObservableProperty] private string _selectedStateName = "";
[ObservableProperty] private string _newHolidayName = "";
[ObservableProperty] private string _newHolidayStartText = "";
[ObservableProperty] private string _newHolidayEndText = "";
[ObservableProperty] private string _holidayNameError = "";
[ObservableProperty] private string _holidayDateError = "";
public List<string> StateOptions { get; } = GermanStateDisplay.Options.ToList();
public ObservableCollection<SchoolHolidayItem> SchoolHolidayEntries { get; } = [];
// ── Konstruktor ───────────────────────────────────────────────────────────
private readonly ISchoolHolidayRepository _schoolHolidays;
private readonly SchoolCalendarSettingsService _calendarSettings;
public SettingsViewModel(ISubjectRepository subjects, ICompetencyDomainRepository domainRepo,
IGradingKeyTemplateRepository gradingKeyTemplates, IGradingSchemeRepository gradingSchemes,
GradingService grading, BackupService backups, DatabaseEncryptionService dbEncryption,
AppLockService appLock, LiteDbContext dbContext, PrivacySettingsService privacy,
IDocumentationRepository documentation, IStudentRepository students,
IShorthandCodeRepository shorthandCodes)
IShorthandCodeRepository shorthandCodes, ISchoolHolidayRepository schoolHolidays,
SchoolCalendarSettingsService calendarSettings)
{
_subjects = subjects;
_domainRepo = domainRepo;
@@ -135,6 +153,8 @@ public partial class SettingsViewModel : ObservableObject
_documentation = documentation;
_students = students;
_shorthandCodes = shorthandCodes;
_schoolHolidays = schoolHolidays;
_calendarSettings = calendarSettings;
LoadSubjects();
LoadShorthandCodes();
LoadGradingKeyTemplates();
@@ -145,6 +165,51 @@ public partial class SettingsViewModel : ObservableObject
AppLockTimeoutMinutes = _appLock.TimeoutMinutes;
RetentionYears = _privacy.RetentionYears;
LoadExpiredDocuments();
SelectedStateName = GermanStateDisplay.Label(_calendarSettings.State);
LoadSchoolHolidays();
}
// ── Ferien & Feiertage: Bundesland / Schulferien pflegen ─────────────────
partial void OnSelectedStateNameChanged(string value) =>
_calendarSettings.SetState(GermanStateDisplay.FromLabel(value));
private void LoadSchoolHolidays()
{
SchoolHolidayEntries.Clear();
foreach (var h in _schoolHolidays.GetAll().OrderBy(h => h.StartDate))
SchoolHolidayEntries.Add(new SchoolHolidayItem(h));
}
[RelayCommand]
private void AddSchoolHoliday()
{
HolidayNameError = ""; HolidayDateError = "";
var valid = true;
if (string.IsNullOrWhiteSpace(NewHolidayName)) { HolidayNameError = "Name erforderlich."; valid = false; }
var hasStart = DateOnly.TryParseExact(NewHolidayStartText, "dd.MM.yyyy", CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.None, out var start);
var hasEnd = DateOnly.TryParseExact(NewHolidayEndText, "dd.MM.yyyy", CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.None, out var end);
if (!hasStart || !hasEnd) { HolidayDateError = "Bitte Beginn und Ende im Format TT.MM.JJJJ angeben."; valid = false; }
else if (end < start) { HolidayDateError = "Das Ende darf nicht vor dem Beginn liegen."; valid = false; }
if (!valid) return;
_schoolHolidays.Save(new SchoolHoliday { Name = NewHolidayName.Trim(), StartDate = start, EndDate = end });
NewHolidayName = ""; NewHolidayStartText = ""; NewHolidayEndText = "";
LoadSchoolHolidays();
}
[RelayCommand]
private void RemoveSchoolHoliday(SchoolHolidayItem? item)
{
if (item is null) return;
_schoolHolidays.Delete(item.Id);
SchoolHolidayEntries.Remove(item);
}
// ── Datenschutz: Löschfristen ─────────────────────────────────────────────
@@ -770,6 +835,13 @@ public class ExpiredDocumentItem(Documentation d, string studentName)
public string CreatedAtDisplay { get; } = d.CreatedAt.ToLocalTime().ToString("dd.MM.yyyy");
}
public class SchoolHolidayItem(SchoolHoliday h)
{
public Guid Id { get; } = h.Id;
public string Name { get; } = h.Name;
public string RangeDisplay { get; } = $"{h.StartDate:dd.MM.yyyy} {h.EndDate:dd.MM.yyyy}";
}
// ── JSON DTOs ─────────────────────────────────────────────────────────────────
internal class CatalogDto
+5
View File
@@ -9,6 +9,8 @@
xmlns:vs="clr-namespace:LehrerApp.Desktop.Views.Students"
xmlns:vset="clr-namespace:LehrerApp.Desktop.Views.Settings"
xmlns:vmset="clr-namespace:LehrerApp.Desktop.ViewModels.Settings"
xmlns:vp="clr-namespace:LehrerApp.Desktop.Views.Planning"
xmlns:vmp="clr-namespace:LehrerApp.Desktop.ViewModels.Planning"
xmlns:svc="clr-namespace:LehrerApp.Desktop.Services"
x:Class="LehrerApp.Desktop.Views.MainWindow"
x:DataType="vm:MainWindowViewModel"
@@ -48,6 +50,9 @@
<DataTemplate DataType="vmset:SettingsViewModel">
<vset:SettingsView/>
</DataTemplate>
<DataTemplate DataType="vmp:TimetableViewModel">
<vp:TimetableView/>
</DataTemplate>
<DataTemplate DataType="vm:PlaceholderViewModel">
<views:PlaceholderView/>
</DataTemplate>
@@ -0,0 +1,34 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Planning"
x:Class="LehrerApp.Desktop.Views.Planning.TimetableSlotDialog"
x:DataType="vm:TimetableSlotDialogViewModel"
Title="{Binding DialogTitle}"
Width="360" SizeToContent="Height"
CanResize="False" WindowStartupLocation="CenterOwner">
<StackPanel Margin="24" Spacing="12">
<TextBlock Text="{Binding DialogTitle}" Classes="dialogtitle"/>
<StackPanel Spacing="4">
<TextBlock Text="Lerngruppe *" FontSize="12" Opacity="0.7"/>
<ComboBox ItemsSource="{Binding GroupOptions}" SelectedItem="{Binding SelectedGroupName}"
HorizontalAlignment="Stretch"/>
<TextBlock Text="{Binding GroupError}" Foreground="Red" FontSize="11"
IsVisible="{Binding GroupError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Raum (optional)" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding Room}"/>
</StackPanel>
<Button Content="Eintrag entfernen" HorizontalAlignment="Stretch" Margin="0,10,0,0"
IsVisible="{Binding IsEditing}" Click="OnDelete"/>
<Grid ColumnDefinitions="*,8,*" Margin="0,4,0,0">
<Button Grid.Column="0" Content="Abbrechen" HorizontalAlignment="Stretch" Click="OnCancel"/>
<Button Grid.Column="2" Content="Speichern" HorizontalAlignment="Stretch" Click="OnSave"/>
</Grid>
</StackPanel>
</Window>
@@ -0,0 +1,30 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using LehrerApp.Desktop.ViewModels.Planning;
namespace LehrerApp.Desktop.Views.Planning;
public partial class TimetableSlotDialog : Window
{
public TimetableSlotDialog() => InitializeComponent();
private void OnSave(object? sender, RoutedEventArgs e)
{
if (DataContext is TimetableSlotDialogViewModel vm)
{
vm.SaveCommand.Execute(null);
if (vm.Result is not null) Close();
}
}
private void OnDelete(object? sender, RoutedEventArgs e)
{
if (DataContext is TimetableSlotDialogViewModel vm)
{
vm.DeleteCommand.Execute(null);
Close();
}
}
private void OnCancel(object? sender, RoutedEventArgs e) => Close();
}
@@ -0,0 +1,210 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Planning"
xmlns:shared="clr-namespace:LehrerApp.Desktop.Views.Shared"
x:Class="LehrerApp.Desktop.Views.Planning.TimetableView"
x:DataType="vm:TimetableViewModel">
<UserControl.Styles>
<Style Selector="Border.timetablecell">
<Setter Property="Background" Value="{DynamicResource SystemControlBackgroundBaseLowBrush}"/>
</Style>
<Style Selector="Border.timetablecell.assigned">
<Setter Property="Background" Value="Transparent"/>
</Style>
<Style Selector="Border.weekheader.today">
<Setter Property="Background" Value="{DynamicResource SystemAccentColor}"/>
</Style>
</UserControl.Styles>
<Grid RowDefinitions="Auto,*">
<shared:PageHeader Grid.Row="0" Margin="32,28,32,0" Title="Stundenplan"
Subtitle="Wiederkehrendes wöchentliches Muster, keine konkreten Termine"/>
<TabbedPage Grid.Row="1" TabPlacement="Top" SelectedIndex="{Binding ActiveTabIndex}">
<!-- Tab: Heute (Standardansicht) -->
<ContentPage Header="Heute">
<DockPanel Margin="32,20,32,20">
<!-- Unten angedockt: heutige Stunden im Detail -->
<Border DockPanel.Dock="Bottom" Margin="0,16,0,0"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16" MaxHeight="240">
<DockPanel>
<TextBlock DockPanel.Dock="Top" Text="{Binding TodayLabel}" FontSize="15" FontWeight="SemiBold" Margin="0,0,0,8"/>
<ScrollViewer>
<ItemsControl ItemsSource="{Binding TodayItems}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:TodayLessonItem">
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="0,0,0,1" Padding="0,8">
<Grid ColumnDefinitions="6,Auto,*,Auto">
<Rectangle Grid.Column="0" Fill="{Binding ColorHex}" Width="5" HorizontalAlignment="Left"/>
<TextBlock Grid.Column="1" Text="{Binding PeriodNumber}" FontSize="17" FontWeight="Bold"
Width="30" HorizontalAlignment="Center" VerticalAlignment="Center" Margin="8,0"/>
<StackPanel Grid.Column="2" Spacing="2">
<TextBlock Text="{Binding GroupName}" FontSize="14" FontWeight="SemiBold"/>
<TextBlock FontSize="11" Opacity="0.6" IsVisible="{Binding HasRoom}">
<Run Text="Raum "/><Run Text="{Binding Room}"/>
</TextBlock>
<TextBlock Text="{Binding LessonTopic}" FontSize="11" Opacity="0.75"
TextWrapping="Wrap" IsVisible="{Binding HasLessonTopic}"/>
<TextBlock Text="{Binding ExamTitle}" FontSize="11" Foreground="#D85A30" FontWeight="SemiBold"
IsVisible="{Binding HasExam}"/>
</StackPanel>
<Button Grid.Column="3" Content="Zur Lerngruppe" FontSize="11" Padding="9,4"
VerticalAlignment="Center"
Command="{Binding $parent[ItemsControl].((vm:TimetableViewModel)DataContext).OpenGroupCommand}"
CommandParameter="{Binding GroupId}"/>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</DockPanel>
</Border>
<TextBlock DockPanel.Dock="Bottom" Text="Heute sind keine Stunden im Stundenplan eingetragen." Classes="emptyhint"
IsVisible="{Binding !TodayItems.Count}" Margin="0,8,0,0"/>
<!-- Rest: Wochenüberblick (nicht editierbar) -->
<ScrollViewer>
<StackPanel Spacing="14">
<Grid ColumnDefinitions="Auto,Auto,*,Auto,Auto">
<Button Grid.Column="0" Content="" FontWeight="Bold" Padding="10,4"
Command="{Binding PreviousWeekCommand}" ToolTip.Tip="Vorherige Woche"/>
<Button Grid.Column="1" Content="" FontWeight="Bold" Padding="10,4" Margin="4,0,0,0"
Command="{Binding NextWeekCommand}" ToolTip.Tip="Nächste Woche"/>
<TextBlock Grid.Column="2" FontSize="16" FontWeight="SemiBold"
VerticalAlignment="Center" Margin="12,0,0,0">
<Run Text="Woche "/><Run Text="{Binding WeekRangeLabel}"/>
</TextBlock>
<Button Grid.Column="3" Content="Diese Woche" Margin="0,0,8,0"
Command="{Binding CurrentWeekCommand}" IsVisible="{Binding !IsCurrentWeek}"/>
<Button Grid.Column="4" Content="Stundenplan bearbeiten" Command="{Binding ShowEditorCommand}"/>
</Grid>
<ItemsControl ItemsSource="{Binding WeekItems}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><UniformGrid Columns="6"/></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:WeekCellItem">
<Grid Margin="2" MinHeight="76">
<Border Classes="weekheader" Classes.today="{Binding IsToday}" CornerRadius="4"
IsVisible="{Binding IsHeader}">
<TextBlock Text="{Binding Text}" FontWeight="SemiBold" FontSize="12" Opacity="0.75"
HorizontalAlignment="Center" VerticalAlignment="Center" Margin="4"/>
</Border>
<TextBlock Text="{Binding Text}" IsVisible="{Binding IsPeriodLabel}"
FontWeight="SemiBold" FontSize="13"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
<Border Classes="timetablecell" Classes.assigned="{Binding IsAssigned}"
CornerRadius="6" IsVisible="{Binding IsSlotCell}">
<Button Background="{Binding ColorHex}" IsVisible="{Binding IsAssigned}"
HorizontalAlignment="Stretch" VerticalAlignment="Stretch"
HorizontalContentAlignment="Stretch" CornerRadius="6" Padding="6"
Command="{Binding $parent[ItemsControl].((vm:TimetableViewModel)DataContext).OpenGroupCommand}"
CommandParameter="{Binding GroupId}">
<StackPanel Spacing="1">
<TextBlock FontSize="11" FontWeight="Bold" Foreground="White" TextWrapping="Wrap">
<Run Text="{Binding SubjectLabel}"/><Run Text=" · "/><Run Text="{Binding GroupName}"/>
</TextBlock>
<TextBlock Text="{Binding Room}" FontSize="10" Foreground="White" Opacity="0.9"
IsVisible="{Binding HasRoom}"/>
<TextBlock Text="{Binding Topic}" FontSize="10" Foreground="White" Opacity="0.8"
TextWrapping="Wrap" IsVisible="{Binding HasTopic}"/>
<TextBlock Text="Ferien" FontSize="10" Foreground="White" FontWeight="SemiBold"
Opacity="0.9" IsVisible="{Binding IsHoliday}" Margin="0,2,0,0"/>
<StackPanel Orientation="Horizontal" Spacing="3" Margin="0,2,0,0"
IsVisible="{Binding !IsHoliday}">
<Border Background="#B71C1C" CornerRadius="7" Padding="4,0" IsVisible="{Binding HasHolidayBadge}">
<TextBlock Text="{Binding HolidayBadge}" FontSize="9" FontWeight="Bold" Foreground="White"/>
</Border>
<TextBlock Text="📝" FontSize="11" IsVisible="{Binding HasExam}" ToolTip.Tip="Klausur"/>
<TextBlock Text="⏰" FontSize="11" IsVisible="{Binding IsLastBeforeExam}" ToolTip.Tip="Letzte Stunde vor der Klausur"/>
<TextBlock Text="🧪" FontSize="11" IsVisible="{Binding HasExperiment}" ToolTip.Tip="Experiment geplant"/>
</StackPanel>
</StackPanel>
</Button>
</Border>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Border Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16" IsVisible="{Binding HoursWarnings.Count}">
<StackPanel Spacing="8">
<TextBlock Text="ABWEICHENDE WOCHENSTUNDEN" FontSize="11" FontWeight="Bold" Opacity="0.5"/>
<ItemsControl ItemsSource="{Binding HoursWarnings}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:HoursWarningItem">
<TextBlock Text="{Binding Text}" FontSize="12" Foreground="#FB8C00"
TextWrapping="Wrap" Margin="0,3"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</DockPanel>
</ContentPage>
<!-- Tab: Bearbeiten (Raster) -->
<ContentPage Header="Bearbeiten">
<ScrollViewer>
<StackPanel Margin="32,20,32,28" Spacing="10">
<ItemsControl ItemsSource="{Binding Cells}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><UniformGrid Columns="6"/></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:TimetableCellItem">
<Grid Margin="2" MinHeight="46">
<TextBlock Text="{Binding Text}" IsVisible="{Binding IsHeader}"
FontWeight="SemiBold" FontSize="12" Opacity="0.6"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
<TextBlock Text="{Binding Text}" IsVisible="{Binding IsPeriodLabel}"
FontWeight="SemiBold" FontSize="13"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
<Border Classes="timetablecell" Classes.assigned="{Binding IsAssigned}"
CornerRadius="6" IsVisible="{Binding IsSlotCell}">
<Panel>
<Button Background="{Binding ColorHex}"
IsVisible="{Binding IsAssigned}"
HorizontalAlignment="Stretch" VerticalAlignment="Stretch"
HorizontalContentAlignment="Center" CornerRadius="6"
Command="{Binding $parent[ItemsControl].((vm:TimetableViewModel)DataContext).EditCellCommand}"
CommandParameter="{Binding}">
<TextBlock Text="{Binding Text}" FontSize="11" Foreground="White"
TextWrapping="Wrap" TextAlignment="Center"/>
</Button>
<Button Content="+" Opacity="0.35"
IsVisible="{Binding !IsAssigned}"
HorizontalAlignment="Stretch" VerticalAlignment="Stretch"
HorizontalContentAlignment="Center" Background="Transparent"
Command="{Binding $parent[ItemsControl].((vm:TimetableViewModel)DataContext).EditCellCommand}"
CommandParameter="{Binding}"/>
<Border Background="#D85A30" CornerRadius="8" Padding="5,1"
IsVisible="{Binding HasBadge}"
HorizontalAlignment="Right" VerticalAlignment="Top" Margin="2">
<TextBlock Text="{Binding BadgeText}" FontSize="10" FontWeight="Bold" Foreground="White"/>
</Border>
</Panel>
</Border>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</ScrollViewer>
</ContentPage>
</TabbedPage>
</Grid>
</UserControl>
@@ -0,0 +1,33 @@
using Avalonia.Controls;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels.Planning;
using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop.Views.Planning;
public partial class TimetableView : UserControl
{
public TimetableView() => InitializeComponent();
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
if (DataContext is TimetableViewModel vm) vm.OnEditSlot = ShowSlotDialog;
}
private async Task ShowSlotDialog(TimetableCellItem cell)
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null || cell.Weekday is null) return;
var vm = new TimetableSlotDialogViewModel(
App.Services.GetRequiredService<ITimetableSlotRepository>(),
App.Services.GetRequiredService<IGroupRepository>(),
App.Services.GetRequiredService<SchoolYearService>(),
cell.Weekday.Value, cell.PeriodNumber, cell.Slot);
var dialog = new TimetableSlotDialog { DataContext = vm };
await dialog.ShowDialog(owner);
}
}
@@ -478,6 +478,64 @@
</ScrollViewer>
</ContentPage>
<!-- Tab: Ferien & Feiertage (4.3.5) -->
<ContentPage Header="Ferien &amp; Feiertage">
<ScrollViewer>
<StackPanel Margin="32,20,32,28" Spacing="14" MaxWidth="480">
<StackPanel Spacing="4">
<TextBlock Text="Bundesland (für Feiertage)" FontSize="13" FontWeight="SemiBold"/>
<ComboBox ItemsSource="{Binding StateOptions}" SelectedItem="{Binding SelectedStateName}"
HorizontalAlignment="Stretch"/>
</StackPanel>
<Separator Margin="0,4"/>
<TextBlock Text="Schulferien" FontSize="16" FontWeight="SemiBold"/>
<TextBlock Text="Werden im Stundenplan als unterrichtsfreie Tage angezeigt."
FontSize="12" Opacity="0.6" TextWrapping="Wrap"/>
<ItemsControl ItemsSource="{Binding SchoolHolidayEntries}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:SchoolHolidayItem">
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="0,0,0,1" Padding="0,7">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0">
<TextBlock Text="{Binding Name}" FontSize="13" FontWeight="SemiBold"/>
<TextBlock Text="{Binding RangeDisplay}" FontSize="12" Opacity="0.6"/>
</StackPanel>
<Button Grid.Column="1" Content="×" FontSize="14" Padding="9,3"
Command="{Binding $parent[ItemsControl].((vm:SettingsViewModel)DataContext).RemoveSchoolHolidayCommand}"
CommandParameter="{Binding}"/>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Noch keine Schulferien hinterlegt." Classes="emptyhint"
IsVisible="{Binding !SchoolHolidayEntries.Count}"/>
<Separator Margin="0,4"/>
<StackPanel Spacing="4">
<TextBox Text="{Binding NewHolidayName}" PlaceholderText="Name (z.B. Sommerferien)"/>
<TextBlock Text="{Binding HolidayNameError}" Foreground="Red" FontSize="11"
IsVisible="{Binding HolidayNameError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Grid ColumnDefinitions="*,8,*">
<TextBox Grid.Column="0" Text="{Binding NewHolidayStartText}" PlaceholderText="Beginn TT.MM.JJJJ"/>
<TextBox Grid.Column="2" Text="{Binding NewHolidayEndText}" PlaceholderText="Ende TT.MM.JJJJ"/>
</Grid>
<TextBlock Text="{Binding HolidayDateError}" Foreground="Red" FontSize="11"
IsVisible="{Binding HolidayDateError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Button Content=" Schulferien hinzufügen" Command="{Binding AddSchoolHolidayCommand}"
HorizontalAlignment="Left"/>
</StackPanel>
</StackPanel>
</ScrollViewer>
</ContentPage>
</TabbedPage>
</Grid>
</UserControl>
@@ -0,0 +1,67 @@
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using Xunit;
namespace LehrerApp.Tests;
public sealed class PublicHolidayServiceTests
{
private readonly PublicHolidayService _service = new();
[Theory]
[InlineData(2023, "2023-04-09")]
[InlineData(2024, "2024-03-31")]
[InlineData(2025, "2025-04-20")]
[InlineData(2026, "2026-04-05")]
[InlineData(2027, "2027-03-28")]
public void GetHolidays_Ostermontag_LiegtEinenTagNachDemBekanntenOstersonntag(int year, string easterSundayIso)
{
var easterSunday = DateOnly.Parse(easterSundayIso);
var holidays = _service.GetHolidays(year, GermanState.NW);
Assert.Contains(holidays, h => h.Name == "Ostermontag" && h.Date == easterSunday.AddDays(1));
Assert.Contains(holidays, h => h.Name == "Karfreitag" && h.Date == easterSunday.AddDays(-2));
}
[Fact]
public void GetHolidays_AlleBundeslaender_EnthaltenDieBundesweitenFeiertage()
{
foreach (var state in Enum.GetValues<GermanState>())
{
var holidays = _service.GetHolidays(2026, state);
Assert.Contains(holidays, h => h.Date == new DateOnly(2026, 1, 1)); // Neujahr
Assert.Contains(holidays, h => h.Date == new DateOnly(2026, 5, 1)); // Tag der Arbeit
Assert.Contains(holidays, h => h.Date == new DateOnly(2026, 10, 3)); // Tag der Deutschen Einheit
Assert.Contains(holidays, h => h.Date == new DateOnly(2026, 12, 25));
Assert.Contains(holidays, h => h.Date == new DateOnly(2026, 12, 26));
}
}
[Fact]
public void GetHolidays_Fronleichnam_NurInDenZustaendigenBundeslaendern()
{
var nrw = _service.GetHolidays(2026, GermanState.NW);
var berlin = _service.GetHolidays(2026, GermanState.BE);
Assert.Contains(nrw, h => h.Name == "Fronleichnam");
Assert.DoesNotContain(berlin, h => h.Name == "Fronleichnam");
}
[Fact]
public void GetHolidays_Reformationstag_NurInDenZustaendigenBundeslaendern()
{
var sachsen = _service.GetHolidays(2026, GermanState.SN);
var bayern = _service.GetHolidays(2026, GermanState.BY);
Assert.Contains(sachsen, h => h.Name == "Reformationstag");
Assert.DoesNotContain(bayern, h => h.Name == "Reformationstag");
}
[Fact]
public void GetHolidays_SindNachDatumSortiert()
{
var holidays = _service.GetHolidays(2026, GermanState.BY);
Assert.Equal(holidays.OrderBy(h => h.Date).Select(h => h.Date), holidays.Select(h => h.Date));
}
}
@@ -0,0 +1,37 @@
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using Xunit;
namespace LehrerApp.Tests;
public sealed class SchoolCalendarSettingsServiceTests
{
[Fact]
public void NeueKonfiguration_HatNordrheinWestfalenAlsStandard()
{
using var temp = new TempAppData();
var service = new SchoolCalendarSettingsService(temp.Path);
Assert.Equal(GermanState.NW, service.State);
}
[Fact]
public void SetState_WirdUeberNeueInstanzHinwegPersistiert()
{
using var temp = new TempAppData();
new SchoolCalendarSettingsService(temp.Path).SetState(GermanState.BY);
var second = new SchoolCalendarSettingsService(temp.Path);
Assert.Equal(GermanState.BY, second.State);
}
private sealed class TempAppData : IDisposable
{
public string Path { get; } = System.IO.Path.Combine(
System.IO.Path.GetTempPath(), $"lehrerapp-schoolcal-tests-{Guid.NewGuid():N}");
public TempAppData() => Directory.CreateDirectory(Path);
public void Dispose() { if (Directory.Exists(Path)) Directory.Delete(Path, recursive: true); }
}
}
+150 -16
View File
@@ -224,10 +224,10 @@ zusammenziehen, daraus die Halbjahresnote bilden":
## 4. Unterrichtsplanung
Modelle `Unit` und `Lesson` existieren, `UnitRepository`/`LessonRepository` ebenfalls.
Navigationspunkt "Unterrichtsplanung" ist weiterhin ein `PlaceholderViewModel`
([MainWindowViewModel.cs](LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs)) — er bleibt
bewusst Platzhalter, bis die gruppenübergreifende Kalenderübersicht aus 4.4 steht. 4.1/4.2 leben
stattdessen im gruppenspezifischen Tab "Planung" (`GroupDetailView`), siehe unten.
Navigationspunkt "Unterrichtsplanung" zeigt seit 4.3 den Stundenplan (`TimetableView`,
Standardtab "Heute" + Bearbeiten-Raster) statt eines `PlaceholderViewModel`. Eine
gruppenübergreifende Wochenansicht fehlt noch, siehe 4.4. 4.1/4.2 leben unabhängig davon im
gruppenspezifischen Tab "Planung" (`GroupDetailView`), siehe unten.
### 4.1 Unterrichtseinheiten (`Unit`)
- [x] **4.1.1** Listenansicht der Einheiten je Gruppe mit Status und Zeitraum
@@ -366,16 +366,146 @@ Redesign:
Hauptweg — deutlich einfacher und für den schnellen Überblick ausreichend.
### 4.3 Stundenplan
- [ ] **4.3.1** Neues Modell `TimetableSlot` (Gruppe, Wochentag, Stunde, Raum) + Repository.
- [ ] **4.3.2** Wochenstundenplan-Ansicht als Raster mit Farbcodierung je Gruppe.
- [ ] **4.3.3** Bearbeitung per Klick/Drag im Raster.
- [ ] **4.3.4** Abgleich mit `LearningGroup.HoursPerWeek` (Warnung bei Abweichung).
- [ ] **4.3.5** Schulferien und Feiertage hinterlegen (Bundesland wählbar) und aus der Planung ausnehmen.
- [x] **4.3.1** Neues Modell `TimetableSlot` (Gruppe, Wochentag, Stunde, Raum) + Repository
[Planning.cs](LehrerApp.Core/Models/Planning.cs),
[TimetableSlotRepository](LehrerApp.Data/Repositories/AllRepositories.cs). Wiederkehrendes
Muster (kein Datum) — für konkret gehaltene Stunden bleibt `Lesson` (4.2) zuständig. Pro
Wochentag/Stunde höchstens eine Gruppe (eindeutiger Index + freundliche Fehlermeldung im
Repository selbst, nicht erst über eine rohe `LiteException`): ein Lehrer kann nicht
gleichzeitig an zwei Orten unterrichten.
- [x] **4.3.2** Wochenstundenplan-Ansicht als Raster mit Farbcodierung je Gruppe — neuer
Navigationspunkt "Unterrichtsplanung" (bisher `PlaceholderViewModel`) zeigt jetzt
[TimetableView.axaml](LehrerApp.Desktop/Views/Planning/TimetableView.axaml). MoFr ×
1.10. Stunde, Farbe deterministisch aus dem Gruppennamen (gleiches Hash-in-Palette-Muster
wie bei den alternativen Unterrichtsabläufen in 4.2.2).
- [x] **4.3.3** Bearbeitung per Klick im Raster — Klick auf eine leere Zelle öffnet
`TimetableSlotDialog` zum Zuweisen (Gruppenauswahl + optionaler Raum), Klick auf eine
belegte Zelle öffnet denselben Dialog zum Ändern/Entfernen. **Drag bewusst nicht
umgesetzt:** Klick deckt die vollständige Bearbeitung (Zuweisen/Ändern/Entfernen) bereits
ab: Drag wäre nur eine schnellere Geste für "Zuordnung an eine andere Zelle verschieben",
kein zusätzlicher Funktionsumfang — bei Bedarf später ergänzbar.
- [x] **4.3.4** Abgleich mit `LearningGroup.HoursPerWeek` — Seitenleiste "Abweichende
Wochenstunden" listet jede Gruppe der aktuellen Schuljahres, deren eingetragene
Slot-Anzahl nicht der hinterlegten Wochenstundenzahl entspricht.
- [x] **4.3.5** Schulferien und Feiertage — gesetzliche Feiertage werden je Bundesland berechnet
([PublicHolidayService.cs](LehrerApp.Core/Services/PublicHolidayService.cs), Gauß'sche
Osterformel + bundeslandspezifische Zusatzfeiertage, empirisch gegen bekannte
Ostersonntage 20232027 verifiziert) statt gespeichert — anders als Schulferien sind sie
algorithmisch herleitbar. Schulferien selbst sind **nicht** herleitbar (jährlich neu von
den Bundesländern festgelegt) und werden deshalb manuell gepflegt
(`SchoolHoliday`-Repository, CRUD in der Seitenleiste). Bundesland als persistente
Einstellung ([SchoolCalendarSettingsService.cs](LehrerApp.Core/Services/SchoolCalendarSettingsService.cs)).
"Aus der Planung ausnehmen" ist für dieses Kapitel als "nächste unterrichtsfreie Tage"
sichtbar (kombinierte, sortierte Liste aus beiden Quellen) — die eigentliche Ausnahme aus
generierten Stunden ist erst mit der (bewusst zurückgestellten) Serienerzeugung 4.2.5
relevant und verwendet dieselben zwei Datenquellen.
**Nachtrag zu 4.3 (Nutzer-Feedback nach Erstumsetzung):**
- **Ferien/Feiertage-Pflege verschoben:** Die Bundesland-Auswahl und das Schulferien-CRUD standen
ursprünglich in der Seitenleiste des Stundenplans selbst — das wirkte dort deplatziert, da es
eine einmalige Einstellung statt einer täglich genutzten Ansicht ist. Beides ist jetzt ein
eigener Tab "Ferien & Feiertage" in den Einstellungen
([SettingsViewModel.cs](LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs),
[SettingsView.axaml](LehrerApp.Desktop/Views/Settings/SettingsView.axaml)). Der Stundenplan
selbst zeigt Bundesland und nächste unterrichtsfreie Tage nur noch lesend an
([TimetableViewModel.cs](LehrerApp.Desktop/ViewModels/Planning/TimetableViewModel.cs)).
- **Badge "1"/"2" vor Ferien:** Die letzte bzw. vorletzte Stunde eines Wochentags vor den nächsten
Schulferien wird im Bearbeiten-Raster mit einem kleinen Badge markiert — Erinnerung, rechtzeitig
abzuschließen. Berechnet pro (Wochentag, Gruppe) statt pro einzelnem `TimetableSlot`: eine
Doppelstunde besteht aus zwei Slots mit demselben Wochentag/derselben Gruppe und bekommt dadurch
automatisch dasselbe Badge, ohne gesonderte Blockerkennung. Gesetzliche Feiertage werden beim
Abzählen der verbleibenden Termine übersprungen. Bewusst nur an `SchoolHoliday` geknüpft, nicht
an einzelne `PublicHoliday`-Tage — es geht um das Vorbereiten auf eine längere Unterbrechung,
nicht um einen einzelnen freien Tag.
- **"Heute" als neue Standardansicht:** Der Stundenplan öffnet jetzt auf einem
nicht-editierenden Tab "Heute" (heutige Stunden mit Gruppe, Raum, Thema aus `Lesson.Topic` falls
vorhanden, sowie ein Hinweis, wenn an dem Tag eine Klausur ansteht). Klick auf eine Stunde
navigiert in die Lerngruppe (Tab "Planung"). Das bisherige Bearbeiten-Raster aus 4.3.2/4.3.3
ist über einen Button ("Stundenplan bearbeiten") als zweiter Tab weiterhin erreichbar.
**Nachtrag zu 4.3, zweite Iteration (Wochenraster in der "Heute"-Ansicht):** Die Tagesliste allein
beantwortete nicht "was steht in der Woche noch an" bzw. "was macht der parallele Kurs" — beides
Fragen, die nur ein Blick über alle Gruppen hinweg beantwortet. Der Tab "Heute" zeigt deshalb jetzt
zusätzlich ein schreibgeschütztes Wochenraster (MoFr × 1.10. Stunde, wie das Bearbeiten-Raster,
aber ohne Klick-zum-Bearbeiten). Jede Kachel zeigt Fach (`Subject.ShortName`), Klasse, Raum und
falls für den Tag hinterlegt das Thema der Stunde, dazu Symbole für: Ferien-Badge ("1"/"2",
wie im Bearbeiten-Raster), Klausur an dem Tag (📝), letzte Stunde vor der nächsten Klausur dieser
Gruppe (⏰) und ein geplantes Experiment (🧪). Die Tagesliste ist per `DockPanel` unten an die
Seite angedockt, das Wochenraster füllt den verbleibenden Platz darüber
([TimetableViewModel.cs](LehrerApp.Desktop/ViewModels/Planning/TimetableViewModel.cs),
[TimetableView.axaml](LehrerApp.Desktop/Views/Planning/TimetableView.axaml)). Klick auf eine
Kachel navigiert wie in der Tagesliste zur Lerngruppe.
- **"Letzte Stunde vor Klausur"** wird wie das Ferien-Badge pro (Wochentag, Gruppe) berechnet,
aber am nächsten `Exam.Date` der jeweiligen Gruppe statt an einem gemeinsamen Ferientermin
ausgerichtet — beide Badges teilen sich denselben Zähl-Helfer (`CountOccurrences`).
- **"Experiment geplant"** hat kein eigenes Datenfeld — es ist eine Texterkennung über die
bereits im Verlaufsplan-Editor (4.2.2) gepflegten `LessonPhaseStep`-Felder (Name/Aktivität/
Material enthält "Experiment", ohne Groß-/Kleinschreibung). Bewusst kein neues Modellfeld:
die Information steckt in den meisten Fällen schon in der ohnehin gepflegten Phasenplanung.
- Das Wochenraster zeigt bewusst nur die laufende Kalenderwoche ohne Vor-/Zurück-Navigation —
für einen Blick auf zukünftige Wochen bleibt vorerst das Bearbeiten-Raster (zeigt das
wiederkehrende Muster unabhängig vom Datum).
**Nachtrag zu 4.3, dritte Iteration (Ferientage im Wochenraster ausgegraut statt eigener Liste):**
Die Box "Nächste unterrichtsfreie Tage" wirkte neben dem neuen Wochenraster redundant — welche
Tage frei sind, sieht man dort jetzt direkt an den betroffenen Stunden. Die Box (samt
`UpcomingFreeDayItem`, `UpcomingFreeDays`, `BuildUpcomingFreeDays`, `BundeslandLabel`) wurde
entfernt. Stattdessen werden Wochenkacheln, deren Datum in Schulferien fällt oder ein gesetzlicher
Feiertag ist, grau eingefärbt (`WeekCellItem.IsHoliday`, Prüfung gegen `SchoolHoliday`-Zeitraum und
die bereits für die Badges berechneten `PublicHoliday`-Daten) und zeigen statt der Klausur-/
Experiment-Symbole nur noch die Aufschrift "Ferien" — die Symbole wären an einem unterrichtsfreien
Tag ohnehin nicht sinnvoll interpretierbar.
**Nachtrag zu 4.3, vierte Iteration (Wochennavigation):** Schalter ""/"" plus "Diese Woche"
erlauben jetzt, im Wochenraster vor und zurück zu blättern (`TimetableViewModel.WeekOffset`,
`PreviousWeekCommand`/`NextWeekCommand`/`CurrentWeekCommand`). Die Ferien-/Klausur-Nähe-Badges
("1"/"2" vor Ferien, ⏰ vor Klausur) waren bis dahin an "heute" verankert und pro (Wochentag,
Gruppe) einmalig berechnet — das wäre beim Blättern falsch geworden (ein Badge hätte in jeder
angezeigten Woche geklebt, nicht nur in der einen Woche, zu der es gehört). Für das Wochenraster
berechnen `HolidayBadgeFor`/`IsLastBeforeExamFor` die Badges deshalb jetzt je Zelle am dort
gezeigten Datum statt an "heute" — das Bearbeiten-Raster (zeigt ohnehin nur das wiederkehrende
Muster ohne Datum) behält die alte, "heute"-verankerte Berechnung. Beim erneuten Navigieren in den
Stundenplan (Sidebar-Klick) springt die Ansicht wieder auf die laufende Woche zurück.
### 4.4 Wochen-/Tagesansicht
- [ ] **4.4.1** Kalenderansicht über alle Gruppen: Woche und Tag.
- [ ] **4.4.2** Sprung von einer Stunde direkt in die Mitarbeitserfassung dieser Gruppe.
- [ ] **4.4.3** Anzeige anstehender Klausurtermine und Abgabefristen im Kalender.
- [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.
Vor-/Zurück-Navigation zwischen Kalenderwochen seit der vierten Iteration).
**Nicht enthalten:** ein eigenständiger Kalendermonat-Überblick — bei Bedarf später
ergänzbar.
- [x] **4.4.2** Sprung von einer Stunde direkt in die Lerngruppe — siehe Nachtrag zu 4.3
("Heute"-Tab, Klick navigiert zur Planung der Gruppe statt direkt in die
Mitarbeitserfassung, da das für den Vorbereitungs-Kontext des Tages passender ist).
- [ ] **4.4.3** Anzeige anstehender Klausurtermine und Abgabefristen im Kalender. *(Heutige
Klausuren bereits im "Heute"-Tab sichtbar — mehrtägiger Vorausblick fehlt noch.)*
### 4.5 Vernetzung Stundenplan ↔ Unterrichtsplanung (zurückgestellt)
Nutzer-Feedback nach der 4.3-Iterationen: Stundenplan (4.3, wiederkehrendes Muster) und
Unterrichtsplanung (4.1/4.2, `Unit`/`Lesson` je Gruppe) laufen bisher zu getrennt nebeneinander.
Ausdrücklich als Idee für später festgehalten, **nicht** jetzt umsetzen:
- [ ] **4.5.1** Terminvorschlag beim Anlegen einer `Lesson`: Tag/Stunde anhand des Stundenplans
vorschlagen, an dem die betroffene Gruppe laut `TimetableSlot` tatsächlich Unterricht hat,
statt Datum/Stunde komplett frei einzutragen.
- [ ] **4.5.2** Vom Stundenplan (Wochenraster oder Bearbeiten-Raster) direkt in einen
"Viewer" der zugehörigen `Lesson` springen können — und von dort auch eine neue `Lesson`
anlegen können. **Offene Frage, vor Umsetzung zu klären:** `Lesson` hängt an einer
übergeordneten `Unit` (4.1) — beim Anlegen direkt aus dem Stundenplan heraus ist unklar,
welcher `Unit` die neue Lesson zugeordnet werden soll (aktuellste offene Einheit der Gruppe?
Rückfrage an die Lehrkraft?). Muss überdacht werden, bevor das gebaut wird.
- [ ] **4.5.3** Aus diesem Lesson-Viewer heraus weiter verzweigen können: in die Zeugnisnote/
Bewertung der Gruppe, und in die Schnelldialoge für Mitarbeit sowie Anwesenheit/Hausaufgaben.
- [ ] **4.5.4** Badges am Stundenplan/Lesson-Viewer, die anzeigen, ob eine Stunde bereits
"kontrolliert" ist — u.a. ob in der vorherigen Stunde Hausaufgaben erteilt wurden und ob
diese noch nicht kontrolliert sind. Muss sich pro Fall abschalten lassen: manchmal wird eine
Hausaufgabe bewusst nicht kontrolliert, ohne dass bis Schuljahresende ständig daran erinnert
werden soll.
- [ ] **4.5.5** Stunden aus dieser Ansicht heraus verschieben können, wenn kurzfristig etwas
dazwischenkommt.
- [ ] **4.5.6** Bei Doppelstunden (90-Minuten-Planung laut Verlaufsplan, 4.2.2) den Inhalt
sinnvoll auf die beiden Kacheln/Perioden aufteilen, damit z.B. gezielt nur die zweite Stunde
eines Blocks verschoben werden kann, ohne den ganzen Block anzufassen.
---
@@ -892,8 +1022,12 @@ Die Abschnitte sind thematisch, nicht chronologisch nummeriert. Sinnvolle Bearbe
erledigt (13.113.4 vollständig; 13.4.2 bewusst zurückgestellt, siehe dort).
5. ~~**Kapitel 5** (Schülerdokumentation)~~ — erledigt (5.2 als Auswertung des bestehenden
Anwesenheits-Trackings statt zweiter Erfassung, siehe dort).
6. ~~**Kapitel 4.1 + 4.2** (Unterrichtsplanung: Einheiten & Einzelstunden)~~ — erledigt.
**Kapitel 4.3** (Stundenplan, neues Modell `TimetableSlot` + Ferien/Feiertage) und **4.4**
(Wochen-/Tageskalender über alle Gruppen) bewusst zurückgestellt — eigenständige neue
Subsysteme, kein Ausbau der bestehenden Unit/Lesson-UI. **→ nächster sinnvoller Schritt.**
6. ~~**Kapitel 4.1 + 4.2 + 4.3** (Unterrichtsplanung: Einheiten, Einzelstunden, Stundenplan)~~
erledigt, inkl. mehrerer Nachtrag-Iterationen aus Nutzer-Feedback (Ferien-Pflege in den
Einstellungen, Wochenraster mit Wochennavigation, Ferientage ausgegraut). ~~**Kapitel 4.4.1/
4.4.2**~~ — erledigt (im Zuge der 4.3-Nachträge miterledigt). Offen bleiben **4.4.3**
(Abgabefristen im Kalender), **4.2.5** (Serienerzeugung von Stunden aus dem Stundenplan) und
**4.5** (engere Vernetzung Stundenplan ↔ Lesson-Planung — ausdrücklich vom Nutzer
zurückgestellt, nicht aus Unklarheit).
**→ nächster sinnvoller Schritt: 4.4.3 oder 4.2.5, je nach Bedarf.**
7. **Kapitel 6** (Arbeitszeit), **11** (Export), **10** (Sync) — danach.