feat: Phase 15 birthdays - user birth dates and calendar birthday template

- AppUser.BirthDate (optional): admin edits in user management, every user edits own via PUT /api/auth/me/birth-date; validator (not before 1900, not future); BirthdayRules (age, leap day 29.2. -> 28.2.)
- Privacy: directory BirthDate only for self, admin and family; audit log without the value
- Calendar template "Geburtstag": CalendarEvent.BirthDate, all-day yearly series enforced server-side, age and display title per occurrence, ICS/CalDAV export as single series with category, BirthDate kept on CalDAV PUT
- Migration Phase15Birthdays, tests (unit, bUnit, integration), docs (ROADMAP, ARCHITECTURE, ADR-0015 addendum)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
JannisandClaude Sonnet 5.5 committed 2026-10-11 15:16:32 +02:00
1 parent 2ce15cb32d
commit ab505d4a4f
38 files changed
+4160 -45

No files matched your search

+4 -4
View File
@@ -80,12 +80,12 @@ Alle Zeitpunkte werden als UTC (`DateTimeOffset`/`DateTime` UTC) gespeichert und
| Bereich | Entitäten |
|---|---|
| Identity ✅ | `AppUser` (DisplayName, Color, AvatarUrl, MustChangePassword, IsActive, ExpiresAt, PinHash, LastLoginAt, Soft Delete), `AppRole` (Admin, Familie, Kind, Gast), `UserAvatar` (Bild ≤ 1 MB) |
| Identity ✅ | `AppUser` (DisplayName, Color, AvatarUrl, BirthDate (optional, `DateOnly`, Phase 15), MustChangePassword, IsActive, ExpiresAt, PinHash, LastLoginAt, Soft Delete), `AppRole` (Admin, Familie, Kind, Gast), `UserAvatar` (Bild ≤ 1 MB) |
| Einstellungen ✅ | `FamilySettings` (FamilyName, TimeZoneId, MealPlanDays (7–10), MealTypes, TaskCategories, ChorePointsEnabled, ChildTasksRequireApproval, GuestsCanEditShoppingList, GuestChatEnabled, LearningPhotoRetentionDays, AiChatEnabled, AiChatChildrenAllowed) |
| Einkauf ✅ | `ShoppingList` (Name, IsDefault, SortOrder, Soft Delete), `ShoppingItem` (Client-Id, Name, Quantity, Category, IsDone, DoneAt, DoneBy, RowVersion, Soft Delete), `ShoppingSuggestion` (Name, Category, CategoryIsManual, UseCount, LastUsedAt). Die Items-DTOs liefern `CreatedByName` („hinzugefügt von …“); für Kinder prüft `ShoppingPlausibility` (Sonderzeichen/Ziffern, Zeichenwiederholung, Tastaturmuster, kleine Quatschliste) Neu-Einträge: verdächtige liefern 409 mit `needsConfirmation`, der Client fragt nach und sendet `Confirm = true` |
| Essen ✅ | `MealPlanEntry` (Date als `DateOnly`, MealType, DishName, Note, RecipeUrl, Ingredients, WishId, RowVersion, Soft Delete; eindeutig je Tag + Mahlzeit), `Dish` (Name, Note, RecipeUrl, Ingredients, UseCount = Planungen, WishCount, WishedBy, LastPlannedAt, Soft Delete; eindeutiger NormalizedName – dauerhafte Wunschliste und Autovervollständigung, `/api/meals/wishlist`), `MealWish` (DishName, Note, Wunschtag/-mahlzeit, Status offen/übernommen/abgelehnt, Soft Delete) |
| Aufgaben ✅ | `TodoTask` (Title, Notes, DueDate + DueTime → DueAt, ReminderMinutesBefore → RemindAt, Priority, Status inkl. „wartet auf Bestätigung“, Category, IsPrivate, IsChore, Points, NeedsApproval, AssignedToFamily, RecurrenceRule + RecurrenceMode, SeriesId/SeriesStart/SeriesIndex, CompletedBy/At, ApprovedBy/At, RowVersion, Soft Delete), `TaskAssignment`, `TaskChecklistItem`, `TaskHistoryEntry` (Verlauf), `PointsLedgerEntry` (Punktebuch). Kategorien kommen aus `FamilySettings.TaskCategories`; „wer/wann/genehmigt“ steht an der Aufgabe und im Verlauf (ADR-0014) |
| Kalender ✅ | `FamilyCalendar` (Name, Color, IsDefault, Soft Delete), `CalendarPermission` (Rolle **oder** Benutzer → None/Read/WriteOwn/Write), `CalendarEvent` (Title, Location, Notes, IsAllDay, StartUtc, EndUtc, TimeZoneId, RecurrenceRule, ExceptionDates, Uid, PersonIds, ForWholeFamily, Sequence, RowVersion, Soft Delete; Overrides als eigene Zeile mit `SeriesMasterId` + `RecurrenceId`), `IcsFeed` (Owner, Scope, CalendarId, TaskMode, TokenHash, TokenPrefix, LastAccessAt, RevokedAt), `CalDavCredential` (UserId, Label, SecretHash, SecretPrefix, LastUsedAt, RevokedAt; App-Passwort für CalDAV) – ADR-0015/0016/0017 |
| Kalender ✅ | `FamilyCalendar` (Name, Color, IsDefault, Soft Delete), `CalendarPermission` (Rolle **oder** Benutzer → None/Read/WriteOwn/Write), `CalendarEvent` (Title, Location, Notes, IsAllDay, BirthDate (Geburtstagsvorlage, Phase 15), StartUtc, EndUtc, TimeZoneId, RecurrenceRule, ExceptionDates, Uid, PersonIds, ForWholeFamily, Sequence, RowVersion, Soft Delete; Overrides als eigene Zeile mit `SeriesMasterId` + `RecurrenceId`), `IcsFeed` (Owner, Scope, CalendarId, TaskMode, TokenHash, TokenPrefix, LastAccessAt, RevokedAt), `CalDavCredential` (UserId, Label, SecretHash, SecretPrefix, LastUsedAt, RevokedAt; App-Passwort für CalDAV) – ADR-0015/0016/0017 |
| KI ✅ | `AiProviderConfig` (Name, Type Ollama/Claude/OpenAiCompatible, BaseUrl, Model, ApiKeyProtected, TimeoutSeconds, IsActive, AllowImages, AllowWebSearch, Purposes, DailyLimitPerUser, RowVersion, Soft Delete), `AiUsage` (Person, Anbieter, Zweck, Dauer, Tokens, Erfolg/Fehlerart – ohne Inhalt), `AiExchange` ✅ (12b: KI-Protokoll je Aufruf – Person, Anbieter, Modell, Zweck, Beginn, Dauer, Erfolg/Fehler, Bildanzahl, Online-Suche, Tokens, `ContentStored`, Prompt, Antwort; Prompt (inkl. System-Prompt) und Antwort immer, auch im KI-Chat und bei Fehlern (Phase 14); `AiUserSettings` ✅ (Phase 14: Admin-Vorgaben je Person – Anbieter, Modell, System-Prompt, Prompt deaktiviert); Aufbewahrung `FamilySettings.AiLogRetentionDays`, Standard 90) – ADR-0020; Zwecke Standard, Lernaufgaben, Foto-Prüfung, KI-Chat (letztere beiden ohne Rückfall auf den Standard, ADR-0022) |
| Chat ✅ | `ChatChannel` (Family / Direct / Ai – privater KI-Kanal je Person, `DirectKey = ai:{userId}`), `ChatMember` (LastReadAt), `ChatMessage` (Text, HasImage; KI-Antworten mit fester Absender-Id `ChatRules.AiSenderId`, kein Benutzerkonto), `ChatAttachment` (PNG/JPEG/WebP; GIF nur aus der Server-GIF-Suche), `ChatReaction` – ADR-0019, ADR-0022 |
| Lernen ✅ (10a) | `LearningSet` (Titel, Fach Mathe/Deutsch, Klassenstufe 1–6, Zielkind, Fälligkeit, Status, Modus Übung/Prüfung, MaxAttempts, TimeLimitMinutes, ShowSolutionsAfterSubmit, ShuffleItems, TotalPoints, GradeThresholds, Source Manual/Ai + AiPrompt/AiProvider/AiModel (von 10b für KI-Entwürfe gesetzt, nie für Kinder sichtbar), RowVersion, Soft Delete), `LearningItem` (Frage, Antworttyp Number/Text/SingleChoice/Paper (Paper je Aufgabe oder für das ganze Blatt in einem Schritt, 12a), CorrectAnswer, NumberTolerance, Options, Hint, Explanation, Difficulty, Points, RowVersion, Soft Delete), `LearningAttempt` (Nummer, Status, StartedAt, DeadlineAt, SubmittedAt, AutoSubmitted, ItemOrder, Ergebnis/Note, RowVersion), `LearningAnswer` (Antwort, Verdict Open/Correct/Partial/Wrong, Grader Auto/Ai/Parent, AwardedPoints, Reason, AiConfidence, PhotoPurgedAt), `LearningPhoto` ✅ (10c: Foto der Papierlösung je Versuch und Aufgabe, Bytes in der Datenbank); `LearningSchedule` ✅ (11c: Zeitplan für automatische Blätter – Kind, Ersteller, Fach, Klasse, Anzahl, Startschwierigkeit + Steigerung, Wochentage/Uhrzeit, Direkt-Zuweisen, `HandledThroughAt`, Retry/Sperre, Soft Delete, RowVersion) und `LearningScheduleRun` (Protokoll ohne Inhalt, eindeutig je Plantermin); `LearningSet.ScheduleId`; `FamilySettings.LearningPhotoRetentionDays`; `PointsLedgerEntry.LearningSetId` – ADR-0021 |
@@ -146,10 +146,10 @@ Das UI bekommt die wirksamen Berechtigungen über `/api/auth/me` und blendet anh
| Bereich | Endpunkte |
|---|---|
| Auth ✅ | `POST /api/auth/login`, `POST /api/auth/pin-login` (Feature-Flag), `POST /api/auth/logout`, `GET /api/auth/me`, `POST /api/auth/change-password` |
| Auth ✅ | `POST /api/auth/login`, `POST /api/auth/pin-login` (Feature-Flag), `POST /api/auth/logout`, `GET /api/auth/me`, `PUT /api/auth/me/birth-date` (eigenes Geburtsdatum, jeder angemeldete Benutzer), `POST /api/auth/change-password` |
| Admin – Benutzer ✅ | `GET/POST /api/admin/users`, `GET/PUT/DELETE /api/admin/users/{id}`, `POST …/{id}/reset-password`, `POST …/{id}/unlock`, `PUT …/{id}/pin`, `PUT/DELETE …/{id}/avatar` |
| Admin – Einstellungen ✅ | `GET /api/settings` (alle), `PUT /api/admin/settings`, `GET /api/admin/audit?page=&pageSize=` |
| Benutzerverzeichnis ✅ | `GET /api/users`, `GET /api/users/{id}/avatar` |
| Benutzerverzeichnis ✅ | `GET /api/users`, `GET /api/users/{id}/avatar` – `BirthDate` im Verzeichnis nur für die eigene Person, Admins und Familie (`BirthdayRules.CanView`, serverseitig) |
| Einkauf ✅ | `GET/POST /api/shopping/lists`, `PUT/DELETE /api/shopping/lists/{id}`, `GET/POST /api/shopping/lists/{id}/items`, `PATCH/DELETE /api/shopping/items/{id}`, `POST /api/shopping/lists/{id}/clear-done`, `GET /api/shopping/suggestions?q=&take=` |
| Essen ✅ | `GET /api/meals?from=&to=`, `PUT /api/meals` (Upsert je Tag + Mahlzeit, Platz im Body), `DELETE /api/meals/{id}`, `POST /api/meals/{id}/move`, `POST /api/meals/{id}/to-shopping`, `GET /api/meals/dishes?q=&take=`, `GET/POST /api/meals/wishes`, `POST …/wishes/{id}/accept`, `POST …/wishes/{id}/reject`, `DELETE …/wishes/{id}` |
| Aufgaben ✅ | `GET/POST /api/tasks` (Anlegen idempotent mit Client-Id), `GET/PUT/DELETE /api/tasks/{id}` (Detail inkl. Verlauf, Ändern mit Version), `POST /api/tasks/{id}/status` (Zielstatus), `PUT /api/tasks/{id}/checklist/{itemId}`, `POST /api/tasks/{id}/approve`, `POST /api/tasks/{id}/reject`, `GET /api/tasks/points`, `POST /api/tasks/points/adjust` |
+7 -1
View File
@@ -194,6 +194,12 @@ Legende: `[x]` erledigt, `[ ]` offen. Nach jeder Phase: Build ohne Warnungen, Te
- [x] KI-Entwurf: Admin sieht den gesendeten Prompt (`POST /api/learning/sets/ai-draft/prompt`), kann Systemanweisung und Nutzer-Prompt anpassen (`CreateAiDraftRequest.PromptOverride`, nur Admin, serverseitig 403 sonst), Schema bleibt separat, Vermerk am Blatt (`AiPrompt`)
- [x] Migration `Phase14AiPrompts`; Tests: Unit (Auflösung, Prompt-Override, Validatoren), bUnit (Entwurf-Dialog Admin/Eltern, Benutzer-KI-Dialog), Integration (`AiPromptTests`, `AiExchangeLogTests`)
## Phase 15 – Geburtstage
- [x] Geburtsdatum (optional) für alle Benutzer: Admin setzt es in der Benutzerverwaltung (Spalte mit Alter), jeder Benutzer im Profil („Meine Einstellungen“, `PUT /api/auth/me/birth-date`); Validator (nicht vor 1900, nicht in der Zukunft); Alter als reine Funktion `BirthdayRules.AgeOn` (Schaltjahr 29.2. → 28.2.)
- [x] Datenschutz: `BirthDate` im Benutzerverzeichnis nur für die eigene Person, Admins und Familie, serverseitig; Audit-Log ohne Wert
- [x] Kalender-Vorlage „Geburtstag“: Person aus dem Verzeichnis (Datum vorbelegt) oder freie Eingabe; ganztägig, jährlich, Titel „Geburtstag von X“, Alter je Vorkommen (`CalendarEvent.BirthDate`, `Age`/`DisplayTitle`) in Agenda/Monat/Woche/Dashboard; ICS/CalDAV: eine Serie mit Kategorie „Geburtstag“, `BirthDate` bleibt beim CalDAV-`PUT` erhalten (ADR-0015 Nachtrag)
- [x] Migration `Phase15Birthdays`; Tests: Unit (Alter/Schaltjahr, Validatoren, Normalisierung, DTO, ICS), bUnit (Vorlage, Benutzerverwaltung, Profil), Integration (`BirthdayTests`, CalDAV-Round-Trip)
## Backlog
- PIN-Login-Oberfläche für ein Familien-Tablet (Profilauswahl + Ziffernblock) – Api ist vorbereitet
- Selbst-Upload des eigenen Profilbilds, Profilseite für Anzeigename/Farbe
@@ -205,7 +211,7 @@ Legende: `[x]` erledigt, `[ ]` offen. Nach jeder Phase: Build ohne Warnungen, Te
- Chat: Nachrichten bearbeiten, Antworten, Suche, Aufräumen alter Nachrichten/Bilder
- Kalender: Stunden-Raster mit Drag & Drop, „dieser und folgende“, Erinnerungen per Web-Push für Termine
- Rezeptdatenbank
- Geburtstage und Jahrestage
- Jahrestage; automatische Geburtstage aller Benutzer als eigene Kalenderebene
- Wand-Tablet-Modus (Kiosk-Dashboard)
- Outlook-Synchronisation über Microsoft Graph (ADR-0017): Zwei-Wege-Abgleich mit Outlook/Microsoft 365 (OAuth, Delta-Abfragen, Webhooks); Outlook kann kein CalDAV, bis dahin nur lesend per ICS-Abo
- CalDAV: Praxistest mit echten Clients (iOS/macOS, Thunderbird, DAVx5), VALARM/Teilnehmer erhalten, Admin-Übersicht und Widerruf der App-Passwörter aller Benutzer
@@ -43,3 +43,21 @@ Zeitraum neu. `Guid.Empty` = Kalenderliste/Rechte geändert.
## Konsequenzen
* Eine Datenquelle für App, Dashboard (`/api/calendar/upcoming`) und ICS-Feeds (ADR-0016).
* Importierte Regeln außerhalb der Teilmenge werden nicht expandiert (siehe ADR-0016).
## Nachtrag Phase 15 – Geburtstage
* **Geburtsdatum:** `AppUser.BirthDate` (optional, `DateOnly`). Setzen: Admin in der Benutzerverwaltung (`UserManagement`), jeder Benutzer für sich selbst
(`PUT /api/auth/me/birth-date`, auch Kinder und Gäste – es gibt keine fremdbestimmten Profilregeln, die das verbieten; Eltern ohne `UserManagement` pflegen
das Datum eines Kindes nicht). Validator in `Shared` (nicht vor 1900, nicht in der Zukunft, Stichtag heute in Europe/Berlin über `TimeProvider`).
Das Audit-Log vermerkt nur „Geburtsdatum geändert“, nicht den Wert.
* **Datenschutz:** `GET /api/users` liefert `BirthDate` nur für die eigene Person, Admins und Familie (Eltern); Kinder und Gäste sehen fremde Geburtsdaten nie.
Ein Termin trägt sein Geburtsdatum für alle, die den Kalender lesen dürfen – im DTO aber nur für Benutzer, die den Termin ändern dürfen (das Alter je
Vorkommen sehen alle Leser).
* **Alter:** reine Funktion `BirthdayRules.AgeOn`. Geburtstage am 29.2. werden in Nichtschaltjahren am **28.2.** gefeiert (Serienregel
`FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=-1`, passend zum Alter: ab dem 28.2. gilt das neue Lebensjahr; rechtlich nach § 187/188 BGB wäre es der 1.3.).
* **Vorlage „Geburtstag“:** `CalendarEvent.BirthDate` markiert den Termin als Geburtstagsserie. Der Server erzwingt (`CalendarEndpoints.NormalizeBirthday`):
ganztägig, jährlich, Beginn am ersten Geburtstag (Geburtsjahr + 1); gesendete Zeiten/Regeln werden überschrieben. Titel bleibt schlicht „Geburtstag von Anna“;
`CalendarOccurrenceDto.Age`/`DisplayTitle` liefern je Vorkommen „… (7 Jahre)“. Geburtstagsserien werden nur als Ganzes bearbeitet (kein „nur dieses Vorkommen“);
Löschen einzelner Jahre geht nur über die API (`scope=Occurrence`); in der Oberfläche löscht „Löschen“ die ganze Serie (offener Punkt); ein geändertes Geburtsdatum verwirft Ausnahmen/Überschreibungen. Überschreibungen verwenden das Datum der Serie.
* **Export:** ICS-Abo und CalDAV liefern genau eine Serie (`SUMMARY` ohne Alter – ein VEVENT kann keinen Titel je Vorkommen tragen) mit `CATEGORIES:Geburtstag`;
das Geburtsdatum wird nicht exportiert. CalDAV-Clients kennen `BirthDate` nicht: Beim `PUT` bleibt es erhalten (wie Ersteller und Personen-Tags).
* **Nicht umgesetzt:** automatische Geburtstage aller Benutzer im Kalender (wäre eine zusätzliche virtuelle Ebene je Sichtbarkeitsregel; bewusst weggelassen).
@@ -74,6 +74,7 @@ public static class UserAdminEndpoints
ExpiresAt = request.ExpiresAt,
MustChangePassword = true,
IsActive = true,
BirthDate = request.BirthDate,
CreatedAt = now,
};
@@ -122,6 +123,12 @@ public static class UserAdminEndpoints
Track(changes, "Rolle", currentRole, request.Role);
Track(changes, "Aktiv", user.IsActive.ToString(), request.IsActive.ToString());
Track(changes, "Ablaufdatum", user.ExpiresAt?.ToString("u"), request.ExpiresAt?.ToString("u"));
if (user.BirthDate != request.BirthDate)
{
// Personenbezogen: im Audit-Log nur der Vermerk, nicht der Wert.
changes.Add("Geburtsdatum geändert");
}
var securityRelevant = currentRole != request.Role || user.IsActive != request.IsActive || user.ExpiresAt != request.ExpiresAt;
@@ -130,6 +137,7 @@ public static class UserAdminEndpoints
user.Color = request.Color.ToUpperInvariant();
user.IsActive = request.IsActive;
user.ExpiresAt = request.ExpiresAt;
user.BirthDate = request.BirthDate;
var result = await userManager.UpdateAsync(user);
if (!result.Succeeded)
@@ -367,7 +375,8 @@ public static class UserAdminEndpoints
user.MustChangePassword,
user.PinHash is not null,
user.CreatedAt,
user.LastLoginAt);
user.LastLoginAt,
user.BirthDate);
private static string? NullIfEmpty(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
@@ -34,6 +34,11 @@ public static class AuthEndpoints
group.MapGet("/me", MeAsync)
.RequireAuthorization();
// Jeder angemeldete Benutzer (auch Kinder und Gäste) pflegt sein eigenes Geburtsdatum.
group.MapPut("/me/birth-date", SetBirthDateAsync)
.RequireAuthorization()
.Validate<SetBirthDateRequest>();
group.MapPost("/change-password", ChangePasswordAsync)
.RequireAuthorization()
.Validate<ChangePasswordRequest>();
@@ -141,6 +146,23 @@ public static class AuthEndpoints
return TypedResults.Ok(await userManager.ToCurrentUserDtoAsync(user, settingsProvider));
}
internal static async Task<Results<Ok<CurrentUserDto>, UnauthorizedHttpResult>> SetBirthDateAsync(
SetBirthDateRequest request,
HttpContext httpContext,
UserManager<AppUser> userManager,
ISettingsProvider settingsProvider)
{
var user = await userManager.GetUserAsync(httpContext.User);
if (user is null)
{
return TypedResults.Unauthorized();
}
user.BirthDate = request.BirthDate;
await userManager.UpdateAsync(user);
return TypedResults.Ok(await userManager.ToCurrentUserDtoAsync(user, settingsProvider));
}
internal static async Task<Results<Ok<CurrentUserDto>, ValidationProblem, UnauthorizedHttpResult>> ChangePasswordAsync(
ChangePasswordRequest request,
HttpContext httpContext,
@@ -6,6 +6,7 @@ using FamilyPlanner.Domain.Calendar;
using FamilyPlanner.Domain.Common;
using FamilyPlanner.Domain.Recurrence;
using FamilyPlanner.Domain.Tasks;
using FamilyPlanner.Domain.Users;
using FamilyPlanner.Infrastructure.Audit;
using FamilyPlanner.Infrastructure.Persistence;
using FamilyPlanner.Infrastructure.Settings;
@@ -158,6 +159,7 @@ public static class CalendarEndpoints
return TypedResults.Forbid();
}
request = NormalizeBirthday(request);
var zone = CalendarTime.Zone((await settingsProvider.GetAsync(ct)).TimeZoneId);
var userId = user.UserId() ?? Guid.Empty;
@@ -185,6 +187,7 @@ public static class CalendarEndpoints
};
ApplyContent(calendarEvent, request);
calendarEvent.RecurrenceRule = NormalizeRule(request.RecurrenceRule);
calendarEvent.BirthDate = request.BirthDate;
db.CalendarEvents.Add(calendarEvent);
await db.SaveChangesAsync(ct);
await notifier.ChangedAsync(calendarEvent.CalendarId, ct);
@@ -240,8 +243,10 @@ public static class CalendarEndpoints
}
var zone = CalendarTime.Zone((await settingsProvider.GetAsync(ct)).TimeZoneId);
request = NormalizeBirthday(request);
var (startUtc, endUtc) = CalendarTime.ToUtc(Span(request), zone);
var onlyOccurrence = (scope ?? EventEditScope.All) == EventEditScope.Occurrence && (target.IsOverride || target.IsRecurring);
var onlyOccurrence = (scope ?? EventEditScope.All) == EventEditScope.Occurrence && (target.IsOverride || target.IsRecurring)
&& master.BirthDate is null && request.BirthDate is null; // Geburtstagsserien nur als Ganzes ändern
if (onlyOccurrence)
{
@@ -290,7 +295,7 @@ public static class CalendarEndpoints
// Serie über ein Vorkommen bearbeitet: Verschiebung relativ zum Vorkommen auf den Serienbeginn übertragen.
var reference = target.IsOverride ? target.RecurrenceId : master.IsRecurring ? occurrence : null;
if (reference is { } occurrenceStart && master.IsRecurring)
if (reference is { } occurrenceStart && master.IsRecurring && request.BirthDate is null)
{
var shift = FamilyTime.ToLocal(startUtc, zone) - FamilyTime.ToLocal(occurrenceStart, zone);
var newStart = FamilyTime.ToUtc(FamilyTime.ToLocal(master.StartUtc, zone) + shift, zone);
@@ -301,20 +306,27 @@ public static class CalendarEndpoints
var oldCalendar = master.CalendarId;
ApplyContent(master, request);
(master.StartUtc, master.EndUtc, master.CalendarId) = (startUtc, endUtc, request.CalendarId);
var birthDateChanged = master.BirthDate != request.BirthDate && request.BirthDate is not null;
master.BirthDate = request.BirthDate;
master.RecurrenceRule = NormalizeRule(request.RecurrenceRule);
master.Sequence++;
if (master.RecurrenceRule is null)
{
master.ExceptionDates = [];
}
else if (birthDateChanged)
{
// Neuer Serienbeginn: bisherige Ausnahmen und Einzeländerungen passen nicht mehr.
master.ExceptionDates = [];
}
if (oldCalendar != master.CalendarId || master.RecurrenceRule is null)
if (oldCalendar != master.CalendarId || master.RecurrenceRule is null || birthDateChanged)
{
// Überschreibungen folgen dem Kalender; ohne Wiederholung entfallen sie.
var overrides = await db.CalendarEvents.Where(e => e.SeriesMasterId == master.Id).ToListAsync(ct);
foreach (var o in overrides)
{
if (master.RecurrenceRule is null)
if (master.RecurrenceRule is null || birthDateChanged)
{
db.CalendarEvents.Remove(o);
}
@@ -517,6 +529,26 @@ public static class CalendarEndpoints
e.ForWholeFamily = r.ForWholeFamily;
}
/// <summary>Geburtstagsvorlage: ganztägig, jährlich, Beginn am ersten Geburtstag – unabhängig von den gesendeten Zeiten.</summary>
internal static SaveEventRequest NormalizeBirthday(SaveEventRequest r)
{
if (r.BirthDate is not { } birthDate)
{
return r;
}
var first = BirthdayRules.FirstBirthday(birthDate);
return r with
{
IsAllDay = true,
StartDate = first,
EndDate = first,
StartTime = null,
EndTime = null,
RecurrenceRule = BirthdayRules.RecurrenceRuleFor(birthDate),
};
}
private static string? NormalizeRule(string? rule) =>
RecurrenceRule.TryParse(rule, out var parsed, out _) ? parsed.ToString() : null;
@@ -544,12 +576,17 @@ public static class CalendarEndpoints
var e = o.Event;
var master = e.IsOverride ? byId.GetValueOrDefault(e.SeriesMasterId!.Value) : e;
var local = CalendarTime.ToLocal(o.StartUtc, o.EndUtc, e.IsAllDay, zone);
var birthDate = master?.BirthDate ?? e.BirthDate;
var canEdit = CalendarRules.CanModify(access, master?.CreatedBy ?? e.CreatedBy, userId);
int? age = birthDate is { } birth ? BirthdayRules.AgeOn(birth, FamilyTime.LocalDate(o.OccurrenceKey, zone)) : null;
return new CalendarOccurrenceDto(
e.Id, o.SeriesId, o.OccurrenceKey, e.CalendarId, e.Title, e.Location, e.Notes, e.IsAllDay,
o.StartUtc, o.EndUtc, local.StartDate, local.StartTime, local.EndDate, local.EndTime,
e.PersonIds, e.ForWholeFamily, master?.RecurrenceRule, e.IsRecurring, e.IsOverride,
master?.CreatedBy ?? e.CreatedBy,
CalendarRules.CanModify(access, master?.CreatedBy ?? e.CreatedBy, userId),
e.RowVersion);
canEdit,
e.RowVersion,
canEdit ? birthDate : null,
age);
}
}
@@ -28,6 +28,9 @@ public static class IcsBuilder
{
public const string ProductId = "-//FamilyPlanner//Familienplaner//DE";
/// <summary>CATEGORIES-Wert für Geburtstagsserien.</summary>
public const string BirthdayCategory = "Geburtstag";
public static string Build(
string feedName,
string timeZoneId,
@@ -102,6 +105,12 @@ public static class IcsBuilder
ical.Categories = [calendarName];
}
if (e.BirthDate is not null)
{
// Der Titel je Vorkommen kann das Alter nicht tragen (eine Serie hat einen SUMMARY) – Kategorie markiert die Serie als Geburtstag.
ical.Categories.Add(BirthdayCategory);
}
if (e.IsRecurring)
{
ical.RecurrenceRule = new RecurrencePattern(e.RecurrenceRule!);
@@ -1,3 +1,6 @@
using System.Security.Claims;
using FamilyPlanner.Api.Infrastructure;
using FamilyPlanner.Domain.Users;
using FamilyPlanner.Infrastructure.Identity;
using FamilyPlanner.Infrastructure.Persistence;
using FamilyPlanner.Shared.Admin;
@@ -19,18 +22,25 @@ public static class UserDirectoryEndpoints
return app;
}
internal static async Task<Ok<List<UserDirectoryEntry>>> ListAsync(AppDbContext db, TimeProvider timeProvider, CancellationToken ct)
internal static async Task<Ok<List<UserDirectoryEntry>>> ListAsync(
ClaimsPrincipal principal, AppDbContext db, TimeProvider timeProvider, CancellationToken ct)
{
var now = timeProvider.GetUtcNow();
var roles = await db.GetRoleMapAsync(ct);
var users = await db.Users.AsNoTracking()
.Where(u => u.IsActive && (u.ExpiresAt == null || u.ExpiresAt > now))
.OrderBy(u => u.DisplayName)
.Select(u => new { u.Id, u.DisplayName, u.Color, u.AvatarUrl })
.Select(u => new { u.Id, u.DisplayName, u.Color, u.AvatarUrl, u.BirthDate })
.ToListAsync(ct);
// Geburtsdatum nur für die eigene Person, Admins und Familie – serverseitig, nicht im UI.
var viewerId = principal.UserId() ?? Guid.Empty;
var viewerRoles = principal.FindAll(ClaimTypes.Role).Select(c => c.Value).ToList();
return TypedResults.Ok(users
.Select(u => new UserDirectoryEntry(u.Id, u.DisplayName, u.Color, u.AvatarUrl, roles.GetValueOrDefault(u.Id, string.Empty)))
.Select(u => new UserDirectoryEntry(
u.Id, u.DisplayName, u.Color, u.AvatarUrl, roles.GetValueOrDefault(u.Id, string.Empty),
BirthdayRules.CanView(viewerId, viewerRoles, u.Id) ? u.BirthDate : null))
.ToList());
}
@@ -76,6 +76,7 @@ internal static class EndpointHelpers
user.MustChangePassword,
user.AvatarUrl,
user.ExpiresAt,
PermissionMatrix.Effective(roles, settings.ToPermissionOptions()));
PermissionMatrix.Effective(roles, settings.ToPermissionOptions()),
user.BirthDate);
}
}
@@ -105,6 +105,12 @@ public sealed class CalendarEvent : AuditableEntity, ISoftDeletable, IHasRowVers
/// <summary>Herkunft beim Import (Dateiname), sonst <c>null</c>.</summary>
public string? ImportSource { get; set; }
/// <summary>
/// Gesetzt = Geburtstagstermin (Vorlage „Geburtstag von …“): ganztägig, jährlich wiederkehrend, Alter je Vorkommen aus diesem Datum.
/// Nur an der Serie (bzw. dem Einzeltermin) gepflegt; Überschreibungen verwenden das Datum der Serie.
/// </summary>
public DateOnly? BirthDate { get; set; }
public bool IsDeleted { get; set; }
public DateTimeOffset? DeletedAt { get; set; }
@@ -0,0 +1,59 @@
namespace FamilyPlanner.Domain.Users;
/// <summary>
/// Reine Regeln rund um Geburtstage: Gültigkeit, Alter, Geburtstag je Jahr und die Vorlage „Geburtstag von …“ im Kalender.
/// Geburtstage vom 29.2. werden in Nichtschaltjahren am 28.2. gefeiert (passend zur Wiederholungsregel
/// <c>BYMONTH=2;BYMONTHDAY=-1</c>); ab diesem Tag zählt das neue Lebensjahr.
/// </summary>
public static class BirthdayRules
{
public const int MinYear = 1900;
public const string TitlePrefix = "Geburtstag von ";
/// <summary>Nicht in der Zukunft und nicht vor 1900.</summary>
public static bool IsValidBirthDate(DateOnly birthDate, DateOnly today) => birthDate.Year >= MinYear && birthDate <= today;
/// <summary>Der Tag, an dem im angegebenen Jahr Geburtstag gefeiert wird (29.2. → 28.2. in Nichtschaltjahren).</summary>
public static DateOnly BirthdayIn(DateOnly birthDate, int year) =>
birthDate is { Month: 2, Day: 29 } && !DateTime.IsLeapYear(year)
? new DateOnly(year, 2, 28)
: new DateOnly(year, birthDate.Month, birthDate.Day);
/// <summary>Alter in vollendeten Jahren am Stichtag; <c>null</c>, wenn der Stichtag vor der Geburt liegt.</summary>
public static int? AgeOn(DateOnly birthDate, DateOnly date)
{
if (date < birthDate)
{
return null;
}
var age = date.Year - birthDate.Year;
return date < BirthdayIn(birthDate, date.Year) ? age - 1 : age;
}
/// <summary>
/// Datenschutz: Das Geburtsdatum sieht nur die Person selbst sowie Admins und Familie (Eltern) – Kinder und Gäste sehen fremde Geburtsdaten nicht.
/// </summary>
public static bool CanView(Guid viewerId, IEnumerable<string> viewerRoles, Guid personId) =>
viewerId == personId || viewerRoles.Any(r => r is Roles.Admin or Roles.Family);
/// <summary>Erster Geburtstag (Beginn der Kalenderserie).</summary>
public static DateOnly FirstBirthday(DateOnly birthDate) => BirthdayIn(birthDate, birthDate.Year + 1);
/// <summary>Wiederholungsregel der jährlichen Geburtstagsserie (RFC 5545, Teilmenge laut ADR-0014).</summary>
public static string RecurrenceRuleFor(DateOnly birthDate) =>
birthDate is { Month: 2, Day: 29 } ? "FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=-1" : "FREQ=YEARLY";
public static string TitleFor(string name) => TitlePrefix + name.Trim();
/// <summary>Name aus einem Titel „Geburtstag von X“; sonst der Titel unverändert.</summary>
public static string NameFromTitle(string title) =>
title.StartsWith(TitlePrefix, StringComparison.Ordinal) ? title[TitlePrefix.Length..].Trim() : title;
/// <summary>„7 Jahre“, „1 Jahr“.</summary>
public static string AgeLabel(int age) => age == 1 ? "1 Jahr" : $"{age} Jahre";
/// <summary>Titel je Vorkommen, z. B. „Geburtstag von Anna (7 Jahre)“; ohne Alter der Titel unverändert.</summary>
public static string DisplayTitle(string title, int? age) => age is { } a ? $"{title} ({AgeLabel(a)})" : title;
}
@@ -30,6 +30,9 @@ public sealed class AppUser : IdentityUser<Guid>, ISoftDeletable
/// <summary>Optionales Ablaufdatum (für Gastzugänge).</summary>
public DateTimeOffset? ExpiresAt { get; set; }
/// <summary>Optionales Geburtsdatum (personenbezogen: nur für die Person selbst, Admins und Eltern sichtbar).</summary>
public DateOnly? BirthDate { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset? LastLoginAt { get; set; }
@@ -0,0 +1,39 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FamilyPlanner.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class Phase15Birthdays : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateOnly>(
name: "BirthDate",
table: "CalendarEvents",
type: "date",
nullable: true);
migrationBuilder.AddColumn<DateOnly>(
name: "BirthDate",
table: "AspNetUsers",
type: "date",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "BirthDate",
table: "CalendarEvents");
migrationBuilder.DropColumn(
name: "BirthDate",
table: "AspNetUsers");
}
}
}
@@ -406,6 +406,9 @@ namespace FamilyPlanner.Infrastructure.Persistence.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateOnly?>("BirthDate")
.HasColumnType("date");
b.Property<Guid>("CalendarId")
.HasColumnType("uniqueidentifier");
@@ -2381,6 +2384,9 @@ namespace FamilyPlanner.Infrastructure.Persistence.Migrations
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<DateOnly?>("BirthDate")
.HasColumnType("date");
b.Property<string>("Color")
.IsRequired()
.HasMaxLength(9)
@@ -15,7 +15,8 @@ public sealed record UserAdminDto(
bool MustChangePassword,
bool HasPin,
DateTimeOffset CreatedAt,
DateTimeOffset? LastLoginAt);
DateTimeOffset? LastLoginAt,
DateOnly? BirthDate = null);
public sealed record CreateUserRequest(
string UserName,
@@ -24,7 +25,8 @@ public sealed record CreateUserRequest(
string Role,
string Color,
string Password,
DateTimeOffset? ExpiresAt);
DateTimeOffset? ExpiresAt,
DateOnly? BirthDate = null);
public sealed record UpdateUserRequest(
string DisplayName,
@@ -32,15 +34,19 @@ public sealed record UpdateUserRequest(
string Role,
string Color,
bool IsActive,
DateTimeOffset? ExpiresAt);
DateTimeOffset? ExpiresAt,
DateOnly? BirthDate = null);
public sealed record ResetPasswordRequest(string NewPassword);
/// <summary><c>Pin = null</c> entfernt die PIN.</summary>
public sealed record SetPinRequest(string? Pin);
/// <summary>Öffentliches Verzeichnis der Familienmitglieder (für Zuweisungen, Farben, Avatare).</summary>
public sealed record UserDirectoryEntry(Guid Id, string DisplayName, string Color, string? AvatarUrl, string Role);
/// <summary>
/// Öffentliches Verzeichnis der Familienmitglieder (für Zuweisungen, Farben, Avatare). <see cref="BirthDate"/> ist personenbezogen und wird
/// serverseitig nur für die eigene Person, Admins und Familie (Eltern) befüllt – sonst <c>null</c>.
/// </summary>
public sealed record UserDirectoryEntry(Guid Id, string DisplayName, string Color, string? AvatarUrl, string Role, DateOnly? BirthDate = null);
public sealed record FamilySettingsDto(
string FamilyName,
@@ -33,7 +33,7 @@ internal static class AdminRules
public sealed class CreateUserRequestValidator : AbstractValidator<CreateUserRequest>
{
public CreateUserRequestValidator()
public CreateUserRequestValidator(TimeProvider? timeProvider = null)
{
RuleFor(r => r.UserName)
.NotEmpty().WithMessage("Bitte einen Benutzernamen eingeben.")
@@ -43,6 +43,7 @@ public sealed class CreateUserRequestValidator : AbstractValidator<CreateUserReq
RuleFor(r => r.Email).ValidOptionalEmail().When(r => !string.IsNullOrWhiteSpace(r.Email));
RuleFor(r => r.Role).ValidRole();
RuleFor(r => r.Color).ValidColor();
RuleFor(r => r.BirthDate).ValidOptionalBirthDate(timeProvider);
RuleFor(r => r.Password).ValidNewPassword();
RuleFor(r => r.ExpiresAt).Null().When(r => r.Role != Roles.Guest)
.WithMessage("Ein Ablaufdatum ist nur für Gastzugänge vorgesehen.");
@@ -51,12 +52,13 @@ public sealed class CreateUserRequestValidator : AbstractValidator<CreateUserReq
public sealed class UpdateUserRequestValidator : AbstractValidator<UpdateUserRequest>
{
public UpdateUserRequestValidator()
public UpdateUserRequestValidator(TimeProvider? timeProvider = null)
{
RuleFor(r => r.DisplayName).ValidDisplayName();
RuleFor(r => r.Email).ValidOptionalEmail().When(r => !string.IsNullOrWhiteSpace(r.Email));
RuleFor(r => r.Role).ValidRole();
RuleFor(r => r.Color).ValidColor();
RuleFor(r => r.BirthDate).ValidOptionalBirthDate(timeProvider);
RuleFor(r => r.ExpiresAt).Null().When(r => r.Role != Roles.Guest)
.WithMessage("Ein Ablaufdatum ist nur für Gastzugänge vorgesehen.");
}
+13 -1
View File
@@ -9,6 +9,9 @@ public sealed record PinLoginRequest(string UserName, string Pin);
public sealed record ChangePasswordRequest(string CurrentPassword, string NewPassword);
/// <summary>Eigenes Geburtsdatum setzen; <c>null</c> entfernt es.</summary>
public sealed record SetBirthDateRequest(DateOnly? BirthDate);
public sealed record CurrentUserDto(
Guid Id,
string UserName,
@@ -19,10 +22,19 @@ public sealed record CurrentUserDto(
bool MustChangePassword,
string? AvatarUrl = null,
DateTimeOffset? ExpiresAt = null,
IReadOnlyDictionary<Permission, AccessLevel>? Permissions = null)
IReadOnlyDictionary<Permission, AccessLevel>? Permissions = null,
DateOnly? BirthDate = null)
{
public AccessLevel AccessTo(Permission permission) =>
Permissions is not null && Permissions.TryGetValue(permission, out var level) ? level : AccessLevel.None;
public bool Can(Permission permission) => AccessTo(permission) != AccessLevel.None;
}
public sealed class SetBirthDateRequestValidator : FluentValidation.AbstractValidator<SetBirthDateRequest>
{
public SetBirthDateRequestValidator(TimeProvider? timeProvider = null)
{
RuleFor(r => r.BirthDate).ValidOptionalBirthDate(timeProvider);
}
}
@@ -0,0 +1,15 @@
using FamilyPlanner.Domain.Common;
using FamilyPlanner.Domain.Users;
using FluentValidation;
namespace FamilyPlanner.Shared;
internal static class BirthDateValidation
{
public const string Message = "Bitte ein Geburtsdatum zwischen 1900 und heute wählen (nicht in der Zukunft).";
/// <summary>Optionales Geburtsdatum: nicht vor 1900, nicht in der Zukunft (Stichtag: heute in der Familienzeitzone).</summary>
public static IRuleBuilderOptions<T, DateOnly?> ValidOptionalBirthDate<T>(this IRuleBuilder<T, DateOnly?> rule, TimeProvider? timeProvider) =>
rule.Must(date => date is null || BirthdayRules.IsValidBirthDate(date.Value, FamilyTime.Today(timeProvider ?? TimeProvider.System)))
.WithMessage(Message);
}
@@ -40,7 +40,8 @@ public sealed record SaveEventRequest(
IReadOnlyList<Guid> PersonIds,
bool ForWholeFamily,
string? RecurrenceRule,
byte[]? Version = null);
byte[]? Version = null,
DateOnly? BirthDate = null);
/// <summary>Ein Vorkommen im angefragten Zeitraum (Einzeltermin, Serienvorkommen oder geänderter Einzeltermin einer Serie).</summary>
public sealed record CalendarOccurrenceDto(
@@ -65,8 +66,13 @@ public sealed record CalendarOccurrenceDto(
bool IsOverride,
Guid? CreatedBy,
bool CanEdit,
byte[] Version)
byte[] Version,
DateOnly? BirthDate = null,
int? Age = null)
{
/// <summary>Titel für die Anzeige: bei Geburtstagen mit Alter je Vorkommen („Geburtstag von Anna (7 Jahre)“).</summary>
public string DisplayTitle => BirthdayRules.DisplayTitle(Title, Age);
/// <summary>Teil einer Serie (Vorkommen oder Überschreibung)?</summary>
public bool IsPartOfSeries => IsRecurring || IsOverride;
@@ -143,8 +149,9 @@ public sealed class SaveEventRequestValidator : AbstractValidator<SaveEventReque
public const int MaxPersons = 20;
public const int MaxDurationDays = 366;
public SaveEventRequestValidator()
public SaveEventRequestValidator(TimeProvider? timeProvider = null)
{
RuleFor(r => r.BirthDate).ValidOptionalBirthDate(timeProvider);
RuleFor(r => r.CalendarId).NotEmpty().WithMessage("Bitte einen Kalender wählen.");
RuleFor(r => r.Title).NotEmpty().WithMessage("Bitte einen Titel eingeben.")
.MaximumLength(CalendarEvent.TitleMaxLength).WithMessage($"Der Titel darf höchstens {CalendarEvent.TitleMaxLength} Zeichen haben.");
@@ -3,6 +3,7 @@
@using Microsoft.AspNetCore.Components.Forms
@inject IAdminApi AdminApi
@inject ISnackbar Snackbar
@inject TimeProvider TimeProvider
<MudDialog>
<DialogContent>
@@ -31,6 +32,11 @@
}
</MudSelect>
<MudDatePicker @bind-Date="_model.BirthDate" Label="Geburtsdatum (optional)" Variant="Variant.Outlined" Clearable="true"
DateFormat="dd.MM.yyyy" InputId="birthdate" Class="fp-birthdate"
HelperText="@BirthDateHelp"
Error="@HasError("BirthDate")" ErrorText="@ErrorFor("BirthDate")" />
<div>
<MudText Typo="Typo.subtitle2" Class="mb-1">Farbe</MudText>
<div class="d-flex flex-wrap gap-2 align-center" role="radiogroup" aria-label="Farbe">
@@ -107,6 +113,10 @@
private bool IsEdit => User is not null;
private string? BirthDateHelp => _model.BirthDate is { } birth && BirthdayRules.AgeOn(DateOnly.FromDateTime(birth), FamilyTime.Today(TimeProvider)) is { } age
? $"Alter: {BirthdayRules.AgeLabel(age)}"
: "Nur für die Person selbst, Eltern und Admins sichtbar.";
protected override void OnInitialized()
{
if (User is { } user)
@@ -117,6 +127,7 @@
_model.Role = user.Role;
_model.Color = user.Color;
_model.IsActive = user.IsActive;
_model.BirthDate = user.BirthDate?.ToDateTime(TimeOnly.MinValue);
_model.ExpiresOn = user.ExpiresAt is { } e ? FamilyTime.LocalDate(e).ToDateTime(TimeOnly.MinValue) : null;
_avatarUrl = user.AvatarUrl;
}
@@ -125,6 +136,7 @@
private async Task SaveAsync()
{
_generalError = null;
var birthDate = _model.BirthDate is { } birth ? DateOnly.FromDateTime(birth) : (DateOnly?)null;
var expiresAt = _model.Role == Roles.Guest && _model.ExpiresOn is { } date
? FamilyTime.EndOfDayUtc(DateOnly.FromDateTime(date))
: (DateTimeOffset?)null;
@@ -133,8 +145,8 @@
UserAdminDto? saved;
if (User is null)
{
var request = new CreateUserRequest(_model.UserName.Trim(), _model.DisplayName.Trim(), _model.Email, _model.Role, _model.Color, _model.Password, expiresAt);
if (!ValidateLocally(new CreateUserRequestValidator().Validate(request)))
var request = new CreateUserRequest(_model.UserName.Trim(), _model.DisplayName.Trim(), _model.Email, _model.Role, _model.Color, _model.Password, expiresAt, birthDate);
if (!ValidateLocally(new CreateUserRequestValidator(TimeProvider).Validate(request)))
{
return;
}
@@ -145,8 +157,8 @@
}
else
{
var request = new UpdateUserRequest(_model.DisplayName.Trim(), _model.Email, _model.Role, _model.Color, _model.IsActive, expiresAt);
if (!ValidateLocally(new UpdateUserRequestValidator().Validate(request)))
var request = new UpdateUserRequest(_model.DisplayName.Trim(), _model.Email, _model.Role, _model.Color, _model.IsActive, expiresAt, birthDate);
if (!ValidateLocally(new UpdateUserRequestValidator(TimeProvider).Validate(request)))
{
return;
}
@@ -256,5 +268,7 @@
public bool IsActive { get; set; } = true;
public DateTime? ExpiresOn { get; set; }
public DateTime? BirthDate { get; set; }
}
}
@@ -1,6 +1,8 @@
@using FamilyPlanner.Domain.Users
@inject IAdminApi AdminApi
@inject IDialogService DialogService
@inject ISnackbar Snackbar
@inject TimeProvider TimeProvider
<div class="d-flex align-center flex-wrap gap-2 mb-3">
<MudText Typo="Typo.h6" HtmlTag="h2">Benutzer</MudText>
@@ -24,6 +26,7 @@ else
<HeaderContent>
<MudTh>Name</MudTh>
<MudTh>Rolle</MudTh>
<MudTh>Geburtstag</MudTh>
<MudTh>Status</MudTh>
<MudTh>Letzte Anmeldung</MudTh>
<MudTh><span class="sr-only">Aktionen</span></MudTh>
@@ -41,6 +44,7 @@ else
<MudTd DataLabel="Rolle">
<MudChip T="string" Size="Size.Small" Color="RoleColor(context.Role)">@Roles.DisplayName(context.Role)</MudChip>
</MudTd>
<MudTd DataLabel="Geburtstag" Class="fp-user-birthdate">@BirthDateText(context.BirthDate)</MudTd>
<MudTd DataLabel="Status">
@foreach (var (text, color) in StatusChips(context))
{
@@ -227,6 +231,12 @@ else
}
}
/// <summary>„05.03.2018 (7 Jahre)“ bzw. „–“.</summary>
private string BirthDateText(DateOnly? birthDate) =>
birthDate is { } birth
? $"{birth:dd.MM.yyyy}" + (BirthdayRules.AgeOn(birth, FamilyPlanner.Domain.Common.FamilyTime.Today(TimeProvider)) is { } age ? $" ({BirthdayRules.AgeLabel(age)})" : string.Empty)
: "–";
internal static string FormatDate(DateTimeOffset? value) =>
value is { } v ? FamilyPlanner.Domain.Common.FamilyTime.ToFamilyTime(v).ToString("dd.MM.yyyy HH:mm") : "–";
}
@@ -136,7 +136,7 @@ public static class CalendarViewModel
}
items.Add(new CalendarItem(
$"{o.EventId}:{o.OccurrenceKey.UtcTicks}", o.Title, o.StartDate, o.EndDate, o.StartTime, o.EndTime, o.IsAllDay,
$"{o.EventId}:{o.OccurrenceKey.UtcTicks}", o.DisplayTitle, o.StartDate, o.EndDate, o.StartTime, o.EndTime, o.IsAllDay,
ColorFor(o, calendarMap, userMap), Persons(o.PersonIds, userMap), o.ForWholeFamily, o.Location, o, null));
}
@@ -1,6 +1,8 @@
@using System.Globalization
@using FamilyPlanner.Domain.Calendar
@using FamilyPlanner.Domain.Recurrence
@using FamilyPlanner.Domain.Users
@using FamilyPlanner.Shared.Admin
@using FamilyPlanner.Shared.Calendar
@inject ICalendarApi Api
@inject IDialogService DialogService
@@ -19,6 +21,32 @@
<MudAlert Severity="Severity.Info" Dense="true">Du änderst nur diesen einen Termin der Serie.</MudAlert>
}
@if (Occurrence is null && !ReadOnly)
{
<MudSelect T="string" Value="_template" ValueChanged="SetTemplate" Label="Vorlage" Variant="Variant.Outlined" Class="fp-event-template">
<MudSelectItem T="string" Value="@string.Empty">Keine Vorlage</MudSelectItem>
<MudSelectItem T="string" Value="@BirthdayTemplate">Geburtstag</MudSelectItem>
</MudSelect>
}
@if (IsBirthday)
{
<MudSelect T="Guid?" Value="_birthPersonId" ValueChanged="SelectBirthdayPerson" Label="Person aus der Familie" Variant="Variant.Outlined"
Clearable="true" Class="fp-birthday-person"
HelperText="Nur Personen mit hinterlegtem Geburtsdatum – oder unten Name und Datum frei eingeben.">
@foreach (var person in UsersWithBirthDate)
{
<MudSelectItem T="Guid?" Value="@((Guid?)person.Id)">@person.DisplayName</MudSelectItem>
}
</MudSelect>
<MudTextField @bind-Value="_birthName" Label="Name" InputId="birthday-name" Variant="Variant.Outlined"
MaxLength="@(CalendarEvent.TitleMaxLength - BirthdayRules.TitlePrefix.Length)" />
<MudTextField @bind-Value="_birthDate" Label="Geburtsdatum" InputType="InputType.Date" InputId="birthday-date"
Variant="Variant.Outlined" ShrinkLabel="true"
HelperText="Der Termin ist ganztägig und wiederholt sich jedes Jahr; das Alter wird je Jahr berechnet." />
}
else
{
<MudTextField @bind-Value="_model.Title" Label="Titel" InputId="event-title" Variant="Variant.Outlined" AutoFocus="@(!ReadOnly)"
ReadOnly="ReadOnly" MaxLength="CalendarEvent.TitleMaxLength" />
@@ -40,6 +68,7 @@
Variant="Variant.Outlined" ReadOnly="ReadOnly" ShrinkLabel="true" />
}
</div>
}
<MudSelect T="Guid" @bind-Value="_model.CalendarId" Label="Kalender" Variant="Variant.Outlined"
Disabled="@(ReadOnly || Scope == EventEditScope.Occurrence)">
@@ -70,12 +99,15 @@
</div>
</div>
@if (!IsBirthday)
{
<MudTextField @bind-Value="_model.Location" Label="Ort (optional)" InputId="event-location" Variant="Variant.Outlined"
ReadOnly="ReadOnly" MaxLength="CalendarEvent.LocationMaxLength" />
}
<MudTextField @bind-Value="_model.Notes" Label="Notiz (optional)" InputId="event-notes" Variant="Variant.Outlined"
ReadOnly="ReadOnly" Lines="2" Sizing="InputSizing.Auto" MaxLength="CalendarEvent.NotesMaxLength" />
@if (Scope == EventEditScope.All)
@if (Scope == EventEditScope.All && !IsBirthday)
{
<RecurrenceEditor @bind-Model="_model.Recurrence" StartDate="ParsedStartDate" ReadOnly="ReadOnly" />
}
@@ -123,20 +155,38 @@
[Parameter]
public bool ReadOnly { get; set; }
private const string BirthdayTemplate = "birthday";
private readonly EventFormModel _model = new();
private string _template = string.Empty;
private Guid? _birthPersonId;
private string _birthName = string.Empty;
private string _birthDate = string.Empty;
private string? _error;
private bool _busy;
private IEnumerable<CalendarDto> SelectableCalendars =>
Calendars.Where(c => c.Access >= CalendarAccess.WriteOwn || c.Id == _model.CalendarId);
private bool IsBirthday => _template == BirthdayTemplate;
// Geburtsdaten liefert der Server nur für die eigene Person, Admins und Familie (Datenschutz) – Kinder und Gäste können frei eingeben.
private IEnumerable<UserDirectoryEntry> UsersWithBirthDate => Users.Where(u => u.BirthDate is not null);
private DateOnly ParsedStartDate => ParseDate(_model.StartDate) ?? DefaultDate;
protected override void OnInitialized()
{
if (Occurrence is { } o)
{
_model.Title = o.Title;
_model.Title = ReadOnly ? o.DisplayTitle : o.Title;
if (o.BirthDate is { } birth)
{
_template = BirthdayTemplate;
_birthName = BirthdayRules.NameFromTitle(o.Title);
_birthDate = Format(birth);
}
_model.IsAllDay = o.IsAllDay;
_model.StartDate = Format(o.StartDate);
_model.EndDate = Format(o.EndDate);
@@ -164,6 +214,25 @@
private void SetAllDay(bool value) => _model.IsAllDay = value;
private void SetTemplate(string value)
{
_template = value;
if (IsBirthday && _model.PersonIds.Count == 0)
{
_model.ForWholeFamily = true;
}
}
private void SelectBirthdayPerson(Guid? id)
{
_birthPersonId = id;
if (id is { } personId && Users.FirstOrDefault(u => u.Id == personId) is { BirthDate: { } birth } person)
{
_birthName = person.DisplayName;
_birthDate = Format(birth);
}
}
/// <summary>Beginn verschoben: Ende behält den Abstand.</summary>
private void SetStartDate(string value)
{
@@ -200,14 +269,46 @@
private async Task SaveAsync()
{
_error = null;
if (ParseDate(_model.StartDate) is not { } startDate || ParseDate(_model.EndDate) is not { } endDate)
SaveEventRequest request;
if (IsBirthday)
{
_error = "Bitte Beginn und Ende angeben.";
return;
if (string.IsNullOrWhiteSpace(_birthName))
{
_error = "Bitte einen Namen eingeben.";
return;
}
if (ParseDate(_birthDate) is not { } birthDate)
{
_error = "Bitte das Geburtsdatum angeben.";
return;
}
// Ganztägig, jährlich, Beginn am ersten Geburtstag – der Server erzwingt dasselbe.
var first = BirthdayRules.FirstBirthday(birthDate);
request = new SaveEventRequest(
Occurrence is null ? Guid.CreateVersion7() : null, _model.CalendarId, BirthdayRules.TitleFor(_birthName),
null, _model.Notes, true, first, null, first, null, _model.PersonIds, _model.ForWholeFamily,
BirthdayRules.RecurrenceRuleFor(birthDate), Occurrence?.Version, birthDate);
}
else
{
if (ParseDate(_model.StartDate) is not { } startDate || ParseDate(_model.EndDate) is not { } endDate)
{
_error = "Bitte Beginn und Ende angeben.";
return;
}
request = BuildRequest(startDate, endDate);
}
await SubmitAsync(request);
}
private SaveEventRequest BuildRequest(DateOnly startDate, DateOnly endDate)
{
var rule = Scope == EventEditScope.All ? _model.Recurrence.ToRule(startDate) : null;
var request = new SaveEventRequest(
return new SaveEventRequest(
Occurrence is null ? Guid.CreateVersion7() : null,
_model.CalendarId,
_model.Title.Trim(),
@@ -222,7 +323,10 @@
_model.ForWholeFamily,
rule,
Occurrence?.Version);
}
private async Task SubmitAsync(SaveEventRequest request)
{
var validation = new SaveEventRequestValidator().Validate(request);
if (!validation.IsValid)
{
@@ -1,5 +1,11 @@
@page "/konto/einstellungen"
@using FamilyPlanner.Domain.Common
@using FamilyPlanner.Domain.Users
@using FamilyPlanner.Shared.Auth
@inject ThemeState Theme
@inject IProfileApi ProfileApi
@inject ISnackbar Snackbar
@inject TimeProvider TimeProvider
<PageTitle>Meine Einstellungen – Familienplaner</PageTitle>
@@ -15,6 +21,20 @@
<MudDivider Class="my-4" />
<MudText Typo="Typo.h6" HtmlTag="h2" Class="mb-2">Geburtsdatum</MudText>
<MudText Typo="Typo.body2" Class="mb-2">
Optional. Sichtbar nur für dich, die Eltern und Administratoren – Gäste und andere Kinder sehen es nicht.
</MudText>
<div class="d-flex align-center flex-wrap gap-2 fp-profile-birthdate">
<MudDatePicker @bind-Date="_birthDate" Label="Geburtsdatum" Variant="Variant.Outlined" Clearable="true" DateFormat="dd.MM.yyyy"
InputId="profile-birthdate" Style="max-width:240px" HelperText="@AgeText"
Error="@(_birthError is not null)" ErrorText="@_birthError" />
<MudButton Variant="Variant.Outlined" Color="Color.Primary" OnClick="SaveBirthDateAsync" Disabled="_savingBirthDate"
Class="fp-profile-birthdate-save">Speichern</MudButton>
</div>
<MudDivider Class="my-4" />
<MudText Typo="Typo.h6" HtmlTag="h2" Class="mb-2">Benachrichtigungen</MudText>
<MudText Typo="Typo.body2" Class="mb-2">Erinnerungen auf diesem Gerät ein- oder ausschalten.</MudText>
<MudButton Href="konto/benachrichtigungen" Variant="Variant.Outlined" Color="Color.Primary" Class="fp-settings-notifications"
@@ -31,5 +51,42 @@
</MudPaper>
@code {
private DateTime? _birthDate;
private string? _birthError;
private bool _savingBirthDate;
private string? AgeText => _birthDate is { } birth && BirthdayRules.AgeOn(DateOnly.FromDateTime(birth), FamilyTime.Today(TimeProvider)) is { } age
? $"Alter: {BirthdayRules.AgeLabel(age)}"
: null;
protected override async Task OnInitializedAsync()
{
var me = await ProfileApi.GetMeAsync();
_birthDate = me.Value?.BirthDate?.ToDateTime(TimeOnly.MinValue);
}
private async Task SaveBirthDateAsync()
{
_birthError = null;
var birth = _birthDate is { } value ? DateOnly.FromDateTime(value) : (DateOnly?)null;
var validation = new SetBirthDateRequestValidator(TimeProvider).Validate(new SetBirthDateRequest(birth));
if (!validation.IsValid)
{
_birthError = validation.Errors[0].ErrorMessage;
return;
}
_savingBirthDate = true;
var result = await ProfileApi.SetBirthDateAsync(birth);
_savingBirthDate = false;
if (result.Problem is not null)
{
_birthError = result.Problem.Message;
return;
}
Snackbar.Add(birth is null ? "Geburtsdatum entfernt." : "Geburtsdatum gespeichert.", Severity.Success);
}
private Task OnThemeChangedAsync(ThemePreference preference) => Theme.SetPreferenceAsync(preference);
}
@@ -297,7 +297,7 @@ else
var occurrence = item.Occurrence!;
var scope = EventEditScope.All;
if (occurrence.CanEdit && occurrence.IsPartOfSeries)
if (occurrence.CanEdit && occurrence.IsPartOfSeries && occurrence.BirthDate is null)
{
var choice = await DialogService.ShowMessageBoxAsync(
"Serientermin bearbeiten",
@@ -95,7 +95,7 @@ else
{
<li>
<span class="fp-dash-label">@DashboardFormat.EventTime(occurrence, day.Date)</span>
<span class="fp-dash-main">@occurrence.Title</span>
<span class="fp-dash-main">@occurrence.DisplayTitle</span>
@if (!string.IsNullOrWhiteSpace(occurrence.Location))
{
<span class="fp-dash-sub">@occurrence.Location</span>
+1
View File
@@ -32,6 +32,7 @@ builder.Services.AddScoped(sp => sp.GetRequiredService<IHttpClientFactory>().Cre
builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<IAdminApi, AdminApi>();
builder.Services.AddScoped<IProfileApi, ProfileApi>();
builder.Services.AddScoped<IShoppingApi, ShoppingApi>();
builder.Services.AddScoped<IMealsApi, MealsApi>();
builder.Services.AddScoped<ITasksApi, TasksApi>();
@@ -0,0 +1,22 @@
using System.Net.Http.Json;
using FamilyPlanner.Shared.Auth;
namespace FamilyPlanner.Web.Client.Services;
/// <summary>Eigene Profildaten (derzeit: Geburtsdatum).</summary>
public interface IProfileApi
{
Task<ApiResult<CurrentUserDto>> GetMeAsync(CancellationToken ct = default);
/// <summary><c>null</c> entfernt das Geburtsdatum.</summary>
Task<ApiResult<CurrentUserDto>> SetBirthDateAsync(DateOnly? birthDate, CancellationToken ct = default);
}
public sealed class ProfileApi(HttpClient http) : IProfileApi
{
public Task<ApiResult<CurrentUserDto>> GetMeAsync(CancellationToken ct = default) =>
ApiCall.SendAsync<CurrentUserDto>(() => http.GetAsync("api/auth/me", ct), ct);
public Task<ApiResult<CurrentUserDto>> SetBirthDateAsync(DateOnly? birthDate, CancellationToken ct = default) =>
ApiCall.SendAsync<CurrentUserDto>(() => http.PutAsJsonAsync("api/auth/me/birth-date", new SetBirthDateRequest(birthDate), ct), ct);
}
@@ -0,0 +1,249 @@
using System.Net;
using System.Net.Http.Json;
using FamilyPlanner.Domain;
using FamilyPlanner.Domain.Calendar;
using FamilyPlanner.Shared;
using FamilyPlanner.Shared.Admin;
using FamilyPlanner.Shared.Auth;
using FamilyPlanner.Shared.Calendar;
namespace FamilyPlanner.IntegrationTests;
/// <summary>Phase 15: Geburtsdatum der Benutzer (Rechte, Datenschutz) und Kalender-Vorlage „Geburtstag“.</summary>
public sealed class BirthdayTests(AppFixture fixture)
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private static readonly DateOnly Born = new(2018, 3, 5);
private static async Task<Guid> FamilyCalendarIdAsync(HttpClient client) =>
(await client.GetFromJsonAsync<List<CalendarDto>>("/api/calendars", Ct))!.Single(c => c.Name == "Familie").Id;
private static async Task<CalendarRangeDto> RangeAsync(HttpClient client, DateOnly from, DateOnly to) =>
(await client.GetFromJsonAsync<CalendarRangeDto>($"/api/calendar/events?from={from:yyyy-MM-dd}&to={to:yyyy-MM-dd}&includeTasks=false", Ct))!;
/// <summary>Vorkommen einer Serie in ganzen Jahren (die Abfrage ist auf eine begrenzte Spanne beschränkt).</summary>
private static async Task<List<CalendarOccurrenceDto>> SeriesAsync(HttpClient client, Guid seriesId, params int[] years)
{
var result = new List<CalendarOccurrenceDto>();
foreach (var year in years)
{
result.AddRange((await RangeAsync(client, new DateOnly(year, 1, 1), new DateOnly(year, 12, 31))).Occurrences.Where(o => o.SeriesId == seriesId));
}
return result.OrderBy(o => o.StartDate).ToList();
}
private static async Task<UserDirectoryEntry?> DirectoryEntryAsync(HttpClient client, Guid id) =>
(await client.GetFromJsonAsync<List<UserDirectoryEntry>>("/api/users", Ct))!.SingleOrDefault(u => u.Id == id);
private static string Key(CalendarOccurrenceDto o) => Uri.EscapeDataString(o.OccurrenceKey.ToString("o"));
/// <summary>Bewusst mit abweichenden Zeiten/Regel: Der Server leitet alles aus dem Geburtsdatum ab.</summary>
private static SaveEventRequest Birthday(Guid calendarId, string name, DateOnly birth) =>
new(Guid.NewGuid(), calendarId, $"Geburtstag von {name}", null, null,
false, new DateOnly(2040, 1, 1), new TimeOnly(9, 0), new DateOnly(2040, 1, 2), new TimeOnly(10, 0),
[], true, "FREQ=DAILY", null, birth);
// ---------- Benutzer ----------
[Fact]
public async Task Admin_sets_and_changes_the_birth_date_with_audit_note_but_without_the_value()
{
using var admin = await fixture.CreateAdminClientAsync(Ct);
var userName = $"geb-{Guid.NewGuid():N}"[..20];
var created = await admin.PostAsJsonAsync("/api/admin/users",
new CreateUserRequest(userName, "Geburtstagskind", null, Roles.Child, "#43A047", AppFixture.StartPassword, null, Born), Ct);
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
var user = (await created.Content.ReadFromJsonAsync<UserAdminDto>(Ct))!;
Assert.Equal(Born, user.BirthDate);
var update = await admin.PutAsJsonAsync($"/api/admin/users/{user.Id}",
new UpdateUserRequest("Geburtstagskind", null, Roles.Child, "#43A047", true, null, new DateOnly(2017, 12, 24)), Ct);
Assert.Equal(HttpStatusCode.OK, update.StatusCode);
Assert.Equal(new DateOnly(2017, 12, 24), (await admin.GetFromJsonAsync<UserAdminDto>($"/api/admin/users/{user.Id}", Ct))!.BirthDate);
var audit = await admin.GetFromJsonAsync<PagedResult<AuditLogEntryDto>>("/api/admin/audit?page=1&pageSize=100", Ct);
var entry = audit!.Items.First(e => e.TargetName == "Geburtstagskind" && e.Action == "user.updated");
Assert.Contains("Geburtsdatum geändert", entry.Details);
Assert.DoesNotContain("2017", entry.Details);
Assert.DoesNotContain("24.12", entry.Details);
var cleared = await admin.PutAsJsonAsync($"/api/admin/users/{user.Id}",
new UpdateUserRequest("Geburtstagskind", null, Roles.Child, "#43A047", true, null, null), Ct);
Assert.Null((await cleared.Content.ReadFromJsonAsync<UserAdminDto>(Ct))!.BirthDate);
}
[Fact]
public async Task Invalid_birth_dates_are_rejected_for_admin_and_self_service()
{
using var admin = await fixture.CreateAdminClientAsync(Ct);
var future = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(30);
var tooOld = new DateOnly(1899, 12, 31);
foreach (var bad in new[] { future, tooOld })
{
var create = await admin.PostAsJsonAsync("/api/admin/users",
new CreateUserRequest($"bad-{Guid.NewGuid():N}"[..20], "Ungültig", null, Roles.Family, "#43A047", AppFixture.StartPassword, null, bad), Ct);
Assert.Equal(HttpStatusCode.BadRequest, create.StatusCode);
}
var (_, kid) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
Assert.Equal(HttpStatusCode.BadRequest, (await kid.PutAsJsonAsync("/api/auth/me/birth-date", new SetBirthDateRequest(future), Ct)).StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, (await kid.PutAsJsonAsync("/api/auth/me/birth-date", new SetBirthDateRequest(tooOld), Ct)).StatusCode);
}
[Theory]
[InlineData(Roles.Family)]
[InlineData(Roles.Child)]
[InlineData(Roles.Guest)]
public async Task Every_role_maintains_their_own_birth_date(string role)
{
var (user, client) = await fixture.CreateSignedInUserAsync(role, Ct);
var set = await client.PutAsJsonAsync("/api/auth/me/birth-date", new SetBirthDateRequest(Born), Ct);
Assert.Equal(HttpStatusCode.OK, set.StatusCode);
Assert.Equal(Born, (await set.Content.ReadFromJsonAsync<CurrentUserDto>(Ct))!.BirthDate);
Assert.Equal(Born, (await client.GetFromJsonAsync<CurrentUserDto>("/api/auth/me", Ct))!.BirthDate);
Assert.Equal(Born, (await DirectoryEntryAsync(client, user.Id))!.BirthDate);
var cleared = await client.PutAsJsonAsync("/api/auth/me/birth-date", new SetBirthDateRequest(null), Ct);
Assert.Null((await cleared.Content.ReadFromJsonAsync<CurrentUserDto>(Ct))!.BirthDate);
}
[Fact]
public async Task Self_service_requires_login()
{
using var anonymous = fixture.CreateApiClient();
var response = await anonymous.PutAsJsonAsync("/api/auth/me/birth-date", new SetBirthDateRequest(Born), Ct);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task Directory_exposes_birth_dates_only_to_self_admin_and_family()
{
using var admin = await fixture.CreateAdminClientAsync(Ct);
var (kidUser, kid) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
var (_, otherKid) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
var (_, guest) = await fixture.CreateSignedInUserAsync(Roles.Guest, Ct);
var (_, family) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
Assert.Equal(HttpStatusCode.OK, (await kid.PutAsJsonAsync("/api/auth/me/birth-date", new SetBirthDateRequest(Born), Ct)).StatusCode);
Assert.Equal(Born, (await DirectoryEntryAsync(kid, kidUser.Id))!.BirthDate);
Assert.Equal(Born, (await DirectoryEntryAsync(admin, kidUser.Id))!.BirthDate);
Assert.Equal(Born, (await DirectoryEntryAsync(family, kidUser.Id))!.BirthDate);
Assert.Null((await DirectoryEntryAsync(otherKid, kidUser.Id))!.BirthDate);
Assert.Null((await DirectoryEntryAsync(guest, kidUser.Id))!.BirthDate);
}
// ---------- Kalender-Vorlage ----------
[Fact]
public async Task Birthday_template_creates_an_all_day_yearly_series_with_age_per_occurrence()
{
using var admin = await fixture.CreateAdminClientAsync(Ct);
var calendarId = await FamilyCalendarIdAsync(admin);
var name = $"Anna{Guid.NewGuid():N}"[..12];
var created = await admin.PostAsJsonAsync("/api/calendar/events", Birthday(calendarId, name, Born), Ct);
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
var first = (await created.Content.ReadFromJsonAsync<CalendarOccurrenceDto>(Ct))!;
Assert.True(first.IsAllDay);
Assert.Equal(new DateOnly(2019, 3, 5), first.StartDate);
Assert.Equal("FREQ=YEARLY", first.RecurrenceRule);
Assert.Equal((Born, 1), (first.BirthDate, first.Age));
var occurrences = await SeriesAsync(admin, first.SeriesId, 2030, 2031, 2032);
Assert.Equal([new DateOnly(2030, 3, 5), new DateOnly(2031, 3, 5), new DateOnly(2032, 3, 5)], occurrences.Select(o => o.StartDate));
Assert.Equal([12, 13, 14], occurrences.Select(o => o.Age!.Value));
Assert.Equal($"Geburtstag von {name} (13 Jahre)", occurrences[1].DisplayTitle);
Assert.All(occurrences, o => Assert.Equal($"Geburtstag von {name}", o.Title));
}
[Fact]
public async Task Leap_day_birthday_is_celebrated_on_feb_28_in_common_years()
{
using var admin = await fixture.CreateAdminClientAsync(Ct);
var calendarId = await FamilyCalendarIdAsync(admin);
var name = $"Schalt{Guid.NewGuid():N}"[..12];
var created = (await (await admin.PostAsJsonAsync("/api/calendar/events", Birthday(calendarId, name, new DateOnly(2004, 2, 29)), Ct))
.Content.ReadFromJsonAsync<CalendarOccurrenceDto>(Ct))!;
var occurrences = await SeriesAsync(admin, created.SeriesId, 2031, 2032);
Assert.Equal([(new DateOnly(2031, 2, 28), 27), (new DateOnly(2032, 2, 29), 28)], occurrences.Select(o => (o.StartDate, o.Age!.Value)));
}
[Fact]
public async Task Editing_the_birth_date_moves_the_series_and_drops_stale_exceptions()
{
using var admin = await fixture.CreateAdminClientAsync(Ct);
var calendarId = await FamilyCalendarIdAsync(admin);
var name = $"Ben{Guid.NewGuid():N}"[..12];
var created = (await (await admin.PostAsJsonAsync("/api/calendar/events", Birthday(calendarId, name, Born), Ct))
.Content.ReadFromJsonAsync<CalendarOccurrenceDto>(Ct))!;
// Ein Jahr entfällt (Vorkommen löschen)
var in2031 = (await RangeAsync(admin, new DateOnly(2031, 3, 1), new DateOnly(2031, 3, 31))).Occurrences.Single(o => o.SeriesId == created.SeriesId);
Assert.Equal(HttpStatusCode.NoContent, (await admin.DeleteAsync($"/api/calendar/events/{in2031.EventId}?scope=Occurrence&occurrence={Key(in2031)}", Ct)).StatusCode);
Assert.DoesNotContain((await RangeAsync(admin, new DateOnly(2031, 3, 1), new DateOnly(2031, 3, 31))).Occurrences, o => o.SeriesId == created.SeriesId);
// Geburtsdatum korrigiert: auch bei „nur dieses Vorkommen“ wird die ganze Serie neu aufgebaut, alte Ausnahmen entfallen
var current = (await RangeAsync(admin, new DateOnly(2030, 3, 1), new DateOnly(2030, 3, 31))).Occurrences.Single(o => o.SeriesId == created.SeriesId);
var update = await admin.PutAsJsonAsync($"/api/calendar/events/{current.EventId}?scope=Occurrence&occurrence={Key(current)}",
Birthday(calendarId, name, new DateOnly(2018, 7, 20)) with { Id = null, Version = current.Version }, Ct);
Assert.Equal(HttpStatusCode.NoContent, update.StatusCode);
var after = (await RangeAsync(admin, new DateOnly(2031, 1, 1), new DateOnly(2031, 12, 31))).Occurrences.Where(o => o.SeriesId == created.SeriesId).ToList();
var occurrence = Assert.Single(after);
Assert.Equal((new DateOnly(2031, 7, 20), 13, new DateOnly(2018, 7, 20)), (occurrence.StartDate, occurrence.Age, occurrence.BirthDate));
}
[Fact]
public async Task Birth_date_in_events_is_validated_and_hidden_from_readers_who_cannot_edit()
{
using var admin = await fixture.CreateAdminClientAsync(Ct);
var calendarId = await FamilyCalendarIdAsync(admin);
var (_, kid) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
var name = $"Cara{Guid.NewGuid():N}"[..12];
var future = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(30);
Assert.Equal(HttpStatusCode.BadRequest, (await admin.PostAsJsonAsync("/api/calendar/events", Birthday(calendarId, name, future), Ct)).StatusCode);
var created = (await (await admin.PostAsJsonAsync("/api/calendar/events", Birthday(calendarId, name, Born), Ct))
.Content.ReadFromJsonAsync<CalendarOccurrenceDto>(Ct))!;
// Kind darf lesen, aber nicht ändern: Alter ja, Geburtsdatum nein
var seenByKid = (await RangeAsync(kid, new DateOnly(2031, 3, 1), new DateOnly(2031, 3, 31))).Occurrences.Single(o => o.SeriesId == created.SeriesId);
Assert.False(seenByKid.CanEdit);
Assert.Null(seenByKid.BirthDate);
Assert.Equal(13, seenByKid.Age);
// Eigene Geburtstage darf das Kind anlegen und sieht dann das Datum
var own = (await (await kid.PostAsJsonAsync("/api/calendar/events", Birthday(calendarId, $"Oma{Guid.NewGuid():N}"[..10], new DateOnly(1950, 5, 17)), Ct))
.Content.ReadFromJsonAsync<CalendarOccurrenceDto>(Ct))!;
Assert.Equal(new DateOnly(1950, 5, 17), own.BirthDate);
Assert.Equal(new DateOnly(1951, 5, 17), own.StartDate);
}
[Fact]
public async Task Feed_exports_the_series_once_with_plain_title_and_birthday_category()
{
using var admin = await fixture.CreateAdminClientAsync(Ct);
var calendarId = await FamilyCalendarIdAsync(admin);
var name = $"Feed{Guid.NewGuid():N}"[..12];
await admin.PostAsJsonAsync("/api/calendar/events", Birthday(calendarId, name, Born), Ct);
var secret = (await (await admin.PostAsJsonAsync("/api/calendar/feeds", new CreateIcsFeedRequest(null, IcsFeedScope.All, null, IcsTaskMode.None), Ct))
.Content.ReadFromJsonAsync<IcsFeedSecretDto>(Ct))!;
using var app = fixture.CreateBrowserClient();
var body = (await app.GetStringAsync(new Uri(secret.HttpsUrl).AbsolutePath, Ct)).Replace("\r\n", "\n");
var vevent = body.Split("BEGIN:VEVENT").Where(e => e.Contains($"SUMMARY:Geburtstag von {name}\n", StringComparison.Ordinal)).Single();
Assert.Contains("RRULE:FREQ=YEARLY", vevent);
Assert.Contains("DTSTART;VALUE=DATE:20190305", vevent);
Assert.Contains("Geburtstag", vevent.Split('\n').Single(l => l.StartsWith("CATEGORIES:", StringComparison.Ordinal)));
}
}
@@ -579,4 +579,32 @@ public sealed class CalDavTests(AppFixture fixture)
using var good = DavClient(user.UserName, secret);
Assert.Equal(HttpStatusCode.TooManyRequests, (await SendAsync(good, HttpMethod.Options, "/caldav/")).Response.StatusCode);
}
[Fact]
public async Task Birthday_series_roundtrips_via_caldav_and_keeps_its_birth_date_when_a_client_saves_it()
{
using var admin = await fixture.CreateAdminClientAsync(Ct);
var calendarId = await FamilyCalendarIdAsync(admin);
using var dav = DavClient(AppFixture.AdminUserName, await NewSecretAsync(admin));
var born = new DateOnly(2018, 3, 5);
var name = $"Dav{Guid.NewGuid():N}"[..10];
var request = new SaveEventRequest(Guid.NewGuid(), calendarId, $"Geburtstag von {name}", null, null, true, born, null, born, null, [], true, null, null, born);
var series = (await (await admin.PostAsJsonAsync("/api/calendar/events", request, Ct)).Content.ReadFromJsonAsync<CalendarOccurrenceDto>(Ct))!;
var path = ResourcePath(calendarId, CalendarEvent.NewUid(series.SeriesId));
// Export: genau eine Serie mit dem schlichten Titel (das Alter steht nur in der Anzeige), Kategorie „Geburtstag“
var get = (await SendAsync(dav, HttpMethod.Get, path)).Body;
Assert.Equal(1, CountOf(get, "BEGIN:VEVENT"));
Assert.Contains($"SUMMARY:Geburtstag von {name}", get);
Assert.Contains("RRULE:FREQ=YEARLY", get);
Assert.Contains("CATEGORIES:Geburtstag", get);
// Ein Client speichert die Serie zurück (mit Notiz, ohne Kategorie/BirthDate – kennt er nicht): Geburtstags-Eigenschaften bleiben
var saved = get.Replace("SUMMARY:", "DESCRIPTION:Torte bestellen\r\nSUMMARY:", StringComparison.Ordinal);
Assert.Equal(HttpStatusCode.NoContent, (await PutAsync(dav, path, saved)).Response.StatusCode);
var occurrence = (await RangeAsync(admin, new DateTime(2031, 3, 1), new DateTime(2031, 3, 31))).Occurrences.Single(o => o.SeriesId == series.SeriesId);
Assert.Equal((born, 13, "Torte bestellen"), (occurrence.BirthDate, occurrence.Age, occurrence.Notes));
Assert.Equal($"Geburtstag von {name} (13 Jahre)", occurrence.DisplayTitle);
}
}
@@ -0,0 +1,280 @@
using FamilyPlanner.Api.Features.Calendar;
using FamilyPlanner.Api.Features.Calendar.Ics;
using FamilyPlanner.Domain;
using FamilyPlanner.Domain.Calendar;
using FamilyPlanner.Domain.Common;
using FamilyPlanner.Domain.Recurrence;
using FamilyPlanner.Domain.Users;
using FamilyPlanner.Shared.Admin;
using FamilyPlanner.Shared.Auth;
using FamilyPlanner.Shared.Calendar;
namespace FamilyPlanner.UnitTests.Calendar;
/// <summary>Geburtstage (Phase 15): reine Alters-/Datumslogik, Validatoren, Serienbildung und Anzeigetitel je Vorkommen.</summary>
public sealed class BirthdayTests
{
private static readonly TimeZoneInfo Berlin = FamilyTime.Berlin;
private static readonly DateOnly Today = new(2026, 10, 11);
private static DateOnly D(int y, int m, int d) => new(y, m, d);
private static TimeProvider Clock() => new FixedClock(new DateTimeOffset(2026, 10, 11, 10, 0, 0, TimeSpan.Zero));
private sealed class FixedClock(DateTimeOffset now) : TimeProvider
{
public override DateTimeOffset GetUtcNow() => now;
}
[Theory]
[InlineData(2018, 3, 5, 2026, 3, 4, 7)]
[InlineData(2018, 3, 5, 2026, 3, 5, 8)]
[InlineData(2018, 3, 5, 2026, 12, 31, 8)]
[InlineData(2026, 10, 11, 2026, 10, 11, 0)]
[InlineData(1980, 12, 31, 2026, 12, 30, 45)]
public void Age_counts_completed_years(int by, int bm, int bd, int y, int m, int d, int expected)
{
Assert.Equal(expected, BirthdayRules.AgeOn(D(by, bm, bd), D(y, m, d)));
}
[Fact]
public void Age_is_null_before_birth()
{
Assert.Null(BirthdayRules.AgeOn(D(2020, 5, 1), D(2020, 4, 30)));
}
[Theory]
[InlineData(2025, 2, 27, 20)] // Nichtschaltjahr: erst am 28.2. das neue Lebensjahr
[InlineData(2025, 2, 28, 21)]
[InlineData(2025, 3, 1, 21)]
[InlineData(2028, 2, 28, 23)] // Schaltjahr: erst am 29.2.
[InlineData(2028, 2, 29, 24)]
[InlineData(2028, 3, 1, 24)]
public void Leap_day_birthdays_are_celebrated_on_feb_28_in_common_years(int y, int m, int d, int expected)
{
Assert.Equal(expected, BirthdayRules.AgeOn(D(2004, 2, 29), D(y, m, d)));
}
[Fact]
public void Birthday_in_year_handles_leap_day()
{
var birth = D(2004, 2, 29);
Assert.Equal(D(2025, 2, 28), BirthdayRules.BirthdayIn(birth, 2025));
Assert.Equal(D(2028, 2, 29), BirthdayRules.BirthdayIn(birth, 2028));
Assert.Equal(D(2025, 3, 5), BirthdayRules.BirthdayIn(D(2018, 3, 5), 2025));
}
[Theory]
[InlineData(1899, 12, 31, false)]
[InlineData(1900, 1, 1, true)]
[InlineData(2026, 10, 11, true)]
[InlineData(2026, 10, 12, false)]
public void Birth_date_must_be_between_1900_and_today(int y, int m, int d, bool valid)
{
Assert.Equal(valid, BirthdayRules.IsValidBirthDate(D(y, m, d), Today));
}
[Fact]
public void Series_rule_for_leap_day_yields_last_day_of_february_each_year()
{
var birth = D(2004, 2, 29);
var start = BirthdayRules.FirstBirthday(birth); // 28.2.2005
Assert.Equal(D(2005, 2, 28), start);
var rule = RecurrenceRule.Parse(BirthdayRules.RecurrenceRuleFor(birth));
var dates = RecurrenceEvaluator.Occurrences(rule, start.ToDateTime(TimeOnly.MinValue), Berlin)
.Take(5).Select(DateOnly.FromDateTime).ToList();
Assert.Equal([D(2005, 2, 28), D(2006, 2, 28), D(2007, 2, 28), D(2008, 2, 29), D(2009, 2, 28)], dates);
}
[Fact]
public void Series_rule_for_normal_date_is_plain_yearly()
{
Assert.Equal("FREQ=YEARLY", BirthdayRules.RecurrenceRuleFor(D(2018, 3, 5)));
Assert.Equal(D(2019, 3, 5), BirthdayRules.FirstBirthday(D(2018, 3, 5)));
}
[Fact]
public void Title_helpers_round_trip_and_format_age()
{
Assert.Equal("Geburtstag von Anna", BirthdayRules.TitleFor(" Anna "));
Assert.Equal("Anna", BirthdayRules.NameFromTitle("Geburtstag von Anna"));
Assert.Equal("Schwimmkurs", BirthdayRules.NameFromTitle("Schwimmkurs"));
Assert.Equal("1 Jahr", BirthdayRules.AgeLabel(1));
Assert.Equal("7 Jahre", BirthdayRules.AgeLabel(7));
Assert.Equal("Geburtstag von Anna (7 Jahre)", BirthdayRules.DisplayTitle("Geburtstag von Anna", 7));
Assert.Equal("Termin", BirthdayRules.DisplayTitle("Termin", null));
}
[Fact]
public void Birth_date_is_visible_to_self_admin_and_family_only()
{
var me = Guid.NewGuid();
var other = Guid.NewGuid();
Assert.True(BirthdayRules.CanView(me, [Roles.Child], me));
Assert.True(BirthdayRules.CanView(me, [Roles.Guest], me));
Assert.True(BirthdayRules.CanView(me, [Roles.Family], other));
Assert.True(BirthdayRules.CanView(me, [Roles.Admin], other));
Assert.False(BirthdayRules.CanView(me, [Roles.Child], other));
Assert.False(BirthdayRules.CanView(me, [Roles.Guest], other));
Assert.False(BirthdayRules.CanView(me, [], other));
}
// ---------- Validatoren ----------
[Fact]
public void Set_birth_date_validator_rejects_future_and_ancient_dates()
{
var validator = new SetBirthDateRequestValidator(Clock());
Assert.True(validator.Validate(new SetBirthDateRequest(null)).IsValid);
Assert.True(validator.Validate(new SetBirthDateRequest(D(2026, 10, 11))).IsValid);
Assert.False(validator.Validate(new SetBirthDateRequest(D(2026, 10, 12))).IsValid);
Assert.False(validator.Validate(new SetBirthDateRequest(D(1899, 12, 31))).IsValid);
}
[Fact]
public void User_requests_validate_birth_date()
{
var create = new CreateUserRequest("anna", "Anna", null, Roles.Child, "#E53935", "Startpasswort1", null, D(2018, 3, 5));
var update = new UpdateUserRequest("Anna", null, Roles.Child, "#E53935", true, null, D(2018, 3, 5));
Assert.True(new CreateUserRequestValidator(Clock()).Validate(create).IsValid);
Assert.True(new UpdateUserRequestValidator(Clock()).Validate(update).IsValid);
Assert.Contains(new CreateUserRequestValidator(Clock()).Validate(create with { BirthDate = D(2030, 1, 1) }).Errors, e => e.PropertyName == "BirthDate");
Assert.Contains(new UpdateUserRequestValidator(Clock()).Validate(update with { BirthDate = D(1800, 1, 1) }).Errors, e => e.PropertyName == "BirthDate");
}
[Fact]
public void Event_request_validates_birth_date()
{
var request = new SaveEventRequest(null, Guid.NewGuid(), "Geburtstag von Anna", null, null, true, D(2027, 3, 5), null, D(2027, 3, 5), null,
[], true, "FREQ=YEARLY", null, D(2018, 3, 5));
Assert.True(new SaveEventRequestValidator(Clock()).Validate(request).IsValid);
Assert.Contains(new SaveEventRequestValidator(Clock()).Validate(request with { BirthDate = D(2030, 1, 1) }).Errors, e => e.PropertyName == "BirthDate");
}
// ---------- Server: Normalisierung und Anzeige je Vorkommen ----------
[Fact]
public void Server_forces_all_day_yearly_series_from_the_birth_date()
{
var request = new SaveEventRequest(null, Guid.NewGuid(), "Geburtstag von Anna", null, null, false, D(2030, 1, 1), new TimeOnly(9, 0),
D(2030, 1, 2), new TimeOnly(10, 0), [], false, "FREQ=DAILY", null, D(2018, 3, 5));
var normalized = CalendarEndpoints.NormalizeBirthday(request);
Assert.True(normalized.IsAllDay);
Assert.Equal((D(2019, 3, 5), D(2019, 3, 5), (TimeOnly?)null, (TimeOnly?)null), (normalized.StartDate, normalized.EndDate, normalized.StartTime, normalized.EndTime));
Assert.Equal("FREQ=YEARLY", normalized.RecurrenceRule);
}
[Fact]
public void Requests_without_birth_date_are_left_alone()
{
var request = new SaveEventRequest(null, Guid.NewGuid(), "Zahnarzt", null, null, false, D(2030, 1, 1), new TimeOnly(9, 0),
D(2030, 1, 1), new TimeOnly(10, 0), [], false, null);
Assert.Same(request, CalendarEndpoints.NormalizeBirthday(request));
}
private static CalendarEvent BirthdaySeries(DateOnly birth, Guid? createdBy = null)
{
var first = BirthdayRules.FirstBirthday(birth);
var id = Guid.NewGuid();
return new CalendarEvent
{
Id = id,
CalendarId = Guid.NewGuid(),
Uid = CalendarEvent.NewUid(id),
Title = "Geburtstag von Anna",
IsAllDay = true,
StartUtc = FamilyTime.StartOfDayUtc(first, Berlin),
EndUtc = FamilyTime.StartOfDayUtc(first.AddDays(1), Berlin),
RecurrenceRule = BirthdayRules.RecurrenceRuleFor(birth),
BirthDate = birth,
CreatedBy = createdBy,
};
}
private static List<CalendarOccurrenceDto> Dtos(CalendarEvent series, DateOnly from, DateOnly to, CalendarAccess access = CalendarAccess.Write, Guid? userId = null)
{
var byId = new Dictionary<Guid, CalendarEvent> { [series.Id] = series };
return CalendarTime.Expand([series], FamilyTime.StartOfDayUtc(from, Berlin), FamilyTime.StartOfDayUtc(to.AddDays(1), Berlin))
.Select(o => CalendarEndpoints.ToDto(o, byId, access, userId ?? Guid.NewGuid(), Berlin))
.ToList();
}
[Fact]
public void Each_occurrence_carries_its_own_age_without_breaking_the_series()
{
var series = BirthdaySeries(D(2018, 3, 5));
var dtos = Dtos(series, D(2025, 1, 1), D(2027, 12, 31));
Assert.Equal([D(2025, 3, 5), D(2026, 3, 5), D(2027, 3, 5)], dtos.Select(d => d.StartDate));
Assert.Equal([7, 8, 9], dtos.Select(d => d.Age!.Value));
Assert.Equal(["Geburtstag von Anna (7 Jahre)", "Geburtstag von Anna (8 Jahre)", "Geburtstag von Anna (9 Jahre)"], dtos.Select(d => d.DisplayTitle));
Assert.All(dtos, d => Assert.Equal("Geburtstag von Anna", d.Title));
Assert.All(dtos, d => Assert.True(d.IsAllDay && d.IsRecurring));
Assert.All(dtos, d => Assert.Equal(series.Id, d.SeriesId));
}
[Fact]
public void Leap_day_series_shows_feb_28_in_common_years_with_correct_age()
{
var series = BirthdaySeries(D(2004, 2, 29));
var dtos = Dtos(series, D(2027, 1, 1), D(2028, 12, 31));
Assert.Equal([(D(2027, 2, 28), 23), (D(2028, 2, 29), 24)], dtos.Select(d => (d.StartDate, d.Age!.Value)));
}
[Fact]
public void Birth_date_is_only_exposed_to_users_who_may_edit_the_event()
{
var owner = Guid.NewGuid();
var series = BirthdaySeries(D(2018, 3, 5), owner);
var editor = Dtos(series, D(2026, 3, 1), D(2026, 3, 31), CalendarAccess.WriteOwn, owner).Single();
var reader = Dtos(series, D(2026, 3, 1), D(2026, 3, 31), CalendarAccess.Read).Single();
Assert.Equal(D(2018, 3, 5), editor.BirthDate);
Assert.Null(reader.BirthDate);
Assert.Equal(8, reader.Age); // Das Alter selbst ist Teil der Anzeige
}
[Fact]
public void Plain_events_have_no_age()
{
var series = BirthdaySeries(D(2018, 3, 5));
series.BirthDate = null;
var dto = Dtos(series, D(2026, 3, 1), D(2026, 3, 31)).Single();
Assert.Null(dto.Age);
Assert.Equal(dto.Title, dto.DisplayTitle);
}
// ---------- ICS ----------
[Fact]
public void Ics_export_keeps_one_series_with_plain_title_and_birthday_category()
{
var series = BirthdaySeries(D(2018, 3, 5));
series.UpdatedAt = series.CreatedAt = new DateTimeOffset(2026, 10, 1, 12, 0, 0, TimeSpan.Zero);
var ics = IcsBuilder.Build("Familie", "Europe/Berlin", [series], new Dictionary<Guid, string> { [series.CalendarId] = "Familie" },
new Dictionary<Guid, string>(), [], IcsTaskMode.None, resourceMode: true).Replace("\r\n", "\n");
Assert.Single(ics.Split('\n'), l => l.StartsWith("BEGIN:VEVENT", StringComparison.Ordinal));
Assert.Contains("SUMMARY:Geburtstag von Anna\n", ics);
Assert.Contains("RRULE:FREQ=YEARLY", ics);
Assert.Contains("DTSTART;VALUE=DATE:20190305", ics);
Assert.Contains("CATEGORIES:" + IcsBuilder.BirthdayCategory, ics);
Assert.DoesNotContain("2018", ics);
}
}
@@ -0,0 +1,4 @@
@* Testhülle: MudBlazor-Provider (Datumsauswahl, Snackbar) + „Meine Einstellungen“. *@
<MudPopoverProvider />
<MudSnackbarProvider />
<FamilyPlanner.Web.Client.Pages.AccountSettings />
@@ -9,19 +9,21 @@ namespace FamilyPlanner.Web.Tests.Account;
public sealed class AccountSettingsPageTests : BunitContext
{
private readonly ThemeState _theme;
private readonly FakeProfileApi _profile = new();
public AccountSettingsPageTests()
{
JSInterop.Mode = JSRuntimeMode.Loose;
Services.AddMudServices();
_theme = new ThemeState(JSInterop.JSRuntime);
Services.AddSingleton(_theme);
Services.AddSingleton<IProfileApi>(_profile);
Services.AddMudServices();
}
[Fact]
public void Offers_the_three_theme_choices_and_links_to_notifications()
{
var cut = Render<AccountSettings>();
var cut = Render<AccountSettingsHost>();
var labels = cut.FindAll(".fp-theme-choice label").Select(l => l.TextContent.Trim()).ToList();
Assert.Equal(["Wie das Gerät", "Hell", "Dunkel"], labels);
@@ -31,7 +33,7 @@ public sealed class AccountSettingsPageTests : BunitContext
[Fact]
public void Links_to_the_calendar_phone_connection()
{
var cut = Render<AccountSettings>();
var cut = Render<AccountSettingsHost>();
Assert.Equal("konto/kalender-verbinden", cut.Find(".fp-settings-caldav").GetAttribute("href"));
Assert.Contains("Mit Handy verbinden", cut.Markup);
@@ -40,11 +42,51 @@ public sealed class AccountSettingsPageTests : BunitContext
[Fact]
public void Choosing_dark_updates_the_theme_state()
{
var cut = Render<AccountSettings>();
var cut = Render<AccountSettingsHost>();
cut.FindAll(".fp-theme-choice input")[2].Click();
cut.WaitForAssertion(() => Assert.Equal(ThemePreference.Dark, _theme.Preference));
Assert.True(_theme.IsDarkMode);
}
[Fact]
public void Shows_the_own_birth_date_with_age_and_saves_changes()
{
var born = FamilyPlanner.Domain.Common.FamilyTime.Today(TimeProvider.System).AddYears(-9).AddDays(-2);
_profile.BirthDate = born;
var cut = Render<AccountSettingsHost>();
cut.WaitForAssertion(() => Assert.Equal($"{born:dd.MM.yyyy}", cut.Find("#profile-birthdate").GetAttribute("value")));
Assert.Contains("Alter: 9 Jahre", cut.Markup);
cut.Find("#profile-birthdate").Change("05.03.2018");
cut.Find(".fp-profile-birthdate-save").Click();
cut.WaitForAssertion(() => Assert.Equal(new DateOnly(2018, 3, 5), Assert.Single(_profile.Saved)));
}
[Fact]
public void Rejects_a_future_birth_date_before_calling_the_api()
{
var cut = Render<AccountSettingsHost>();
cut.Find("#profile-birthdate").Change("05.03.2999");
cut.Find(".fp-profile-birthdate-save").Click();
cut.WaitForAssertion(() => Assert.Contains("nicht in der Zukunft", cut.Markup));
Assert.Empty(_profile.Saved);
}
[Fact]
public void Clearing_the_birth_date_removes_it()
{
_profile.BirthDate = new DateOnly(2018, 3, 5);
var cut = Render<AccountSettingsHost>();
cut.WaitForAssertion(() => Assert.Equal("05.03.2018", cut.Find("#profile-birthdate").GetAttribute("value")));
cut.Find("#profile-birthdate").Change(string.Empty);
cut.Find(".fp-profile-birthdate-save").Click();
cut.WaitForAssertion(() => Assert.Null(Assert.Single(_profile.Saved)));
}
}
@@ -0,0 +1,30 @@
using FamilyPlanner.Shared.Auth;
using FamilyPlanner.Web.Client.Services;
namespace FamilyPlanner.Web.Tests.Account;
internal sealed class FakeProfileApi : IProfileApi
{
public DateOnly? BirthDate { get; set; }
public List<DateOnly?> Saved { get; } = [];
public ApiProblem? NextProblem { get; set; }
private CurrentUserDto Me() => new(Guid.NewGuid(), "anna", "Anna", null, "#E53935", ["Kind"], false, BirthDate: BirthDate);
public Task<ApiResult<CurrentUserDto>> GetMeAsync(CancellationToken ct = default) => Task.FromResult(ApiResult<CurrentUserDto>.Ok(Me()));
public Task<ApiResult<CurrentUserDto>> SetBirthDateAsync(DateOnly? birthDate, CancellationToken ct = default)
{
if (NextProblem is { } problem)
{
NextProblem = null;
return Task.FromResult(ApiResult<CurrentUserDto>.Fail(problem));
}
Saved.Add(birthDate);
BirthDate = birthDate;
return Task.FromResult(ApiResult<CurrentUserDto>.Ok(Me()));
}
}
@@ -35,6 +35,53 @@ public sealed class UserManagementTests : BunitContext
Assert.Contains("PIN", cut.Markup);
}
[Fact]
public void Lists_birth_date_with_computed_age()
{
var today = FamilyPlanner.Domain.Common.FamilyTime.Today(TimeProvider.System);
var born = today.AddYears(-7).AddDays(-1);
_api.Users[1] = _api.Users[1] with { BirthDate = born };
var cut = Render<UserManagementHost>();
var cells = cut.FindAll(".fp-user-birthdate").Select(c => c.TextContent.Trim()).ToList();
Assert.Equal(["–", $"{born:dd.MM.yyyy} (7 Jahre)", "–"], cells);
}
[Fact]
public void Create_dialog_sends_the_birth_date()
{
var cut = Render<UserManagementHost>();
cut.Find("button.fp-add-user").Click();
cut.WaitForElement("#user-edit-form");
cut.Find("#display-name").Change("Max");
cut.Find("#user-name").Change("max");
cut.Find("#password").Change("Startpasswort1");
cut.Find("#birthdate").Change("05.03.2018");
cut.Find("#user-edit-form").Submit();
cut.WaitForAssertion(() => Assert.Single(_api.Created));
Assert.Equal(new DateOnly(2018, 3, 5), _api.Created[0].BirthDate);
}
[Fact]
public void Create_dialog_rejects_a_future_birth_date()
{
var cut = Render<UserManagementHost>();
cut.Find("button.fp-add-user").Click();
cut.WaitForElement("#user-edit-form");
cut.Find("#display-name").Change("Max");
cut.Find("#user-name").Change("max");
cut.Find("#password").Change("Startpasswort1");
cut.Find("#birthdate").Change("05.03.2999");
cut.Find("#user-edit-form").Submit();
Assert.Contains("nicht in der Zukunft", cut.Markup);
Assert.Empty(_api.Created);
}
[Fact]
public void Create_dialog_validates_before_calling_the_api()
{
@@ -0,0 +1,140 @@
using FamilyPlanner.Domain.Common;
using FamilyPlanner.Shared.Calendar;
using FamilyPlanner.Web.Client.Services;
using FamilyPlanner.Web.Tests.Shopping;
using Microsoft.Extensions.DependencyInjection;
using MudBlazor.Services;
namespace FamilyPlanner.Web.Tests.Calendar;
/// <summary>Kalender-Vorlage „Geburtstag“: Person aus dem Verzeichnis oder freie Eingabe, Anzeige mit Alter je Vorkommen.</summary>
public sealed class BirthdayTemplateTests : BunitContext
{
private readonly FakeCalendarApi _api = new();
private readonly DateOnly _today = FamilyTime.Today(TimeProvider.System);
public BirthdayTemplateTests()
{
JSInterop.Mode = JSRuntimeMode.Loose;
Services.AddMudServices();
Services.AddSingleton<ICalendarApi>(_api);
Services.AddSingleton<ILiveConnection>(new FakeLiveConnection());
Services.AddSingleton<ILocalStore, InMemoryLocalStore>();
Services.AddSingleton(TimeProvider.System);
}
private static string Text(AngleSharp.Dom.IElement element) => element.TextContent.Trim();
private void ChooseBirthdayTemplate(IRenderedComponent<CalendarHost> cut)
{
cut.Find(".fp-fab").Click();
cut.WaitForElement(".fp-event-template");
cut.Find(".fp-event-template").MouseDown();
cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll(".mud-list-item")));
cut.FindAll(".mud-list-item").First(i => Text(i) == "Geburtstag").Click();
cut.WaitForElement("#birthday-name");
}
[Fact]
public void Picking_a_person_prefills_name_and_birth_date_and_saves_a_yearly_all_day_series()
{
var cut = Render<CalendarHost>();
ChooseBirthdayTemplate(cut);
Assert.Empty(cut.FindAll("#event-title")); // keine Zeiten/Titelfelder bei der Vorlage
cut.Find(".fp-birthday-person").MouseDown();
cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll(".mud-list-item")));
cut.FindAll(".mud-list-item").First(i => Text(i) == "Anna").Click();
cut.WaitForAssertion(() => Assert.Equal("Anna", cut.Find("#birthday-name").GetAttribute("value")));
Assert.Equal("2018-03-05", cut.Find("#birthday-date").GetAttribute("value"));
cut.Find("#event-form").Submit();
cut.WaitForAssertion(() => Assert.Single(_api.Created));
var request = _api.Created[0];
Assert.Equal("Geburtstag von Anna", request.Title);
Assert.True(request.IsAllDay);
Assert.Equal(new DateOnly(2018, 3, 5), request.BirthDate);
Assert.Equal(new DateOnly(2019, 3, 5), request.StartDate);
Assert.Equal("FREQ=YEARLY", request.RecurrenceRule);
Assert.True(request.ForWholeFamily);
Assert.True(new SaveEventRequestValidator().Validate(request).IsValid);
}
[Fact]
public void Free_entry_of_name_and_birth_date_works_for_leap_day()
{
var cut = Render<CalendarHost>();
ChooseBirthdayTemplate(cut);
cut.Find("#birthday-name").Change("Oma Erna");
cut.Find("#birthday-date").Change("1952-02-29");
cut.Find("#event-form").Submit();
cut.WaitForAssertion(() => Assert.Single(_api.Created));
var request = _api.Created[0];
Assert.Equal("Geburtstag von Oma Erna", request.Title);
Assert.Equal(new DateOnly(1952, 2, 29), request.BirthDate);
Assert.Equal(new DateOnly(1953, 2, 28), request.StartDate);
Assert.Equal("FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=-1", request.RecurrenceRule);
}
[Fact]
public void Missing_name_or_date_is_reported_and_nothing_is_saved()
{
var cut = Render<CalendarHost>();
ChooseBirthdayTemplate(cut);
cut.Find("#event-form").Submit();
cut.WaitForAssertion(() => Assert.Contains("Bitte einen Namen eingeben.", cut.Markup));
cut.Find("#birthday-name").Change("Anna");
cut.Find("#event-form").Submit();
cut.WaitForAssertion(() => Assert.Contains("Bitte das Geburtsdatum angeben.", cut.Markup));
Assert.Empty(_api.Created);
}
[Fact]
public void Future_birth_date_is_rejected_by_the_shared_validator()
{
var cut = Render<CalendarHost>();
ChooseBirthdayTemplate(cut);
cut.Find("#birthday-name").Change("Baby");
cut.Find("#birthday-date").Change(_today.AddYears(1).ToString("yyyy-MM-dd"));
cut.Find("#event-form").Submit();
cut.WaitForAssertion(() => Assert.Contains("nicht in der Zukunft", cut.Markup));
Assert.Empty(_api.Created);
}
[Fact]
public void Agenda_shows_the_age_of_each_occurrence()
{
var birthday = FakeCalendarApi.Occurrence("Geburtstag von Anna", _today, rule: "FREQ=YEARLY")
with { Age = 7, BirthDate = new DateOnly(2018, 3, 5) };
_api.Occurrences.Add(birthday);
var cut = Render<CalendarHost>();
cut.WaitForAssertion(() => Assert.Contains("Geburtstag von Anna (7 Jahre)", cut.Markup));
}
[Fact]
public void Editing_a_birthday_opens_the_template_directly_without_the_series_question()
{
var birthday = FakeCalendarApi.Occurrence("Geburtstag von Anna", _today, rule: "FREQ=YEARLY")
with { Age = 7, BirthDate = new DateOnly(2018, 3, 5) };
_api.Occurrences.Add(birthday);
var cut = Render<CalendarHost>();
cut.WaitForElement("[aria-label^='Termin: Geburtstag von Anna']").Click();
cut.WaitForElement("#birthday-name");
Assert.Equal("Anna", cut.Find("#birthday-name").GetAttribute("value"));
Assert.Equal("2018-03-05", cut.Find("#birthday-date").GetAttribute("value"));
Assert.Empty(cut.FindAll(".fp-event-template")); // Vorlage nur beim Anlegen
Assert.DoesNotContain("Nur diesen", cut.Markup);
}
}
@@ -9,7 +9,7 @@ namespace FamilyPlanner.Web.Tests.Calendar;
internal sealed class FakeCalendarApi : ICalendarApi
{
public static readonly Guid FamilyCalendarId = Guid.Parse("00000000-0000-0000-0000-0000000000c1");
public static readonly UserDirectoryEntry Anna = new(Guid.Parse("00000000-0000-0000-0000-0000000000a1"), "Anna", "#E53935", null, Roles.Child);
public static readonly UserDirectoryEntry Anna = new(Guid.Parse("00000000-0000-0000-0000-0000000000a1"), "Anna", "#E53935", null, Roles.Child, new DateOnly(2018, 3, 5));
public static readonly UserDirectoryEntry Papa = new(Guid.Parse("00000000-0000-0000-0000-0000000000a2"), "Papa", "#1E88E5", null, Roles.Family);
public List<CalendarDto> Calendars { get; } = [new(FamilyCalendarId, "Familie", "#3949AB", true, 0, CalendarAccess.Write)];