diff --git a/.sonarcloud.properties b/.sonarcloud.properties new file mode 100644 index 00000000..f9db0eaa --- /dev/null +++ b/.sonarcloud.properties @@ -0,0 +1,6 @@ +# Read by SonarCloud Automatic Analysis (there is no scanner step in CI). +# +# Duplication detection only -- these files are still analysed for issues: +# - buddy-api.ts is generated from docs/backend/openapi/buddy.json (`task docs:openapi`); +# - the en/da translation dictionaries mirror each other's keys by design (da is typed against en). +sonar.cpd.exclusions=src/frontend/buddy/src/app/core/api/buddy-api.ts,src/frontend/buddy/src/app/core/i18n/translations/** diff --git a/README.md b/README.md index 3e2cbdc3..50b613cb 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,9 @@ in real time. - [x] Private iCal subscription link with the pickups and drop-offs a guardian is assigned to, across all their children, with a reminder before each timed slot (see [Pickup iCal feed](docs/backend/analysis/pickup-ical-feed.md)) +- [x] Night duty: a fixed (1–4 week) schedule of who is responsible for a child at night, with + per-night overrides, switches and free-text notes such as a weekly screen night, shown on + the printed week plan and as a "Tonight" card for the child ### Child diff --git a/docs/backend/README.md b/docs/backend/README.md index d22471bf..897a5d4a 100644 --- a/docs/backend/README.md +++ b/docs/backend/README.md @@ -13,6 +13,7 @@ Backend documentation for the Buddy API. - [Pickups flow](pickups/flow.md) - [Babysitters flow](babysitters/flow.md) - [Work locations flow](work-locations/flow.md) +- [Night duty flow](night-duty/flow.md) - [Print templates flow](print-templates/flow.md) - [Task Library flow](task-library/flow.md) - [Progress flow](progress/flow.md) @@ -84,6 +85,7 @@ model or permissions logic. - [Skipping single occurrences](analysis/skip-occurrences.md) - [Publishing Docker images on GitHub](analysis/publish-docker-images.md) - [SBOM generation for builds](analysis/sbom-generation.md) +- [Night duty](analysis/night-duty.md) ## Current focus areas diff --git a/docs/backend/analysis/aggregate-roots.md b/docs/backend/analysis/aggregate-roots.md index 37f17dcd..20c3cead 100644 --- a/docs/backend/analysis/aggregate-roots.md +++ b/docs/backend/analysis/aggregate-roots.md @@ -34,6 +34,7 @@ flowchart TB PickupSchedule["PickupSchedule\nPickupScheduleId(Guid)"] PickupFeed["PickupFeed\nPickupFeedId(Guid)"] WorkLocationSchedule["WorkLocationSchedule\nWorkLocationScheduleId(Guid)"] + NightSchedule["NightSchedule\nNightScheduleId(Guid)"] BabysitterList["BabysitterList\nBabysitterListId(Guid)"] PrintTemplate["PrintTemplate\nPrintTemplateId(Guid)"] TaskTemplate["TaskTemplate\nTaskTemplateId(Guid)"] @@ -74,6 +75,8 @@ flowchart TB PickupFeed -- "guardianId (stored, equals stream id)" --> User BabysitterList -- "guardianId (stored, equals stream id)" --> User WorkLocationSchedule -- "guardianId (stored, equals stream id)" --> User + NightSchedule -- "childId (stored, equals stream id); nights' guardianId / modifiedBy" --> User + NightSchedule -- "nights: babysitterId (on guardianId's list)" --> BabysitterList PrintTemplate -- "owner (user or group); rows reference children, groups, calendars, work locations" --> User PrintTemplate -- "owner group" --> Group TaskTemplate -- "createdBy / lastModifiedBy" --> User @@ -131,6 +134,7 @@ flowchart TB | PickupFeed | `Features/Pickups/Types/PickupFeed.cs` | `PickupFeedId(Guid)`, equal to the guardian's `UserId` | `guardianId` → User (the feed reads that guardian's children's PickupSchedules through active GuardianLinks) | | BabysitterList | `Features/Babysitters/Types/BabysitterList.cs` | `BabysitterListId(Guid)`, equal to the guardian's `UserId` | `guardianId` → User | | WorkLocationSchedule | `Features/WorkLocations/Types/WorkLocationSchedule.cs` | `WorkLocationScheduleId(Guid)`, equal to the guardian's `UserId` | `guardianId` → User | +| NightSchedule | `Features/NightDuty/Types/NightSchedule.cs` | `NightScheduleId(Guid)`, equal to the child's `UserId` | `childId` → User; pattern days' and overrides' `guardianId` → User; a babysitter entry's `babysitterId` → a BabysitterList entry; events' `modifiedBy` → User | | PrintTemplate | `Features/PrintTemplates/Types/PrintTemplate.cs` | `PrintTemplateId(Guid)` | `owner` → User or Group; rows' `childId` / `guardianId` / `assignedToId` → User, `mealGroupId` → Group, `calendarIds` → Calendar, `workLocationId` → a WorkLocationSchedule location (references only, never access grants) | | TaskTemplate | `Features/TaskLibrary/Types/TaskTemplate.cs` | `TaskTemplateId(Guid)` | `createdBy` / `lastModifiedBy` → User; owning `childId` lives in `TaskTemplateIndexDocument`, not the aggregate itself | | ChildProgress | `Features/Progress/Types/ChildProgress.cs` | `ProgressId(Guid)`, equal to the child's `UserId` | `childId` → User; `awardedOccurrences` → CalendarItem (computed reference, not a foreign-key relationship) | diff --git a/docs/backend/analysis/domain-model-diagram.md b/docs/backend/analysis/domain-model-diagram.md index c08439ed..75e35eab 100644 --- a/docs/backend/analysis/domain-model-diagram.md +++ b/docs/backend/analysis/domain-model-diagram.md @@ -117,6 +117,13 @@ classDiagram +DateOverrides Overrides } + class NightSchedule { + +NightScheduleId Id + +UserId ChildId + +NightPattern[] Patterns + +DateOverrides Overrides + } + class BabysitterList { +BabysitterListId Id +UserId GuardianId @@ -232,6 +239,7 @@ classDiagram MedicineEffectLog --> User : childId MedicineEffectLog ..> MedicineSchedule : regimen PickupSchedule --> User : childId, assignments + NightSchedule --> User : childId, nights PickupFeed --> User : guardianId WorkLocationSchedule --> User : guardianId BabysitterList --> User : guardianId diff --git a/docs/backend/analysis/event-stream-snapshots.md b/docs/backend/analysis/event-stream-snapshots.md index c150c380..3206eec6 100644 --- a/docs/backend/analysis/event-stream-snapshots.md +++ b/docs/backend/analysis/event-stream-snapshots.md @@ -1,7 +1,8 @@ # Event-Stream Snapshots -Status: Implemented for all 28 event-sourced aggregates across all 18 modules (the 28th, -`PickupFeed`, came with docs/backend/analysis/pickup-ical-feed.md). Questions 1-5 +Status: Implemented for all 29 event-sourced aggregates across all 19 modules (the 28th, +`PickupFeed`, came with docs/backend/analysis/pickup-ical-feed.md, the 29th, `NightSchedule`, with +docs/backend/analysis/night-duty.md). Questions 1-5 below are resolved with what was actually built, including several real Marten/serialization constraints hit along the way that the original plan only flagged as possibilities — plus two more found during the full rollout (a `ValueTuple`-keyed dictionary/set gotcha, and a diff --git a/docs/backend/analysis/images/night-duty-child-tonight-mockup.png b/docs/backend/analysis/images/night-duty-child-tonight-mockup.png new file mode 100644 index 00000000..0545c533 Binary files /dev/null and b/docs/backend/analysis/images/night-duty-child-tonight-mockup.png differ diff --git a/docs/backend/analysis/images/night-duty-edit-night-mockup.png b/docs/backend/analysis/images/night-duty-edit-night-mockup.png new file mode 100644 index 00000000..53ad5335 Binary files /dev/null and b/docs/backend/analysis/images/night-duty-edit-night-mockup.png differ diff --git a/docs/backend/analysis/images/night-duty-guardian-mockup.png b/docs/backend/analysis/images/night-duty-guardian-mockup.png new file mode 100644 index 00000000..60d691b7 Binary files /dev/null and b/docs/backend/analysis/images/night-duty-guardian-mockup.png differ diff --git a/docs/backend/analysis/images/night-duty-print-mockup.png b/docs/backend/analysis/images/night-duty-print-mockup.png new file mode 100644 index 00000000..4ae57560 Binary files /dev/null and b/docs/backend/analysis/images/night-duty-print-mockup.png differ diff --git a/docs/backend/analysis/night-duty.md b/docs/backend/analysis/night-duty.md new file mode 100644 index 00000000..29045c83 --- /dev/null +++ b/docs/backend/analysis/night-duty.md @@ -0,0 +1,620 @@ +# Night Duty + +Status: Implemented. `Features/NightDuty` ships the `NightSchedule` aggregate (schema `nightduty`, +inline snapshot, golden files), the five slices and routes below, `PrintRowKind.NightDuty` and the +`nightDuty` feature flag; the frontend ships `NightDutyService`, `/guardian/night-duty` (week view +with change, switch and undo, and the fixed-schedule editor), the child's "Tonight" card and the +Night print row. See [the flow doc](../night-duty/flow.md) and "Implementation notes" near the end +for where the code differs from this design. + +## Context + +Guardians want to plan **who is on duty at night** for each child: who puts the child to bed, who +gets up when the child wakes, and where the child sleeps (GitHub issue #66). Most families follow +the same rhythm every week, or an A/B rhythm across two homes, with the odd exception ("Mom and Dad +switch Tuesday and Thursday this week", "Grandma has her this Saturday"). So the plan is set up once +and only adjusted when something changes. It isn't filled in date by date. + +A night can also carry a short free-text note. The motivating example is a weekly **screen +night**, the one night a week the children may use a screen at bedtime. It belongs on the same plan +so the child sees it on the printed week plan and on their own day view. + +Requirements settled in the issue: + +1. **A fixed schedule** that repeats every week, or in a cycle of up to 4 weeks, with one + responsible person per night. +2. **Per-night overrides** that win over the fixed schedule for that date only, and can be removed + again. A switch between two guardians is two overrides saved together. +3. **A free-text note** on a night, in the fixed schedule or as a one-off, with or without a + responsible person. +4. **Past nights are frozen.** A change to the fixed schedule takes effect from a date, and earlier + nights keep the schedule they had. +5. **Visible to the child** as a "Tonight" card on the child's day dashboard, and **printable** as a + Night row in a week plan print template. +6. **Dates only.** There are no bedtime or handover times in v1. A time can go in the note. + +Out of scope, with their own issues: an iCal feed of night duty (#67), showing the responsible +guardian next to a logged night in the sleep diary (#68), and bedtime and handover times (#69). + +Nothing like this exists in Buddy today. The closest precedents are: + +- [work-locations.md](work-locations.md) (`Features/WorkLocations`): a 1–4 week **pattern** + anchored on a Monday, plus a sparse per-date **override** map, resolved as override → pattern → + nothing. Night duty has the same shape, applied to a child rather than a guardian. +- [pickup-schedules.md](pickup-schedules.md) (`Features/Pickups`): the **per-child** scope, the + guardian/child authorization tiers and the **assignee** union (`Guardian`, `Babysitter`, + `Playdate`, ...). Night duty reuses its assignee shapes, but not its "every date filled in by + hand" model. +- [babysitters.md](babysitters.md): a babysitter is referenced by `(GuardianId, BabysitterId)` and + its name is resolved on read. + +This document answers seven questions: whether this extends `Pickups`, the aggregate's shape and +identity, how a responsible person and a note are modelled, how versioned fixed schedules work, how +overrides and switches work, who can see and change the data, and how the print sheet and the child +view use it. + +## Question 1: a third pickup slot, or a new feature? + +**Decision: a new feature, `Features/NightDuty`, with its own `NightSchedule` aggregate.** +`Pickups` stays untouched. + +- **Pickups have no fixed schedule.** A `PickupSchedule` is a sparse map of hand-filled + `(Date, Slot)` assignments + ([PickupSchedule.cs](../../../src/backend/buddy/Features/Pickups/Types/PickupSchedule.cs)). Adding + a pattern and versioning to it for one slot would change how the two existing slots expand, and + every pickup reader with them. +- **A night isn't a time-of-day slot.** `PickupSlot` is `DropOff | PickUp` with an optional time; + a night runs from date D into D+1 and has no time in v1 (requirement 6). The printed pickup cell + is split diagonally into two triangles, one per slot, and has no room for a third value. +- **The assignee cases differ.** `SelfEscort` and `Sibling` make sense for getting to school, not + for being responsible at night. A night needs a `Sleepover` (the child sleeps somewhere else) and + an `Unassigned` case that still carries a note. +- This is the same call [work-locations.md, Question 1](work-locations.md#question-1-calendar-items-or-a-new-feature) + made: a distinct concept with its own resolution rules gets a distinct aggregate. + +Considered and rejected: a `CalendarItem` recurrence per guardian (calendar recurrence has no +weekday lists or exceptions, see the same work-locations question), and a third `PickupSlot.Night` +(above). + +## Question 2: aggregate shape and identity + +**Decision: one `NightSchedule` stream per child, whose id equals the child's `UserId`, created +lazily by the first write. No index document.** + +```text +NightSchedule( + NightScheduleId Id, // == ChildId.Value + UserId ChildId, + ImmutableList Patterns, // fixed-schedule versions, ordered by EffectiveFrom, see Question 4 + ImmutableDictionary Overrides) // sparse, see Question 5 +``` + +- **Per child, like `PickupSchedule` and `MedicineSchedule`.** Siblings can sleep in different + homes on the same night, so a family-wide schedule (the `MealPlan` shape) doesn't fit. +- **Deterministic id, like `WorkLocationScheduleId.ForGuardian` and `ProgressId.ForChild`.** + `NightScheduleId.ForChild(UserId)` returns `new(childId.Value)`, so "this child's schedule" is a + direct stream lookup + ([WorkLocationScheduleId.cs](../../../src/backend/buddy/Features/WorkLocations/Types/WorkLocationScheduleId.cs)). + `PickupSchedule` needs `PickupScheduleIndexDocument` only because its id is random; that + precedent is deliberately not followed. +- **Lazy creation.** `NightScheduleStarted` is appended together with the first real write, like + `WorkLocationScheduleStarted` and `PickupScheduleCreated`. A child whose guardians never open the + feature has no stream, and reads return an empty schedule. +- **Dates are the child's wall-clock dates.** The night of `D` is the night that starts on the + evening of `D`. As in work locations, `DateOnly` carries no time zone, so DST never comes into it. + "Today" (for the "Tonight" card and for the `EffectiveFrom` check) is resolved in the child's + time zone with the existing `MedicineEffectNotes.ChildTodayAsync` + ([MedicineEffectNotes.cs:46](../../../src/backend/buddy/Features/Medicines/MedicineEffectNotes.cs)), + which a shared helper should take over rather than duplicate. + +## Question 3: the responsible person and the note + +**Decision: a night holds a `NightEntry` of a responsible person and a note, where the +responsible person is a union of four cases.** + +```text +NightEntry(NightResponsible Responsible, string Note) // Note "" = none; never Unassigned with "" + +NightResponsible = + Guardian(UserId GuardianId) // an active guardian of the child + | Babysitter(UserId GuardianId, BabysitterId BabysitterId) // on that guardian's BabysitterList + | Sleepover(string HostName, string Location, string ContactInfo) // Location, ContactInfo "" = not given + | Unassigned // a note on its own, e.g. "Screen night 📱" +``` + +- **The cases copy `PickupAssignee`'s shapes** (`Guardian`, `Babysitter`, and `Playdate` renamed to + `Sleepover`) ([PickupAssignee.cs](../../../src/backend/buddy/Features/Pickups/Types/PickupAssignee.cs)). + It is a separate union, not `PickupAssignee` itself, because the case sets differ (Question 1), + and a shared union would let a pickup carry `Unassigned` or a night carry `SelfEscort`. +- **Persisted with an explicit discriminator.** `Guardian` and `Babysitter` both carry a bare + guid, so System.Text.Json's shape-based union classifier can't tell them apart. A + `NightResponsibleJsonConverter` writes `{"Kind":"Guardian","GuardianId":...}` and so on, the same + fix as `PickupAssigneeJsonConverter` and `WorkDayOverrideJsonConverter`. On the wire, + `NightResponsibleDto` is a `KindDiscriminatedJsonConverter` with a numeric `kind`, like + `PickupAssigneeDto`. +- **Names are resolved on read, not stored.** A guardian's display name comes from the existing + guardian list the frontend already loads (the pickup page does the same). A babysitter's name is + resolved through `BabysitterNames` + ([BabysitterNames.cs](../../../src/backend/buddy/Features/Pickups/BabysitterNames.cs)), so a + rename reaches every night. `BabysitterNames` takes `PickupAssignee`s today. It should be + generalised to take `(GuardianId, BabysitterId)` pairs and move to `Features/Babysitters`; see + "Refactors" below. +- **The note is short free text.** It has a 60-character maximum, is trimmed with + `FreeText.Normalize`, and may contain emoji. It is not a separate field on the night, so an + override replaces the whole entry; see Question 5 for how the editor keeps a scheduled note. +- **`Unassigned` with an empty note is not an entry.** In a pattern that day is simply left out; + as an override it is the `Cleared` case (Question 5). The validator rejects it. + +## Question 4: the fixed schedule, versioned from a date + +**Decision: the fixed schedule is a list of `NightPattern` versions, each with an `EffectiveFrom` +date. Saving a version replaces every version that starts on or after its date. A date resolves +against the latest version that starts on or before it.** + +```text +NightPattern( + DateOnly EffectiveFrom, // first night this version applies to + int CycleWeeks, // 1–4, as WorkPattern + DateOnly AnchorMonday, // the Monday that starts cycle week 0, as WorkPattern + IReadOnlyList Days) // days not listed have no entry + +NightPatternDay(int Week, DayOfWeek Day, NightEntry Entry) // Week 0-based, < CycleWeeks +``` + +- **Cycle arithmetic is `WorkPattern`'s, unchanged.** The cycle week of a date is the whole weeks + from `AnchorMonday`, floored modulo `CycleWeeks`, so dates before the anchor and ISO week 53 years + resolve correctly ([WorkPattern.cs](../../../src/backend/buddy/Features/WorkLocations/Types/WorkPattern.cs); + [work-locations.md, Question 4](work-locations.md#question-4-alternating-weekly-patterns) explains + why it's an anchor and not ISO parity). `MondayOnOrBefore` and `CycleWeekOf` move to a small + shared `WeekCycle` helper that both features call; see "Refactors". +- **Versioned, unlike work locations v1.** Work locations explicitly left "pattern effective-from + dates" as an open question, because printing looks forward. Night duty is shown to the child, + printed on the fridge, and will be read by the sleep diary (#68), so a schedule change must not + rewrite who was on duty last month (requirement 4). +- **`EffectiveFrom` can't be in the past.** It defaults to today in the child's time zone, and the + handler rejects an earlier date with a validation error. The past is therefore never rewritten + by a schedule save. Only overrides can touch a past date (Question 5). +- **"From this date on, this is the schedule".** Saving a version with `EffectiveFrom = X` drops + every stored version with `EffectiveFrom >= X` and appends the new one. This covers the cases + without a separate delete: + - editing the upcoming version again replaces it (same date); + - saving an earlier date than an upcoming version replaces that upcoming version too, so there is + never a stale future version the guardian can't see; + - stopping the schedule from a date is a save of an empty `Days` list. + + Considered and rejected: keeping later versions when an earlier one is saved. It's more + expressive ("change from Monday, and again from January"), but the editor would need a version + timeline, and an upcoming change could silently override what a guardian just saved. + +- **Before the first version, there's nothing.** A date earlier than every `EffectiveFrom` + resolves to "no entry", as a child with no stream does. +- **The anchor stays per version.** Each version carries its own `AnchorMonday`, so an A/B rhythm + can be re-phased from a date ("from Monday, week A starts again") without touching the past. +- **Replaced wholesale.** The editor edits the whole grid of one version, and one + `NightPatternReplaced` event per save keeps the edit atomic, as `WorkPatternReplaced` and + `PrintTemplateRowsReplaced` do. + +## Question 5: overrides and switching nights + +**Decision: a sparse per-date override map, where an override is either an entry or `Cleared`. +Resolution is override → the pattern version in effect → nothing. Writes take a list of dates, +so a switch is one request and one append.** + +```text +NightOverride = Entry(NightEntry Entry) | Cleared // Cleared = "the schedule says Dad, but nothing is planned this night" +``` + +- **"No override" is not "override to nothing",** as in work locations. No override means "follow + the fixed schedule", and `Cleared` blanks a night the schedule would fill. +- **`SetNightOverrides` takes a list of `(date, override)` pairs,** not a `[from, to]` range with + one value as `SetWorkLocationOverrides` does. A switch sets two dates to two different values, so + a single-value range can't express it, while a holiday is still easy to send as a list. At most 31 + dates per request (the `ValidDateRange` cap), with no duplicates. +- **One event per date that changes, in a single append.** This copies `WorkLocationOverridden` / + `WorkLocationOverrideCleared`, not the `NightOverridesSet` / `NightOverridesCleared` batch events + the issue sketched. Per-date events keep the fold a one-key `SetItem`/`Remove`, match the precedent + and the golden files, and make "who changed this night" a simple stream filter later. Atomicity + comes from the single `SaveChangesAsync`, not from one event. Dates whose value wouldn't change + emit nothing. +- **Switching is a frontend action.** "Switch two nights" reads both resolved nights and sends one + `SetNightOverrides` with the **responsible persons swapped and each night's note kept**. A screen + night stays on Friday when Mom and Dad trade Friday for Tuesday. The server sees two ordinary + overrides, so there is no switch-specific rule to test or version. +- **The editor keeps a scheduled note by default.** When a guardian changes only the person on a + screen night, the dialog pre-fills the note from the schedule, so the override doesn't drop it by + accident (an override replaces the whole entry, Question 3). +- **Overrides may be in the past.** Unlike the schedule, a guardian can correct a past night ("Dad + did it after all"). That is the record being corrected, not the rules being rewritten, and the + override is visible in the history. +- **`ClearNightOverrides` takes `from` and `to`** (the shared range rule), like + `ClearWorkLocationOverrides`. It is idempotent: dates without an override emit nothing. +- **Always overwrite**, with no confirmation step, as `PickupAssigned` and `WorkLocationOverridden` + do. + +## Question 6: who can see and change this + +**Decision: the `Pickups` tiers. Manage = an active guardian of the child. View = the child +themself. Everyone else gets `NotFound`.** + +| Tier | Who | Actions | +| ---------- | -------------------------------------- | ------------------------------------------- | +| **Manage** | An active `GuardianLink` to the child | Everything | +| **View** | The child themself | `ListNights` only (the "Tonight" card) | +| — | Everyone else, a revoked link included | `NotFound`; a child calling a write → `403` | + +- **Same helper shape as `PickupAuthorization`** + ([PickupAuthorization.cs](../../../src/backend/buddy/Features/Pickups/PickupAuthorization.cs)), + as a `NightDutyAuthorization` in the feature. Copying ~40 lines rather than reaching into + `Pickups` keeps the features independent, the same trade-off `WorkLocationAuthorization` made. +- **Any active guardian can write, and every guardian sees every change.** There is no + per-guardian ownership of a night: the schedule is the child's (requirement "a change by one + guardian is visible to the other guardians"). Concurrent saves use expected-version appends via + `StreamVersionTracker`; a true race is `409 concurrency_conflict`. +- **The child can't read `GetNightSchedule`.** The child sees resolved nights, not the editor's + versions and notes for weeks ahead. `GetNightSchedule` is Manage only; a child calling it gets + `403`, as a child calling a pickup write does. +- **Referenced people are checked on write.** A `Guardian` must hold an active link to the child, + and a `Babysitter`'s `GuardianId` must too, with the babysitter active on that guardian's list. + These are the `AssignPickup` relationship checks + ([AssignPickup.Handler.cs:84](../../../src/backend/buddy/Features/Pickups/AssignPickup/AssignPickup.Handler.cs)), + run for each pattern day and override, and reported as `400 validation_error`. +- **No group sharing in v1**, as pickups. A grandparent in the family group who isn't a guardian + can't see the schedule. + +## Question 7: the print sheet and the child's "Tonight" card + +**Decision: a new `PrintRowKind.NightDuty` row and a child dashboard card, both reading +`ListNights`.** + +### Print + +- `PrintRowKind.NightDuty` is **appended at the end** of the enum, after `CalendarAgenda`, because + the ordinal is the wire value ([PrintRowKind.cs](../../../src/backend/buddy/Features/PrintTemplates/Types/PrintRowKind.cs)). +- The row needs `ChildId` (an existing optional `PrintTemplateRow` field) and nothing else. The + template's write-time checks extend as for a `Pickup` row: the caller must be a guardian of that + child. +- `WeekPlanLoader` calls `ListNights(childId, monday, sunday)` once per Night row, behind the same + `unlessOff` feature switch as the other sources + ([week-plan-loader.ts:87](../../../src/frontend/buddy/src/app/features/guardian/print/week-plan-loader.ts)). +- `assembleWeekPlan` gets a `nightCells` case next to `workLocationCells` + ([assemble-week-plan.ts:124](../../../src/frontend/buddy/src/app/features/guardian/print/assemble-week-plan.ts)). + A guardian prints in their template `GuardianColor`, a babysitter in their `BabysitterColor`, a + sleepover as "Overnight: Grandma Lis" / "Overnatning: Grandma Lis", and the note goes on a second + line. An empty night is left blank so it can be written in by hand. A4 and A3 must still print + as one landscape page. + +A mockup of the printed week plan with a Night row (demo data, not a screenshot of built code): + +![Night duty print row mockup](images/night-duty-print-mockup.png) + +### Child view + +- A "Tonight" card on the child's day dashboard (`features/child/home/home.html`), below the + "Today's pickup & drop-off" card, reading `ListNights(me, today, today)` with the child's own View + access. "Today" comes from the app's child clock, like every other card on the dashboard. +- It shows the responsible person large (the guardian's calling name and avatar, a babysitter's + name, or the sleepover host) and the note as its own chip ("📱 Screen night!"). An empty night + shows no card, as an empty pickup day shows none. + +A mockup of the "Tonight" card on the child's phone (demo data, not a screenshot of built code): + +![Night duty Tonight card mockup](images/night-duty-child-tonight-mockup.png) + +## Events + +```text +NightScheduleStarted(NightScheduleId Id, UserId ChildId, DateTimeOffset OccurredAt) + // appended lazily with the first write, never on its own + +NightPatternReplaced(NightScheduleId Id, NightPattern Pattern, UserId ModifiedBy, DateTimeOffset OccurredAt) + // Pattern.EffectiveFrom says which versions it replaces (all with EffectiveFrom >= it) + +NightOverridden(NightScheduleId Id, DateOnly Date, NightOverride Override, UserId ModifiedBy, DateTimeOffset OccurredAt) + +NightOverrideCleared(NightScheduleId Id, DateOnly Date, NightOverride Before, UserId ModifiedBy, DateTimeOffset OccurredAt) +``` + +- **`ModifiedBy` is carried,** unlike the work-location events. Any guardian of the child can + write (Question 6), so the writer isn't implied by the stream, as `PickupCleared.ModifiedBy` + already shows. +- **No `Before` on `NightPatternReplaced`.** The replaced versions are already in the stream and + nothing reads them from the event (the eliminate-nulls.md Phase 6 stance on `WorkLocationOverridden`). +- `NightResponsible` and `NightOverride` get explicit-discriminator Marten converters (Question 3). +- All four events get golden-file coverage in `buddy.IntegrationTests/EventShapeTests`. + +### Rehydration + +`NightSchedule.Start` / `Advance`, as `WorkLocationSchedule`'s: + +- `NightScheduleStarted` seeds an empty schedule (no versions, no overrides). +- `NightPatternReplaced` sets + `Patterns = Patterns.Where(p => p.EffectiveFrom < e.Pattern.EffectiveFrom).Append(e.Pattern)`, + which stays sorted because nothing later survives. +- `NightOverridden` and `NightOverrideCleared` set or remove one key in `Overrides`. + +An inline `NightScheduleSnapshotProjection` writes the snapshot to the shared `snapshots` schema +([event-stream-snapshots.md](event-stream-snapshots.md)), with a `NightScheduleSnapshot(Guid Id, +NightSchedule NightSchedule)` wrapper like `WorkLocationScheduleSnapshot`. The events live in a new +Marten schema, `nightduty`. + +## Read models + +There are no index documents (Question 2). `NightExpansion.Expand(schedule, from, to)` is a pure, +recomputed-on-every-call helper with `WorkLocationExpansion`'s contract: + +```text +Night(DateOnly Date, NightStatus Status) + +NightStatus = + Unplanned // no override, no entry in the version in effect + | Cleared(NightEntry? Scheduled) // override to nothing; what the schedule would have said + | Planned(NightEntry Entry, NightSource Source, NightEntry? Scheduled) + // Source = Pattern | Override; Scheduled is set only for an override, so the editor can show "Schedule: Dad" +``` + +The wire shape is `{ "kind": 0 } | { "kind": 1, "scheduled" } | { "kind": 2, "entry", "source", +"scheduled" }`, with `NightResponsibleDto` inside `entry`, a babysitter's `name` resolved, as +`WorkDayStatus` and `PickupAssigneeDto` do. Carrying the scheduled entry costs one extra lookup +per overridden date and saves the week view a second request. It is how the mockup shows +"Changed · Schedule: Dad". + +## Command slices (`Features/NightDuty/`, same vertical-slice shape as `WorkLocations`) + +| Slice | Tier | Notes | +| --------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `ReplaceNightPattern` | Manage | `effectiveFrom` (default today in the child's zone, not in the past), `cycleWeeks`, `anchorMonday`, `days`. No-op when equal to the version already stored for that date. Emits `NightPatternReplaced` | +| `SetNightOverrides` | Manage | `nights: [{ date, override }]`, 1–31 distinct dates. One `NightOverridden` per date that changes | +| `ClearNightOverrides` | Manage | `from`, `to`. One `NightOverrideCleared` per date that had an override; idempotent | +| `GetNightSchedule` | Manage | All pattern versions (flagged past / current / upcoming) for the editor; overrides are read resolved, via `ListNights` | +| `ListNights` | Manage, View | Resolved `Night`s for `[from, to]`, with names resolved | + +### Validation limits + +Structural rules go in each slice's validator. Rules that need other aggregates (guardian links, +babysitter lists) or the child's time zone are handler checks, as +[validation-rules.md](validation-rules.md) prescribes: + +| Field | Rule | +| -------------------------- | ---------------------------------------------------------------------------------------------- | +| `EffectiveFrom` | today or later, in the child's time zone (handler) | +| `CycleWeeks` | 1–4 | +| `AnchorMonday` | must be a Monday | +| `Days` | no null entries; 0 ≤ `Week` < `CycleWeeks`; at most one entry per (`Week`, `Day`) | +| `Note` | trimmed, ≤ 60 characters | +| Entry | not `Unassigned` with an empty note | +| `Sleepover.HostName` | required, ≤ 200 (as `Playdate`); `Location` ≤ 200, `ContactInfo` ≤ 2000 | +| `Guardian.GuardianId` | an active guardian of the child (handler) | +| `Babysitter` | its `GuardianId` an active guardian of the child, the babysitter active on that list (handler) | +| Override `nights` | 1–31 entries, distinct dates | +| `ListNights`, clear ranges | `from ≤ to`, `to - from` ≤ 31 (shared `ValidDateRange`) | + +## Routes + +```text +PUT /night-duty/children/{childId}/pattern ReplaceNightPattern +PUT /night-duty/children/{childId}/overrides SetNightOverrides +DELETE /night-duty/children/{childId}/overrides ClearNightOverrides (from, to as query params) +GET /night-duty/children/{childId} GetNightSchedule +GET /night-duty/children/{childId}/nights ListNights (from, to as query params) +``` + +Status codes follow [http-status-codes.md](../http-status-codes.md): `NotFound` for no relationship +to the child, `Forbidden` for a child calling a Manage route, `BadRequest` with an `ErrorEnvelope` +for validation failures. After the endpoints exist: a `NightDuty.http` file, `task docs:openapi` +and `task codegen:backend`. + +## Refactors + +Two small extractions, so the second user of each doesn't copy it: + +1. **Week cycle arithmetic.** Today it lives on `WorkPattern` + ([WorkPattern.cs:16-25](../../../src/backend/buddy/Features/WorkLocations/Types/WorkPattern.cs)): + + ```csharp + public static DateOnly MondayOnOrBefore(DateOnly date) => + date.AddDays(-(((int)date.DayOfWeek + 6) % 7)); + + public int CycleWeekOf(DateOnly date) { /* floored modulo from AnchorMonday */ } + ``` + + It becomes a static `Common/WeekCycle` with `MondayOnOrBefore(date)` and + `CycleWeekOf(anchorMonday, cycleWeeks, date)`. `WorkPattern` keeps its instance methods as + one-line delegates, so no work-locations caller or snapshot changes. + +2. **Babysitter name resolution.** `BabysitterNames.LoadAsync` takes `IEnumerable` + and `NameOf` takes a `PickupAssignee.Babysitter` + ([BabysitterNames.cs:20-27](../../../src/backend/buddy/Features/Pickups/BabysitterNames.cs)): + + ```csharp + // before + public string NameOf(PickupAssignee.Babysitter babysitter) => ...; + public static Task LoadAsync(IEnumerable assignees, ...); + + // after (Features/Babysitters) + public string NameOf(BabysitterId babysitterId) => ...; + public static Task LoadAsync(IEnumerable<(UserId GuardianId, BabysitterId BabysitterId)> refs, ...); + ``` + + Its `Pickups` callers pass the pairs instead. That is 4 call sites (`LoadAsync` in + `AssignPickup.Handler`, `PickupScheduleExpansion` and `PickupsPersonalDataExport`, `NameOf` in + `PickupAssigneeDto.FromDomain`) and no event or wire change. + +## Frontend + +A mockup of the guardian page: the resolved week with three changed nights and a screen night, and +the fixed-schedule editor preparing a 2-week cycle from Nov 16 (demo data, not a screenshot of built +code): + +![Night duty guardian page mockup](images/night-duty-guardian-mockup.png) + +A mockup of changing one night and of switching two nights (demo data, not a screenshot of built +code): + +![Night duty edit and switch dialogs mockup](images/night-duty-edit-night-mockup.png) + +- **Service:** `core/night-duty.service.ts`, following `work-locations.service.ts`, with + `postIdempotent`-style idempotency keys on the writes as the other services do. +- **Route:** `/guardian/night-duty` under `features/guardian/night-duty/`, behind a new + `nightDuty` feature flag (`features.service.ts`, `FeatureOptions.cs`), with a guardian menu + entry and a help topic. The pickup page gets a "Night duty" link next to "Manage babysitters", + and the night duty page links back. This puts the two schedules next to each other without + growing the pickup page further. +- **Week card:** a child picker, week paging, seven night tiles. Each tile shows the resolved + person, the note chip, and whether it is from the schedule or changed (with "Schedule: Dad" + underneath). Tapping a tile opens the edit dialog (person, babysitter picker, sleepover fields, + "Nobody planned", note pre-filled from the schedule, "Back to schedule" to clear). A "Switch two + nights" action picks two tiles and saves one `SetNightOverrides`. +- **Fixed schedule card:** the work-location pattern editor's layout (`segmented-control` for the + cycle length, "this week is week A/B" anchor picker, a cycle-weeks × 7 grid), with a person + select and a note input per cell, an "Applies from" date (default today, min today), and a short + list of versions (current, upcoming). +- **Child card:** in `features/child/home/home.html`, as described in Question 7. +- **Print:** `PRINT_ROW_KIND.nightDuty` in the template editor (child picker only) and the sheet. +- **i18n:** `core/i18n/translations/{en,da}/night-duty.ts` ("Night duty" / "Nattevagt"), plus + `child.home.tonight.*` and the print row label ("Night" / "Nat"). +- **Screenshots:** `guardian-night-duty` in `screenshots/pages.ts`, and a Night row and a screen + night in `demo-family.ts`, so the child home and print sheet screenshots change too. The demo + family gets a second guardian, Jonas Holm (calling name "Dad"), as a co-guardian of Emil and + Ida, so the seeded schedule shows the Mom/Dad rhythm of the mockups. The seed also adds a + babysitter night and a sleepover. Dad's demo Keycloak user is created the same way as the + existing demo users, and the pickup screenshots can use him too. + +## Testing + +Alba integration tests mirror `WorkLocations`' folder, with the usual per-status cases plus: + +- cycle resolution for dates before the anchor, across a year boundary and across ISO week 53 + (2026-12-28 to 2027-01-03); +- versioning: a save from `X` leaves nights before `X` resolving against the older version; an + `EffectiveFrom` in the past is `400`; a save from `X` drops an upcoming version from `Y > X`; a + date before the first version is `Unplanned`; +- override beats pattern; `Cleared` blanks a scheduled night; clearing reverts; a two-date + `SetNightOverrides` appends exactly two events in one append; an unchanged date emits nothing; +- a note alone (`Unassigned` + note) is accepted in both a pattern and an override; `Unassigned` + with no note, and a 61-character note, are `400`; +- a guardian who isn't an active guardian of the child, and an unknown or archived babysitter, are + `400`; a non-guardian gets `404`; a child gets `200` on `ListNights` and `403` on writes and on + `GetNightSchedule`; a revoked link drops to `404`; +- a write by one guardian is visible to the other through `ListNights`; +- a babysitter rename shows on the next `ListNights`. + +Plus `EventShapeTests` golden files, a `SnapshotTests` entry, Vitest specs for the service, the +page, the dialogs, the child card and `nightCells`, and a Playwright journey: save a fixed schedule, +switch two nights, see the change marked, open the print sheet and see the Night row. + +## GDPR + +`NightDutyPersonalData` (eraser and exporter), registered like `PickupsPersonalData` +([PickupsPersonalData.cs](../../../src/backend/buddy/Features/Pickups/PickupsPersonalData.cs)): + +- erasing the **child** deletes the stream and its snapshot (sleepover hosts' names and contact + details are the child's data, like playdate hosts); +- erasing a **guardian** leaves the child's schedule alone, as pickups do. Nights that referred to + them stop resolving a name and show "a former guardian"; +- the export includes each child's versions and overrides in the guardian's and the child's export. + +## Failure and edge-case behavior + +| Case | Behavior | +| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| Child's guardians have never used the feature | No stream; reads return no versions and `Unplanned` for every night; no "Tonight" card | +| Date before the first version's `EffectiveFrom` | `Unplanned` (or the override, if any) | +| Schedule saved with `EffectiveFrom` in the past | `400 validation_error`; the past is never rewritten by a schedule save | +| Schedule saved from `X` while an upcoming `Y > X` exists | The upcoming version is dropped; the new one applies from `X` | +| Same version saved again | No-op, no event | +| Override to the value it already has | No-op, no event | +| Clear a range with no overrides | Idempotent no-op (`Success`, no event) | +| Override on a past date | Allowed (correcting the record); shown as changed | +| Guardian's `GuardianLink` is revoked | Their reads and writes drop to `404`; existing nights naming them still resolve, the editor flags them | +| Babysitter archived or renamed | Existing nights keep resolving the (new) name; new entries can't pick an archived babysitter | +| Guardian deletes their account | Nights naming them show "a former guardian"; the schedule is untouched | +| Child opens the app after midnight | "Tonight" is the night of the child's current date, i.e. the coming evening, not the night in progress | +| Week 53 years | No effect; cycle weeks count from the anchor | +| Concurrent saves by two guardians | Expected-version append; the loser gets `409 concurrency_conflict` and reloads | + +## Implementation notes + +Where the built code differs from the design above: + +- **Status case names.** The wire cases of `NightStatus` are `NightUnplanned`, `NightCleared` and + `NightPlanned`: the OpenAPI schema ids are the bare type names, and `Unplanned` already belongs to + `WorkDayStatus`. +- **The week view starts tonight**, like the pickup planner, not on a Monday. A schedule can't start + in the past, so a Monday-first week would open on blank nights before today. +- **Switching is inline.** "Switch two nights" turns the tiles' buttons into pickers and shows a + confirm button for the two picked nights, rather than a separate dialog. Switching a night with a + note-only or empty night moves only the person; a side left with neither person nor note becomes + a cleared night. +- **The "Tonight" card** is a self-contained component placed after the day's sections (so below + the pickup card), and takes "today" from the app's child clock like the other cards. +- **Shared helpers**, as the "Refactors" section planned: `Common/WeekCycle`, + `Features/Babysitters/BabysitterNames`, and `Features/Users/UserLocalDate` (the child's today, + taken out of `MedicineEffectNotes`). +- **Print row label.** The editor calls the kind "Night duty" ("Nattevagt"); the demo template's + row is labelled "Night". + +## Estimate + +| | | +| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Complexity | High -- a new aggregate with versioned patterns, 4 events and 2 persisted unions, 5 routes, 2 small refactors, a guardian page with 2 dialogs, a child card, a print row kind, GDPR and demo data | +| Single developer | 8-12 days | +| AI agent | 5-8 hours, plus about 2 hours of human review | + +## Decisions made + +| Question | Decision | +| -------------------------- | --------------------------------------------------------------------------------------------------------- | +| Pickup slot or new feature | New feature `Features/NightDuty`, because pickups have no fixed schedule and a different set of assignees | +| Identity | One stream per child, id equal to the child's `UserId`, created lazily; no index document | +| Responsible person | `Guardian \| Babysitter \| Sleepover \| Unassigned`, plus a note of at most 60 characters | +| Fixed schedule | 1–4 week cycle from an anchor Monday, as work locations, in versions with an `EffectiveFrom` | +| Past nights | Frozen: `EffectiveFrom` can't be in the past; a save replaces all versions from its date on | +| Overrides | Sparse per-date `Entry \| Cleared`; override → version in effect → nothing | +| Override writes | A list of `(date, override)` pairs, one event per changed date in one append | +| Switching nights | A frontend action: swap the responsible persons, keep each night's note, one `SetNightOverrides` | +| Events | Per-date `NightOverridden` / `NightOverrideCleared` (the work-locations precedent), not batch events | +| Who sees it | Guardians Manage, the child Views resolved nights only, everyone else `NotFound` | +| Print | `PrintRowKind.NightDuty`, appended last; blank cell for an empty night | +| Child view | A "Tonight" card on the day dashboard; no card for an empty night | +| Page placement | Its own page `/guardian/night-duty`, linked from and to the pickup page | +| Demo data | A second demo guardian, Jonas Holm ("Dad"), seeded in `demo-family.ts` | +| "Tonight" after midnight | Follows the child's date, so after midnight the card shows the coming evening | +| "Changed by" history | Stored (`ModifiedBy` on every event) but not shown in v1 | +| Group visibility | None in v1: guardians and the child only, as pickups and work locations | + +## Remaining open questions + +None for v1. The questions raised in review are settled in the table above. The ones deferred +there can be added later without a redesign: + +- **"Tonight" after midnight.** A rule that shows the previous date's night until a cut-off + hour is a change to the child card only. +- **"Changed by" history.** `ModifiedBy` is already on every event, so showing it means adding a + field to `ListNights` and a line in the dialog. +- **Group visibility.** A View tier for group members goes in `NightDutyAuthorization`, as it + would for pickups and work locations. + +## Diagram + +```mermaid +flowchart TB + subgraph App["Buddy backend (AuthZ + domain via events)"] + direction TB + subgraph Aggregates["Event-sourced aggregates"] + Guardian["User (guardian)"] + Child["User (child)"] + Link["GuardianLink\n(existing)"] + Sitters["BabysitterList\n(existing, per guardian)"] + Sched["NightSchedule\nId == ChildId\nPatterns (EffectiveFrom versions)\nOverrides"] + Tpl["PrintTemplate\nrow kind NightDuty"] + end + Resolver["NightDutyAuthorization\n1. caller == child -> View\n2. active GuardianLink -> Manage\n3. else NotFound"] + Guardian -- "PUT pattern / overrides (Manage)" --> Resolver + Child -- "GET nights (View)" --> Resolver + Link -. "FindActiveLinkAsync (existing)" .-> Resolver + Resolver --> Sched + Sched -. "Babysitter(GuardianId, BabysitterId)\nname resolved on read" .-> Sitters + Tpl -. "ChildId -> ListNights" .-> Sched + end +``` diff --git a/docs/backend/glossary.md b/docs/backend/glossary.md index b5e9c569..890737c7 100644 --- a/docs/backend/glossary.md +++ b/docs/backend/glossary.md @@ -362,6 +362,24 @@ A one-off exception for a single date: a different location, or no location at a Another guardian who holds an active `GuardianLink` to at least one of the same children. Co-guardians can view each other's work locations. +## Night duty domain + +### NightSchedule + +Who is responsible for a child at night (who puts the child to bed, gets up in the night, and where the child sleeps), plus a short note such as "Screen night". One per child; its id equals the child's `UserId`. Guardians manage it; the child sees tonight's entry. + +### NightPattern + +One version of the fixed night schedule: from its `EffectiveFrom` date on, a cycle of 1–4 weeks counted from an anchor Monday, like a `WorkPattern`. Saving a version replaces every version that starts on or after its date, and `EffectiveFrom` is never in the past, so earlier nights keep the schedule they had. + +### NightEntry / NightResponsible + +A night's plan: a responsible person (a guardian, a babysitter, a sleepover host, or nobody) and a note. Nobody without a note is not an entry. + +### NightOverride + +A one-off change to a single night: a different entry, or `Cleared` ("nothing planned this night"). Overrides beat the fixed schedule; a switch between two guardians is two overrides saved together. + ## Babysitter domain ### BabysitterList diff --git a/docs/backend/night-duty/flow.md b/docs/backend/night-duty/flow.md new file mode 100644 index 00000000..ce8b5720 --- /dev/null +++ b/docs/backend/night-duty/flow.md @@ -0,0 +1,101 @@ +# Night Duty Flow + +The night duty feature records who is responsible for a child at night (who puts the child to +bed, gets up when they wake, and where they sleep), plus an optional short note such as a weekly +screen night. Guardians set a fixed schedule once (a cycle of 1–4 weeks), versioned from a date +so earlier nights keep the schedule they had, and override single nights. The child sees +tonight's entry on their day dashboard, and a print template can include a Night row. The design +is in [night-duty.md](../analysis/night-duty.md). + +```mermaid +sequenceDiagram + actor Guardian + actor CoGuardian as Co-guardian + actor Child + participant App as Client app + participant API as Buddy API + participant ND as NightDuty feature + participant Guardians as Guardian links + participant Sitters as Babysitter lists + participant Store as Night schedule event store + + Guardian->>App: Set a 2-week schedule from next Monday + App->>API: PUT /night-duty/children/{childId}/pattern + API->>ND: ReplaceNightPattern command + ND->>Guardians: Caller is an active guardian of the child? + ND->>ND: effectiveFrom not before the child's today + ND->>Guardians: Every named guardian is an active guardian of the child + ND->>Sitters: Every named babysitter is active on that guardian's list + ND->>Store: Start the stream lazily, append NightPatternReplaced + API-->>App: 200 OK (every version, flagged past/current/upcoming) + + CoGuardian->>App: Switch Tuesday and Thursday + App->>API: PUT /night-duty/children/{childId}/overrides {nights: [two dates]} + ND->>Store: Append one NightOverridden per changed date, in one append + API-->>App: 200 OK (the resolved nights that were set) + + Child->>App: Open the day dashboard + App->>API: GET /night-duty/children/{childId}/nights?from=today&to=today + ND->>Guardians: Caller is the child (View) + ND->>Store: Read snapshot, resolve override -> version in effect -> nothing + ND->>Sitters: Resolve babysitter names + API-->>App: 200 OK (Night[]) +``` + +## Endpoints + +| Method | Route | Behavior | +| -------- | ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `PUT` | `/night-duty/children/{childId}/pattern` | Saves a fixed-schedule version: `effectiveFrom` (optional, default the child's today, never in the past), `cycleWeeks` (1–4), `anchorMonday`, and `days` of `{week, day, entry}`. Replaces every version starting on or after `effectiveFrom`. Saving the last version again appends nothing. Returns the same body as `GET`. | +| `PUT` | `/night-duty/children/{childId}/overrides` | Sets single nights: `nights` of `{date, entry}`, 1–31 distinct dates; `entry: null` clears a night ("nothing planned"). A switch is two dates in one request. Returns the resolved nights that were set. | +| `DELETE` | `/night-duty/children/{childId}/overrides?from=...&to=...` | Removes overrides in the range so those nights follow the fixed schedule again. Idempotent. | +| `GET` | `/night-duty/children/{childId}` | The child's `today` and every version, oldest first, with `status` `Past`, `Current` or `Upcoming`. Guardians only. No stream yet reads as no versions. | +| `GET` | `/night-duty/children/{childId}/nights?from=...&to=...` | One `Night` per date with a `status`: `{ kind: 0 }` unplanned, `{ kind: 1, scheduled }` cleared by an override, or `{ kind: 2, entry, source, scheduled }` planned, with `source` `Pattern` or `Override`. `scheduled` is what the fixed schedule says for an overridden night. Guardians and the child themself. | + +An `entry` is `{ responsible, note }`. `responsible` is `{ kind: 0, guardianId }` (a guardian), +`{ kind: 1, guardianId, babysitterId, name }` (a babysitter on that guardian's list; `name` is +output only), `{ kind: 2, hostName, location, contactInfo }` (a sleepover) or `{ kind: 3 }` +(nobody named, only with a note). Date ranges are inclusive, and the night of a date runs into the +next morning. + +## Validation + +Every rule the endpoints enforce, validator rules first, then the handler's own checks. +A `400 validation_error` carries the message in the `ErrorEnvelope`'s `details` under the +key in the Field column, `""` for a check that isn't tied to one field (see +[HTTP status codes](../http-status-codes.md#error-response-shape-implemented)). A body +that can't be bound is rejected before these rules run, with the same envelope. + +| Endpoint | Field | Rule | Code | Status | +| ------------------------------------------ | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | ------ | +| `ReplaceNightPattern` | `""` | `days` has no `null` entries. Checked by the endpoint before the validator runs. | `validation_error` | 400 | +| `ReplaceNightPattern` | `cycleWeeks` | Between 1 and 4, inclusive. | `validation_error` | 400 | +| `ReplaceNightPattern` | `anchorMonday` | A Monday. | `validation_error` | 400 | +| `ReplaceNightPattern` | `days[n]` | `week` between 0 and `cycleWeeks` - 1. | `validation_error` | 400 | +| `ReplaceNightPattern` | `days[n]` | `day` a valid weekday. | `validation_error` | 400 | +| `ReplaceNightPattern` | `days` | At most one entry per `week` and `day`. | `validation_error` | 400 | +| `ReplaceNightPattern` | `days[n].entry.note` | At most 60 characters after trimming. | `validation_error` | 400 | +| `ReplaceNightPattern` | `days[n].entry.responsible` | A responsible person, a note, or both. A sleepover needs a `hostName` (at most 200 characters); `location` at most 200, `contactInfo` at most 2000. | `validation_error` | 400 | +| `ReplaceNightPattern` | `""` | `effectiveFrom` is not before the child's today. | `validation_error` | 400 | +| `ReplaceNightPattern`, `SetNightOverrides` | `""` | A newly named guardian is an active guardian of the child; a newly named babysitter is active on the list of an active guardian of the child. People already on the replaced version or the nights being set are not checked again. | `validation_error` | 400 | +| `SetNightOverrides` | `""` | `nights` has no `null` entries. Checked by the endpoint before the validator runs. | `validation_error` | 400 | +| `SetNightOverrides` | `nights` | 1–31 entries, each date at most once, all within 31 days of each other. | `validation_error` | 400 | +| `SetNightOverrides` | `nights[n].entry.note` | At most 60 characters after trimming. | `validation_error` | 400 | +| `SetNightOverrides` | `nights[n].entry.responsible` | As for `ReplaceNightPattern`. | `validation_error` | 400 | +| `ClearNightOverrides`, `ListNights` | `to` | `to` is not before `from`. | `validation_error` | 400 | +| `ClearNightOverrides`, `ListNights` | `to` | `to` - `from` is at most 31 days. | `validation_error` | 400 | + +## Authorization model + +- **Manage** is any active guardian of the child: every write and `GET /night-duty/children/{childId}`. + The child calling one of these gets `403`. +- **View** is the child themself: `ListNights` only, the "Tonight" card. +- Everyone else gets `404`. Access is computed on every request, so revoking a `GuardianLink` + removes access immediately. + +## Data protection + +Erasing the child deletes the stream and its snapshot (sleepover hosts' details are the child's +data). Erasing a guardian leaves the child's schedule in place; nights naming them stop +resolving a name. The `nightDuty` section of the personal-data export holds each child's +versions and overrides. diff --git a/docs/backend/openapi/buddy.json b/docs/backend/openapi/buddy.json index c40479b8..04d8888f 100644 --- a/docs/backend/openapi/buddy.json +++ b/docs/backend/openapi/buddy.json @@ -18044,6 +18044,525 @@ ] } }, + "/night-duty/children/{childId}/pattern": { + "put": { + "tags": [ + "NightDuty" + ], + "operationId": "ReplaceNightPattern", + "parameters": [ + { + "name": "childId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReplaceNightPatternRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NightScheduleResponse" + } + } + } + }, + "400": { + "description": "Bad Request. ErrorEnvelope code: \u0060validation_error\u0060.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "x-error-codes": [ + "validation_error" + ] + }, + "404": { + "description": "Not Found" + }, + "403": { + "description": "Forbidden. ErrorEnvelope code: \u0060not_a_buddy_member\u0060, \u0060user_not_provisioned\u0060.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "x-error-codes": [ + "not_a_buddy_member", + "user_not_provisioned" + ] + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "409": { + "description": "Conflict. ErrorEnvelope code: \u0060concurrency_conflict\u0060.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "x-error-codes": [ + "concurrency_conflict" + ] + }, + "429": { + "$ref": "#/components/responses/TooManyRequests" + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + }, + "security": [ + { + "keycloak": [] + } + ] + } + }, + "/night-duty/children/{childId}/overrides": { + "put": { + "tags": [ + "NightDuty" + ], + "operationId": "SetNightOverrides", + "parameters": [ + { + "name": "childId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetNightOverridesRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Night" + } + } + } + } + }, + "400": { + "description": "Bad Request. ErrorEnvelope code: \u0060validation_error\u0060.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "x-error-codes": [ + "validation_error" + ] + }, + "404": { + "description": "Not Found" + }, + "403": { + "description": "Forbidden. ErrorEnvelope code: \u0060not_a_buddy_member\u0060, \u0060user_not_provisioned\u0060.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "x-error-codes": [ + "not_a_buddy_member", + "user_not_provisioned" + ] + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "409": { + "description": "Conflict. ErrorEnvelope code: \u0060concurrency_conflict\u0060.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "x-error-codes": [ + "concurrency_conflict" + ] + }, + "429": { + "$ref": "#/components/responses/TooManyRequests" + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + }, + "security": [ + { + "keycloak": [] + } + ] + }, + "delete": { + "tags": [ + "NightDuty" + ], + "operationId": "ClearNightOverrides", + "parameters": [ + { + "name": "childId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "from", + "in": "query", + "required": true, + "schema": { + "type": "string", + "format": "date" + } + }, + { + "name": "to", + "in": "query", + "required": true, + "schema": { + "type": "string", + "format": "date" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request. ErrorEnvelope code: \u0060validation_error\u0060.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "x-error-codes": [ + "validation_error" + ] + }, + "404": { + "description": "Not Found" + }, + "403": { + "description": "Forbidden. ErrorEnvelope code: \u0060not_a_buddy_member\u0060, \u0060user_not_provisioned\u0060.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "x-error-codes": [ + "not_a_buddy_member", + "user_not_provisioned" + ] + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "409": { + "description": "Conflict. ErrorEnvelope code: \u0060concurrency_conflict\u0060.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "x-error-codes": [ + "concurrency_conflict" + ] + }, + "429": { + "$ref": "#/components/responses/TooManyRequests" + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + }, + "security": [ + { + "keycloak": [] + } + ] + } + }, + "/night-duty/children/{childId}": { + "get": { + "tags": [ + "NightDuty" + ], + "operationId": "GetNightSchedule", + "parameters": [ + { + "name": "childId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "If-None-Match", + "in": "header", + "description": "An ETag from an earlier 200; when it still matches, the answer is 304 with no body.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "headers": { + "ETag": { + "description": "A strong validator: a hash of this body.", + "schema": { + "type": "string" + } + }, + "Cache-Control": { + "description": "private, no-cache (revalidate with If-None-Match before reuse).", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NightScheduleResponse" + } + } + } + }, + "404": { + "description": "Not Found" + }, + "403": { + "description": "Forbidden. ErrorEnvelope code: \u0060not_a_buddy_member\u0060, \u0060user_not_provisioned\u0060.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "x-error-codes": [ + "not_a_buddy_member", + "user_not_provisioned" + ] + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "304": { + "$ref": "#/components/responses/NotModified" + }, + "429": { + "$ref": "#/components/responses/TooManyRequests" + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + }, + "security": [ + { + "keycloak": [] + } + ] + } + }, + "/night-duty/children/{childId}/nights": { + "get": { + "tags": [ + "NightDuty" + ], + "operationId": "ListNights", + "parameters": [ + { + "name": "childId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "from", + "in": "query", + "required": true, + "schema": { + "type": "string", + "format": "date" + } + }, + { + "name": "to", + "in": "query", + "required": true, + "schema": { + "type": "string", + "format": "date" + } + }, + { + "name": "If-None-Match", + "in": "header", + "description": "An ETag from an earlier 200; when it still matches, the answer is 304 with no body.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "headers": { + "ETag": { + "description": "A strong validator: a hash of this body.", + "schema": { + "type": "string" + } + }, + "Cache-Control": { + "description": "private, no-cache (revalidate with If-None-Match before reuse).", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Night" + } + } + } + } + }, + "400": { + "description": "Bad Request. ErrorEnvelope code: \u0060validation_error\u0060.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "x-error-codes": [ + "validation_error" + ] + }, + "404": { + "description": "Not Found" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "description": "Forbidden. ErrorEnvelope code: \u0060not_a_buddy_member\u0060, \u0060user_not_provisioned\u0060.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "x-error-codes": [ + "not_a_buddy_member", + "user_not_provisioned" + ] + }, + "304": { + "$ref": "#/components/responses/NotModified" + }, + "429": { + "$ref": "#/components/responses/TooManyRequests" + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + }, + "security": [ + { + "keycloak": [] + } + ] + } + }, "/print-templates": { "post": { "tags": [ @@ -26381,6 +26900,38 @@ "type": "string", "format": "uuid" }, + "BabysitterNightResponsibleDto": { + "required": [ + "guardianId", + "babysitterId", + "kind" + ], + "type": "object", + "properties": { + "guardianId": { + "type": "string", + "format": "uuid" + }, + "babysitterId": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": [ + "null", + "string" + ] + }, + "kind": { + "enum": [ + 1 + ], + "type": "integer", + "description": "BabysitterNightResponsibleDto", + "format": "int32" + } + } + }, "BabysitterRequest": { "required": [ "name" @@ -28507,6 +29058,27 @@ "type": "string", "format": "uuid" }, + "GuardianNightResponsibleDto": { + "required": [ + "guardianId", + "kind" + ], + "type": "object", + "properties": { + "guardianId": { + "type": "string", + "format": "uuid" + }, + "kind": { + "enum": [ + 0 + ], + "type": "integer", + "description": "GuardianNightResponsibleDto", + "format": "int32" + } + } + }, "GuardianSummary": { "required": [ "id", @@ -28653,6 +29225,7 @@ "pickups", "babysitters", "workLocations", + "nightDuty", "printing", "progress", "taskLibrary", @@ -28695,6 +29268,9 @@ "workLocations": { "type": "boolean" }, + "nightDuty": { + "type": "boolean" + }, "printing": { "type": "boolean" }, @@ -29997,116 +30573,392 @@ "type": "string", "format": "date" }, - "endDate": { - "type": [ - "null", - "string" - ], - "format": "date" + "endDate": { + "type": [ + "null", + "string" + ], + "format": "date" + }, + "isStopped": { + "type": "boolean" + }, + "isExcludedFromEffects": { + "type": "boolean" + }, + "createdBy": { + "type": "string", + "format": "uuid" + }, + "lastModifiedBy": { + "type": "string", + "format": "uuid" + } + } + }, + "MyFeelingsTodayResponse": { + "required": [ + "date", + "promptTimes", + "displayMode", + "checkIns" + ], + "type": "object", + "properties": { + "date": { + "type": "string", + "format": "date" + }, + "promptTimes": { + "type": "array", + "items": { + "type": "string", + "format": "time" + } + }, + "displayMode": { + "$ref": "#/components/schemas/FeelingsDisplayMode" + }, + "checkIns": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FeelingCheckInResponse" + } + } + } + }, + "MyTransitionWarningsResponse": { + "required": [ + "enabled", + "leadMinutes", + "kinds", + "quietHours", + "overrides" + ], + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "leadMinutes": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "kinds": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WarningKind" + } + }, + "quietHours": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/QuietHours" + } + ] + }, + "overrides": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ItemWarningOverrideResponse" + } + } + } + }, + "Name": { + "required": [ + "givenName", + "familyName" + ], + "type": "object", + "properties": { + "givenName": { + "type": "string" + }, + "familyName": { + "type": "string" + } + } + }, + "Night": { + "required": [ + "date", + "status" + ], + "type": "object", + "properties": { + "date": { + "type": "string", + "format": "date" + }, + "status": { + "$ref": "#/components/schemas/NightStatus" + } + } + }, + "NightCleared": { + "required": [ + "scheduled", + "kind" + ], + "type": "object", + "properties": { + "scheduled": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/NightEntryDto" + } + ] + }, + "kind": { + "enum": [ + 1 + ], + "type": "integer", + "description": "NightCleared", + "format": "int32" + } + } + }, + "NightEntryDto": { + "required": [ + "responsible" + ], + "type": "object", + "properties": { + "responsible": { + "$ref": "#/components/schemas/NightResponsibleDto" + }, + "note": { + "type": [ + "null", + "string" + ] + } + } + }, + "NightOverrideRequest": { + "required": [ + "date" + ], + "type": "object", + "properties": { + "date": { + "type": "string", + "format": "date" + }, + "entry": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/NightEntryDto" + } + ] + } + } + }, + "NightPatternDayDto": { + "required": [ + "week", + "day", + "entry" + ], + "type": "object", + "properties": { + "week": { + "type": "integer", + "format": "int32" }, - "isStopped": { - "type": "boolean" + "day": { + "$ref": "#/components/schemas/DayOfWeek" }, - "isExcludedFromEffects": { - "type": "boolean" + "entry": { + "$ref": "#/components/schemas/NightEntryDto" + } + } + }, + "NightPatternDayRequest": { + "required": [ + "week", + "day", + "entry" + ], + "type": "object", + "properties": { + "week": { + "type": "integer", + "format": "int32" }, - "createdBy": { - "type": "string", - "format": "uuid" + "day": { + "$ref": "#/components/schemas/DayOfWeek" }, - "lastModifiedBy": { - "type": "string", - "format": "uuid" + "entry": { + "$ref": "#/components/schemas/NightEntryDto" } } }, - "MyFeelingsTodayResponse": { + "NightPatternResponse": { "required": [ - "date", - "promptTimes", - "displayMode", - "checkIns" + "effectiveFrom", + "cycleWeeks", + "anchorMonday", + "days", + "status" ], "type": "object", "properties": { - "date": { + "effectiveFrom": { "type": "string", "format": "date" }, - "promptTimes": { - "type": "array", - "items": { - "type": "string", - "format": "time" - } + "cycleWeeks": { + "type": "integer", + "format": "int32" }, - "displayMode": { - "$ref": "#/components/schemas/FeelingsDisplayMode" + "anchorMonday": { + "type": "string", + "format": "date" }, - "checkIns": { + "days": { "type": "array", "items": { - "$ref": "#/components/schemas/FeelingCheckInResponse" + "$ref": "#/components/schemas/NightPatternDayDto" } + }, + "status": { + "$ref": "#/components/schemas/NightPatternStatus" } } }, - "MyTransitionWarningsResponse": { + "NightPatternStatus": { + "enum": [ + "Past", + "Current", + "Upcoming" + ], + "type": "string" + }, + "NightPlanned": { "required": [ - "enabled", - "leadMinutes", - "kinds", - "quietHours", - "overrides" + "entry", + "source", + "scheduled", + "kind" ], "type": "object", "properties": { - "enabled": { - "type": "boolean" - }, - "leadMinutes": { - "type": "array", - "items": { - "type": "integer", - "format": "int32" - } + "entry": { + "$ref": "#/components/schemas/NightEntryDto" }, - "kinds": { - "type": "array", - "items": { - "$ref": "#/components/schemas/WarningKind" - } + "source": { + "$ref": "#/components/schemas/NightSource" }, - "quietHours": { + "scheduled": { "oneOf": [ { "type": "null" }, { - "$ref": "#/components/schemas/QuietHours" + "$ref": "#/components/schemas/NightEntryDto" } ] }, - "overrides": { + "kind": { + "enum": [ + 2 + ], + "type": "integer", + "description": "NightPlanned", + "format": "int32" + } + } + }, + "NightResponsibleDto": { + "oneOf": [ + { + "$ref": "#/components/schemas/GuardianNightResponsibleDto" + }, + { + "$ref": "#/components/schemas/BabysitterNightResponsibleDto" + }, + { + "$ref": "#/components/schemas/SleepoverNightResponsibleDto" + }, + { + "$ref": "#/components/schemas/UnassignedNightResponsibleDto" + } + ], + "description": "One of 4 cases, picked by the numeric \u0022kind\u0022: 0 = GuardianNightResponsibleDto, 1 = BabysitterNightResponsibleDto, 2 = SleepoverNightResponsibleDto, 3 = UnassignedNightResponsibleDto." + }, + "NightScheduleResponse": { + "required": [ + "childId", + "today", + "patterns" + ], + "type": "object", + "properties": { + "childId": { + "type": "string", + "format": "uuid" + }, + "today": { + "type": "string", + "format": "date" + }, + "patterns": { "type": "array", "items": { - "$ref": "#/components/schemas/ItemWarningOverrideResponse" + "$ref": "#/components/schemas/NightPatternResponse" } } } }, - "Name": { + "NightSource": { + "enum": [ + "Pattern", + "Override" + ], + "type": "string" + }, + "NightStatus": { + "oneOf": [ + { + "$ref": "#/components/schemas/NightUnplanned" + }, + { + "$ref": "#/components/schemas/NightCleared" + }, + { + "$ref": "#/components/schemas/NightPlanned" + } + ], + "description": "One of 3 cases, picked by the numeric \u0022kind\u0022: 0 = NightUnplanned, 1 = NightCleared, 2 = NightPlanned." + }, + "NightUnplanned": { "required": [ - "givenName", - "familyName" + "kind" ], "type": "object", "properties": { - "givenName": { - "type": "string" - }, - "familyName": { - "type": "string" + "kind": { + "enum": [ + 0 + ], + "type": "integer", + "description": "NightUnplanned", + "format": "int32" } } }, @@ -30456,7 +31308,8 @@ "CalendarEvents", "TaskChecklist", "Blank", - "CalendarAgenda" + "CalendarAgenda", + "NightDuty" ], "type": "string" }, @@ -30916,6 +31769,37 @@ } } }, + "ReplaceNightPatternRequest": { + "required": [ + "cycleWeeks", + "anchorMonday", + "days" + ], + "type": "object", + "properties": { + "cycleWeeks": { + "type": "integer", + "format": "int32" + }, + "anchorMonday": { + "type": "string", + "format": "date" + }, + "days": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NightPatternDayRequest" + } + }, + "effectiveFrom": { + "type": [ + "null", + "string" + ], + "format": "date" + } + } + }, "ReplacePrintTemplateBabysitterColorsRequest": { "required": [ "colors" @@ -31584,6 +32468,20 @@ } } }, + "SetNightOverridesRequest": { + "required": [ + "nights" + ], + "type": "object", + "properties": { + "nights": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NightOverrideRequest" + } + } + } + }, "SetOccurrenceSkipRequest": { "required": [ "date", @@ -32068,6 +32966,38 @@ } } }, + "SleepoverNightResponsibleDto": { + "required": [ + "hostName", + "kind" + ], + "type": "object", + "properties": { + "hostName": { + "type": "string" + }, + "location": { + "type": [ + "null", + "string" + ] + }, + "contactInfo": { + "type": [ + "null", + "string" + ] + }, + "kind": { + "enum": [ + 2 + ], + "type": "integer", + "description": "SleepoverNightResponsibleDto", + "format": "int32" + } + } + }, "StartAiSessionRequest": { "required": [ "from", @@ -32633,6 +33563,22 @@ "TimeZoneId": { "type": "string" }, + "UnassignedNightResponsibleDto": { + "required": [ + "kind" + ], + "type": "object", + "properties": { + "kind": { + "enum": [ + 3 + ], + "type": "integer", + "description": "UnassignedNightResponsibleDto", + "format": "int32" + } + } + }, "Unplanned": { "required": [ "kind" @@ -33745,6 +34691,9 @@ { "name": "Medicines" }, + { + "name": "NightDuty" + }, { "name": "Pickups" }, diff --git a/docs/frontend/README.md b/docs/frontend/README.md index 867f090b..8d500bb9 100644 --- a/docs/frontend/README.md +++ b/docs/frontend/README.md @@ -153,6 +153,9 @@ The guardian routes currently include: - `/guardian/house-rules/print/children/:childId`, `/guardian/house-rules/print/groups/:groupId` — A4 portrait printouts for the fridge, outside the shell like the week-plan sheet - `/guardian/babysitters` — the guardian's saved babysitters and nannies, which any of a child's guardians can pick in the pickup planner +- `/guardian/night-duty` — who is on duty each night for a child: the week ahead from tonight, where a + night is changed or two nights are switched, and the fixed 1–4 week schedule versioned from a + date (shown to the child as a "Tonight" card and printable as a Night row) - `/guardian/work-locations` — the guardian's own work locations, alternating weekly pattern, and per-day exceptions (used by the printable week plan; never shown to children) - `/guardian/print` — quick print: pick a template and a start date, preview, print; create templates @@ -313,6 +316,8 @@ Enums arrive as member names (`'Owner'`, `'Dinner'`); only `kind` discriminators guardians can pick for that child - `PrintTemplatesService` manages print templates (layout, rows, guardian name colors); the `WeekPlanLoader` in `features/guardian/print` composes a week from the existing services +- `NightDutyService` saves a child's fixed night schedule, sets, switches and clears single nights, + and lists resolved nights (also for the child's own "Tonight" card) - `WorkLocationsService` manages the guardian's work locations, pattern, and exceptions, and lists resolved work days for the guardian or a co-guardian - `TaskLibraryService` manages per-child task templates and subtasks (routines and packing lists, diff --git a/docs/screenshots/README.md b/docs/screenshots/README.md index b31d7a5c..cfb986c0 100644 --- a/docs/screenshots/README.md +++ b/docs/screenshots/README.md @@ -238,6 +238,16 @@ Who takes and fetches the child each day, and a private calendar link with the s Pickup & drop-off on a phone +## Night duty + +Who is on duty each night: the week ahead with changed nights marked, and the fixed schedule they come from. + +![Night duty](guardian-night-duty.png) + +Night duty on a tablet + +Night duty on a phone + ## Babysitters Saved babysitters and nannies that any of a child’s guardians can plan for a pickup or drop-off. diff --git a/docs/screenshots/child-calendar.png b/docs/screenshots/child-calendar.png index b4a937d6..8808b78e 100644 Binary files a/docs/screenshots/child-calendar.png and b/docs/screenshots/child-calendar.png differ diff --git a/docs/screenshots/child-first-then.png b/docs/screenshots/child-first-then.png index 44545d6b..9097487f 100644 Binary files a/docs/screenshots/child-first-then.png and b/docs/screenshots/child-first-then.png differ diff --git a/docs/screenshots/child-home.png b/docs/screenshots/child-home.png index 08b9c0a8..9542d21e 100644 Binary files a/docs/screenshots/child-home.png and b/docs/screenshots/child-home.png differ diff --git a/docs/screenshots/child-homework.png b/docs/screenshots/child-homework.png index 4cde1626..bbdac65a 100644 Binary files a/docs/screenshots/child-homework.png and b/docs/screenshots/child-homework.png differ diff --git a/docs/screenshots/child-mealplan.png b/docs/screenshots/child-mealplan.png index d36fe5da..555d795b 100644 Binary files a/docs/screenshots/child-mealplan.png and b/docs/screenshots/child-mealplan.png differ diff --git a/docs/screenshots/child-rewards.png b/docs/screenshots/child-rewards.png index 821463d0..b687212d 100644 Binary files a/docs/screenshots/child-rewards.png and b/docs/screenshots/child-rewards.png differ diff --git a/docs/screenshots/child-timer.png b/docs/screenshots/child-timer.png index 4034a4ba..2e7bf7b4 100644 Binary files a/docs/screenshots/child-timer.png and b/docs/screenshots/child-timer.png differ diff --git a/docs/screenshots/guardian-admin.png b/docs/screenshots/guardian-admin.png index 338dbc59..31fb19a7 100644 Binary files a/docs/screenshots/guardian-admin.png and b/docs/screenshots/guardian-admin.png differ diff --git a/docs/screenshots/guardian-calendar.png b/docs/screenshots/guardian-calendar.png index eb8420fb..34b40c42 100644 Binary files a/docs/screenshots/guardian-calendar.png and b/docs/screenshots/guardian-calendar.png differ diff --git a/docs/screenshots/guardian-dashboard.png b/docs/screenshots/guardian-dashboard.png index aa52c67b..952fc641 100644 Binary files a/docs/screenshots/guardian-dashboard.png and b/docs/screenshots/guardian-dashboard.png differ diff --git a/docs/screenshots/guardian-feelings.png b/docs/screenshots/guardian-feelings.png index d043240b..b703d4ac 100644 Binary files a/docs/screenshots/guardian-feelings.png and b/docs/screenshots/guardian-feelings.png differ diff --git a/docs/screenshots/guardian-help.png b/docs/screenshots/guardian-help.png index dcd7164a..1f82e9fe 100644 Binary files a/docs/screenshots/guardian-help.png and b/docs/screenshots/guardian-help.png differ diff --git a/docs/screenshots/guardian-mealplan-ai-assistant.png b/docs/screenshots/guardian-mealplan-ai-assistant.png index 8562f7a3..b39a1b0d 100644 Binary files a/docs/screenshots/guardian-mealplan-ai-assistant.png and b/docs/screenshots/guardian-mealplan-ai-assistant.png differ diff --git a/docs/screenshots/guardian-mealplan.png b/docs/screenshots/guardian-mealplan.png index 8bd2161f..e8d749de 100644 Binary files a/docs/screenshots/guardian-mealplan.png and b/docs/screenshots/guardian-mealplan.png differ diff --git a/docs/screenshots/guardian-medicine-effects.png b/docs/screenshots/guardian-medicine-effects.png index b5208591..cbcacf3d 100644 Binary files a/docs/screenshots/guardian-medicine-effects.png and b/docs/screenshots/guardian-medicine-effects.png differ diff --git a/docs/screenshots/guardian-medicine.png b/docs/screenshots/guardian-medicine.png index 7f7357c7..26e48b95 100644 Binary files a/docs/screenshots/guardian-medicine.png and b/docs/screenshots/guardian-medicine.png differ diff --git a/docs/screenshots/guardian-night-duty.png b/docs/screenshots/guardian-night-duty.png new file mode 100644 index 00000000..8ca93f87 Binary files /dev/null and b/docs/screenshots/guardian-night-duty.png differ diff --git a/docs/screenshots/guardian-pickup.png b/docs/screenshots/guardian-pickup.png index a58082e7..84181c8f 100644 Binary files a/docs/screenshots/guardian-pickup.png and b/docs/screenshots/guardian-pickup.png differ diff --git a/docs/screenshots/guardian-print-sheet.png b/docs/screenshots/guardian-print-sheet.png index d85aa519..179933fe 100644 Binary files a/docs/screenshots/guardian-print-sheet.png and b/docs/screenshots/guardian-print-sheet.png differ diff --git a/docs/screenshots/guardian-print-template-editor.png b/docs/screenshots/guardian-print-template-editor.png index 374ce18b..940adb3a 100644 Binary files a/docs/screenshots/guardian-print-template-editor.png and b/docs/screenshots/guardian-print-template-editor.png differ diff --git a/docs/screenshots/guardian-progress.png b/docs/screenshots/guardian-progress.png index 9cc03dc7..b6b5a03b 100644 Binary files a/docs/screenshots/guardian-progress.png and b/docs/screenshots/guardian-progress.png differ diff --git a/docs/screenshots/guardian-sleep-diary.png b/docs/screenshots/guardian-sleep-diary.png index 781f905b..89c46ac3 100644 Binary files a/docs/screenshots/guardian-sleep-diary.png and b/docs/screenshots/guardian-sleep-diary.png differ diff --git a/docs/screenshots/guardian-visit-summaries.png b/docs/screenshots/guardian-visit-summaries.png index 11146b38..465d75b7 100644 Binary files a/docs/screenshots/guardian-visit-summaries.png and b/docs/screenshots/guardian-visit-summaries.png differ diff --git a/docs/screenshots/guardian-visit-summary-print.png b/docs/screenshots/guardian-visit-summary-print.png index a31378dc..760bb60e 100644 Binary files a/docs/screenshots/guardian-visit-summary-print.png and b/docs/screenshots/guardian-visit-summary-print.png differ diff --git a/docs/screenshots/guardian-visit-summary.png b/docs/screenshots/guardian-visit-summary.png index 1c12b258..d4843943 100644 Binary files a/docs/screenshots/guardian-visit-summary.png and b/docs/screenshots/guardian-visit-summary.png differ diff --git a/docs/screenshots/guardian-work-locations.png b/docs/screenshots/guardian-work-locations.png index 448ddae8..58346406 100644 Binary files a/docs/screenshots/guardian-work-locations.png and b/docs/screenshots/guardian-work-locations.png differ diff --git a/docs/screenshots/mobile/child-calendar.png b/docs/screenshots/mobile/child-calendar.png index 0588cd6a..b2df7332 100644 Binary files a/docs/screenshots/mobile/child-calendar.png and b/docs/screenshots/mobile/child-calendar.png differ diff --git a/docs/screenshots/mobile/child-first-then.png b/docs/screenshots/mobile/child-first-then.png index aa5a81d9..dc804a02 100644 Binary files a/docs/screenshots/mobile/child-first-then.png and b/docs/screenshots/mobile/child-first-then.png differ diff --git a/docs/screenshots/mobile/child-home.png b/docs/screenshots/mobile/child-home.png index ab64f34e..e6e2325d 100644 Binary files a/docs/screenshots/mobile/child-home.png and b/docs/screenshots/mobile/child-home.png differ diff --git a/docs/screenshots/mobile/child-homework.png b/docs/screenshots/mobile/child-homework.png index ae385bd3..7067ff9d 100644 Binary files a/docs/screenshots/mobile/child-homework.png and b/docs/screenshots/mobile/child-homework.png differ diff --git a/docs/screenshots/mobile/child-mealplan.png b/docs/screenshots/mobile/child-mealplan.png index 57edd992..6cd9c70c 100644 Binary files a/docs/screenshots/mobile/child-mealplan.png and b/docs/screenshots/mobile/child-mealplan.png differ diff --git a/docs/screenshots/mobile/child-rewards.png b/docs/screenshots/mobile/child-rewards.png index d906881c..3872e1d9 100644 Binary files a/docs/screenshots/mobile/child-rewards.png and b/docs/screenshots/mobile/child-rewards.png differ diff --git a/docs/screenshots/mobile/child-timer.png b/docs/screenshots/mobile/child-timer.png index e12f6112..b1d7bb65 100644 Binary files a/docs/screenshots/mobile/child-timer.png and b/docs/screenshots/mobile/child-timer.png differ diff --git a/docs/screenshots/mobile/guardian-admin.png b/docs/screenshots/mobile/guardian-admin.png index 3ca6a37a..8b993b5e 100644 Binary files a/docs/screenshots/mobile/guardian-admin.png and b/docs/screenshots/mobile/guardian-admin.png differ diff --git a/docs/screenshots/mobile/guardian-calendar.png b/docs/screenshots/mobile/guardian-calendar.png index 8cb36e92..6b42e4f1 100644 Binary files a/docs/screenshots/mobile/guardian-calendar.png and b/docs/screenshots/mobile/guardian-calendar.png differ diff --git a/docs/screenshots/mobile/guardian-dashboard.png b/docs/screenshots/mobile/guardian-dashboard.png index 4b73c547..2f573236 100644 Binary files a/docs/screenshots/mobile/guardian-dashboard.png and b/docs/screenshots/mobile/guardian-dashboard.png differ diff --git a/docs/screenshots/mobile/guardian-feelings.png b/docs/screenshots/mobile/guardian-feelings.png index b37b7415..18e2077c 100644 Binary files a/docs/screenshots/mobile/guardian-feelings.png and b/docs/screenshots/mobile/guardian-feelings.png differ diff --git a/docs/screenshots/mobile/guardian-help.png b/docs/screenshots/mobile/guardian-help.png index 08dee982..4bc9816f 100644 Binary files a/docs/screenshots/mobile/guardian-help.png and b/docs/screenshots/mobile/guardian-help.png differ diff --git a/docs/screenshots/mobile/guardian-mealplan-ai-assistant.png b/docs/screenshots/mobile/guardian-mealplan-ai-assistant.png index 20844f41..5eb309be 100644 Binary files a/docs/screenshots/mobile/guardian-mealplan-ai-assistant.png and b/docs/screenshots/mobile/guardian-mealplan-ai-assistant.png differ diff --git a/docs/screenshots/mobile/guardian-mealplan.png b/docs/screenshots/mobile/guardian-mealplan.png index b94b3165..bac14791 100644 Binary files a/docs/screenshots/mobile/guardian-mealplan.png and b/docs/screenshots/mobile/guardian-mealplan.png differ diff --git a/docs/screenshots/mobile/guardian-medicine-effects.png b/docs/screenshots/mobile/guardian-medicine-effects.png index 9dae2de3..d81252ad 100644 Binary files a/docs/screenshots/mobile/guardian-medicine-effects.png and b/docs/screenshots/mobile/guardian-medicine-effects.png differ diff --git a/docs/screenshots/mobile/guardian-medicine.png b/docs/screenshots/mobile/guardian-medicine.png index 379d4392..5339f7e3 100644 Binary files a/docs/screenshots/mobile/guardian-medicine.png and b/docs/screenshots/mobile/guardian-medicine.png differ diff --git a/docs/screenshots/mobile/guardian-night-duty.png b/docs/screenshots/mobile/guardian-night-duty.png new file mode 100644 index 00000000..70e4fea8 Binary files /dev/null and b/docs/screenshots/mobile/guardian-night-duty.png differ diff --git a/docs/screenshots/mobile/guardian-pickup.png b/docs/screenshots/mobile/guardian-pickup.png index 51eaf8b3..49349b32 100644 Binary files a/docs/screenshots/mobile/guardian-pickup.png and b/docs/screenshots/mobile/guardian-pickup.png differ diff --git a/docs/screenshots/mobile/guardian-print-sheet.png b/docs/screenshots/mobile/guardian-print-sheet.png index 824bf6cd..0e966197 100644 Binary files a/docs/screenshots/mobile/guardian-print-sheet.png and b/docs/screenshots/mobile/guardian-print-sheet.png differ diff --git a/docs/screenshots/mobile/guardian-print-template-editor.png b/docs/screenshots/mobile/guardian-print-template-editor.png index d7fedb38..2685e111 100644 Binary files a/docs/screenshots/mobile/guardian-print-template-editor.png and b/docs/screenshots/mobile/guardian-print-template-editor.png differ diff --git a/docs/screenshots/mobile/guardian-progress.png b/docs/screenshots/mobile/guardian-progress.png index 79f18ead..989f3bf8 100644 Binary files a/docs/screenshots/mobile/guardian-progress.png and b/docs/screenshots/mobile/guardian-progress.png differ diff --git a/docs/screenshots/mobile/guardian-sleep-diary.png b/docs/screenshots/mobile/guardian-sleep-diary.png index c9549df1..3482e0b3 100644 Binary files a/docs/screenshots/mobile/guardian-sleep-diary.png and b/docs/screenshots/mobile/guardian-sleep-diary.png differ diff --git a/docs/screenshots/mobile/guardian-visit-summaries.png b/docs/screenshots/mobile/guardian-visit-summaries.png index b9ec8ee3..0b357b60 100644 Binary files a/docs/screenshots/mobile/guardian-visit-summaries.png and b/docs/screenshots/mobile/guardian-visit-summaries.png differ diff --git a/docs/screenshots/mobile/guardian-visit-summary-print.png b/docs/screenshots/mobile/guardian-visit-summary-print.png index a38f22dc..adfa5d06 100644 Binary files a/docs/screenshots/mobile/guardian-visit-summary-print.png and b/docs/screenshots/mobile/guardian-visit-summary-print.png differ diff --git a/docs/screenshots/mobile/guardian-visit-summary.png b/docs/screenshots/mobile/guardian-visit-summary.png index 1f0f1211..2e76bca5 100644 Binary files a/docs/screenshots/mobile/guardian-visit-summary.png and b/docs/screenshots/mobile/guardian-visit-summary.png differ diff --git a/docs/screenshots/mobile/guardian-work-locations.png b/docs/screenshots/mobile/guardian-work-locations.png index 3e0f1ad4..75499596 100644 Binary files a/docs/screenshots/mobile/guardian-work-locations.png and b/docs/screenshots/mobile/guardian-work-locations.png differ diff --git a/docs/screenshots/mobile/shared-feelings.png b/docs/screenshots/mobile/shared-feelings.png index faa81801..914283ee 100644 Binary files a/docs/screenshots/mobile/shared-feelings.png and b/docs/screenshots/mobile/shared-feelings.png differ diff --git a/docs/screenshots/mobile/shared-sleep-diary.png b/docs/screenshots/mobile/shared-sleep-diary.png index f9483848..69bad6ff 100644 Binary files a/docs/screenshots/mobile/shared-sleep-diary.png and b/docs/screenshots/mobile/shared-sleep-diary.png differ diff --git a/docs/screenshots/mobile/shared-visit-summary.png b/docs/screenshots/mobile/shared-visit-summary.png index e6ccb8d4..e1ac36d1 100644 Binary files a/docs/screenshots/mobile/shared-visit-summary.png and b/docs/screenshots/mobile/shared-visit-summary.png differ diff --git a/docs/screenshots/shared-feelings.png b/docs/screenshots/shared-feelings.png index 5ec40902..04b22983 100644 Binary files a/docs/screenshots/shared-feelings.png and b/docs/screenshots/shared-feelings.png differ diff --git a/docs/screenshots/shared-sleep-diary.png b/docs/screenshots/shared-sleep-diary.png index a6120410..94cd88fc 100644 Binary files a/docs/screenshots/shared-sleep-diary.png and b/docs/screenshots/shared-sleep-diary.png differ diff --git a/docs/screenshots/shared-visit-summary.png b/docs/screenshots/shared-visit-summary.png index 4a8c34ce..526b0f7d 100644 Binary files a/docs/screenshots/shared-visit-summary.png and b/docs/screenshots/shared-visit-summary.png differ diff --git a/docs/screenshots/tablet/child-calendar.png b/docs/screenshots/tablet/child-calendar.png index e14d20f2..cb0f0470 100644 Binary files a/docs/screenshots/tablet/child-calendar.png and b/docs/screenshots/tablet/child-calendar.png differ diff --git a/docs/screenshots/tablet/child-first-then.png b/docs/screenshots/tablet/child-first-then.png index 9fe36a8f..99e0cfbb 100644 Binary files a/docs/screenshots/tablet/child-first-then.png and b/docs/screenshots/tablet/child-first-then.png differ diff --git a/docs/screenshots/tablet/child-home.png b/docs/screenshots/tablet/child-home.png index 9eb700f5..aa49e2de 100644 Binary files a/docs/screenshots/tablet/child-home.png and b/docs/screenshots/tablet/child-home.png differ diff --git a/docs/screenshots/tablet/child-homework.png b/docs/screenshots/tablet/child-homework.png index a2b0a5c6..432e661d 100644 Binary files a/docs/screenshots/tablet/child-homework.png and b/docs/screenshots/tablet/child-homework.png differ diff --git a/docs/screenshots/tablet/child-mealplan.png b/docs/screenshots/tablet/child-mealplan.png index 36890059..d668f3ef 100644 Binary files a/docs/screenshots/tablet/child-mealplan.png and b/docs/screenshots/tablet/child-mealplan.png differ diff --git a/docs/screenshots/tablet/child-rewards.png b/docs/screenshots/tablet/child-rewards.png index 46e29390..347b32b7 100644 Binary files a/docs/screenshots/tablet/child-rewards.png and b/docs/screenshots/tablet/child-rewards.png differ diff --git a/docs/screenshots/tablet/child-timer.png b/docs/screenshots/tablet/child-timer.png index d5154c19..82e7bb4d 100644 Binary files a/docs/screenshots/tablet/child-timer.png and b/docs/screenshots/tablet/child-timer.png differ diff --git a/docs/screenshots/tablet/guardian-admin.png b/docs/screenshots/tablet/guardian-admin.png index 10a749b5..fca4a4e9 100644 Binary files a/docs/screenshots/tablet/guardian-admin.png and b/docs/screenshots/tablet/guardian-admin.png differ diff --git a/docs/screenshots/tablet/guardian-calendar.png b/docs/screenshots/tablet/guardian-calendar.png index 7ca51eec..c52d7af8 100644 Binary files a/docs/screenshots/tablet/guardian-calendar.png and b/docs/screenshots/tablet/guardian-calendar.png differ diff --git a/docs/screenshots/tablet/guardian-dashboard.png b/docs/screenshots/tablet/guardian-dashboard.png index 4695d26e..5ca1d28e 100644 Binary files a/docs/screenshots/tablet/guardian-dashboard.png and b/docs/screenshots/tablet/guardian-dashboard.png differ diff --git a/docs/screenshots/tablet/guardian-feelings.png b/docs/screenshots/tablet/guardian-feelings.png index e0dd99c3..ca69cdf0 100644 Binary files a/docs/screenshots/tablet/guardian-feelings.png and b/docs/screenshots/tablet/guardian-feelings.png differ diff --git a/docs/screenshots/tablet/guardian-help.png b/docs/screenshots/tablet/guardian-help.png index 29f8a287..f414b12a 100644 Binary files a/docs/screenshots/tablet/guardian-help.png and b/docs/screenshots/tablet/guardian-help.png differ diff --git a/docs/screenshots/tablet/guardian-mealplan-ai-assistant.png b/docs/screenshots/tablet/guardian-mealplan-ai-assistant.png index 90d0b29f..4651db45 100644 Binary files a/docs/screenshots/tablet/guardian-mealplan-ai-assistant.png and b/docs/screenshots/tablet/guardian-mealplan-ai-assistant.png differ diff --git a/docs/screenshots/tablet/guardian-mealplan.png b/docs/screenshots/tablet/guardian-mealplan.png index 4b078dc5..2c39eb37 100644 Binary files a/docs/screenshots/tablet/guardian-mealplan.png and b/docs/screenshots/tablet/guardian-mealplan.png differ diff --git a/docs/screenshots/tablet/guardian-medicine-effects.png b/docs/screenshots/tablet/guardian-medicine-effects.png index af829938..83a7acbb 100644 Binary files a/docs/screenshots/tablet/guardian-medicine-effects.png and b/docs/screenshots/tablet/guardian-medicine-effects.png differ diff --git a/docs/screenshots/tablet/guardian-medicine.png b/docs/screenshots/tablet/guardian-medicine.png index fab8323a..aec558cb 100644 Binary files a/docs/screenshots/tablet/guardian-medicine.png and b/docs/screenshots/tablet/guardian-medicine.png differ diff --git a/docs/screenshots/tablet/guardian-night-duty.png b/docs/screenshots/tablet/guardian-night-duty.png new file mode 100644 index 00000000..0dd6e189 Binary files /dev/null and b/docs/screenshots/tablet/guardian-night-duty.png differ diff --git a/docs/screenshots/tablet/guardian-pickup.png b/docs/screenshots/tablet/guardian-pickup.png index 4f0cffbb..be75c0af 100644 Binary files a/docs/screenshots/tablet/guardian-pickup.png and b/docs/screenshots/tablet/guardian-pickup.png differ diff --git a/docs/screenshots/tablet/guardian-print-sheet.png b/docs/screenshots/tablet/guardian-print-sheet.png index 3f25bc7a..7cf1e033 100644 Binary files a/docs/screenshots/tablet/guardian-print-sheet.png and b/docs/screenshots/tablet/guardian-print-sheet.png differ diff --git a/docs/screenshots/tablet/guardian-print-template-editor.png b/docs/screenshots/tablet/guardian-print-template-editor.png index 8a733a2b..3d9e1565 100644 Binary files a/docs/screenshots/tablet/guardian-print-template-editor.png and b/docs/screenshots/tablet/guardian-print-template-editor.png differ diff --git a/docs/screenshots/tablet/guardian-progress.png b/docs/screenshots/tablet/guardian-progress.png index d2d6c81d..ddaa5493 100644 Binary files a/docs/screenshots/tablet/guardian-progress.png and b/docs/screenshots/tablet/guardian-progress.png differ diff --git a/docs/screenshots/tablet/guardian-sleep-diary.png b/docs/screenshots/tablet/guardian-sleep-diary.png index e1a72f23..d68b494e 100644 Binary files a/docs/screenshots/tablet/guardian-sleep-diary.png and b/docs/screenshots/tablet/guardian-sleep-diary.png differ diff --git a/docs/screenshots/tablet/guardian-visit-summaries.png b/docs/screenshots/tablet/guardian-visit-summaries.png index c6f04eb4..d1a007cb 100644 Binary files a/docs/screenshots/tablet/guardian-visit-summaries.png and b/docs/screenshots/tablet/guardian-visit-summaries.png differ diff --git a/docs/screenshots/tablet/guardian-visit-summary-print.png b/docs/screenshots/tablet/guardian-visit-summary-print.png index 380addb4..950bbc82 100644 Binary files a/docs/screenshots/tablet/guardian-visit-summary-print.png and b/docs/screenshots/tablet/guardian-visit-summary-print.png differ diff --git a/docs/screenshots/tablet/guardian-visit-summary.png b/docs/screenshots/tablet/guardian-visit-summary.png index 4ac5ffe9..def2724d 100644 Binary files a/docs/screenshots/tablet/guardian-visit-summary.png and b/docs/screenshots/tablet/guardian-visit-summary.png differ diff --git a/docs/screenshots/tablet/guardian-work-locations.png b/docs/screenshots/tablet/guardian-work-locations.png index 9922f9da..7a173dad 100644 Binary files a/docs/screenshots/tablet/guardian-work-locations.png and b/docs/screenshots/tablet/guardian-work-locations.png differ diff --git a/docs/screenshots/tablet/shared-feelings.png b/docs/screenshots/tablet/shared-feelings.png index c1e1144e..8c3cd981 100644 Binary files a/docs/screenshots/tablet/shared-feelings.png and b/docs/screenshots/tablet/shared-feelings.png differ diff --git a/docs/screenshots/tablet/shared-sleep-diary.png b/docs/screenshots/tablet/shared-sleep-diary.png index dda130fa..cd2d6adf 100644 Binary files a/docs/screenshots/tablet/shared-sleep-diary.png and b/docs/screenshots/tablet/shared-sleep-diary.png differ diff --git a/docs/screenshots/tablet/shared-visit-summary.png b/docs/screenshots/tablet/shared-visit-summary.png index 24ac0086..966feda7 100644 Binary files a/docs/screenshots/tablet/shared-visit-summary.png and b/docs/screenshots/tablet/shared-visit-summary.png differ diff --git a/src/backend/buddy.IntegrationTests/Common/FeatureFlags/FeatureFlagsTests.cs b/src/backend/buddy.IntegrationTests/Common/FeatureFlags/FeatureFlagsTests.cs index ef93055a..0c997425 100644 --- a/src/backend/buddy.IntegrationTests/Common/FeatureFlags/FeatureFlagsTests.cs +++ b/src/backend/buddy.IntegrationTests/Common/FeatureFlags/FeatureFlagsTests.cs @@ -38,6 +38,7 @@ private Task AllOffAsync() => ["Features:Pickups"] = "false", ["Features:Babysitters"] = "false", ["Features:WorkLocations"] = "false", + ["Features:NightDuty"] = "false", ["Features:Printing"] = "false", ["Features:Progress"] = "false", ["Features:TaskLibrary"] = "false", @@ -63,7 +64,7 @@ public async Task An_anonymous_caller_sees_every_feature_on_by_default() { var flags = await GetFeaturesAsync(fixture.Host); - Assert.Equal(new InstallationFeatures(true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true), flags); + Assert.Equal(new InstallationFeatures(true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true), flags); } [Fact] @@ -71,9 +72,9 @@ public async Task The_flags_endpoint_reports_disabled_features_and_sub_flags_fol { var flags = await GetFeaturesAsync(await AllOffAsync()); - Assert.Equal(new InstallationFeatures(false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false), flags); + Assert.Equal(new InstallationFeatures(false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false), flags); Assert.Equal( - new InstallationFeatures(true, false, false, false, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true), + new InstallationFeatures(true, false, false, false, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true), await GetFeaturesAsync(await MealplanExtrasOffAsync())); } @@ -95,6 +96,7 @@ public async Task The_flags_are_serialized_with_camel_case_names() Assert.True(json.RootElement.GetProperty("firstThen").GetBoolean()); Assert.True(json.RootElement.GetProperty("visitSummaries").GetBoolean()); Assert.True(json.RootElement.GetProperty("homework").GetBoolean()); + Assert.True(json.RootElement.GetProperty("nightDuty").GetBoolean()); } [Fact] @@ -114,6 +116,7 @@ public async Task Every_disabled_feature_answers_404_while_core_features_keep_wo $"/pickups/children/{child.Id}/schedule?from=2026-03-02&to=2026-03-08", "/babysitters/me", $"/work-locations/guardians/{guardianId}", + $"/night-duty/children/{child.Id}", "/print-templates/", $"/progress/children/{child.Id}", $"/task-templates/children/{child.Id}", diff --git a/src/backend/buddy.IntegrationTests/Common/FeatureFlags/FeatureOptionsTests.cs b/src/backend/buddy.IntegrationTests/Common/FeatureFlags/FeatureOptionsTests.cs index ca42a432..b7282c39 100644 --- a/src/backend/buddy.IntegrationTests/Common/FeatureFlags/FeatureOptionsTests.cs +++ b/src/backend/buddy.IntegrationTests/Common/FeatureFlags/FeatureOptionsTests.cs @@ -22,7 +22,7 @@ public void No_features_section_turns_every_feature_on() using var provider = services.BuildServiceProvider(); Assert.Equal( - new InstallationFeatures(true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true), + new InstallationFeatures(true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true), provider.GetRequiredService>().Value.Effective()); } diff --git a/src/backend/buddy.IntegrationTests/Common/Postgres/PostgresDataSourceTests.cs b/src/backend/buddy.IntegrationTests/Common/Postgres/PostgresDataSourceTests.cs index 5a977993..033f5fd6 100644 --- a/src/backend/buddy.IntegrationTests/Common/Postgres/PostgresDataSourceTests.cs +++ b/src/backend/buddy.IntegrationTests/Common/Postgres/PostgresDataSourceTests.cs @@ -9,6 +9,7 @@ using buddy.Features.HouseRules; using buddy.Features.Mealplans; using buddy.Features.Medicines; +using buddy.Features.NightDuty; using buddy.Features.Pickups; using buddy.Features.PrintTemplates; using buddy.Features.Progress; @@ -94,6 +95,7 @@ public sealed class PostgresDataSourceHostTests(BuddyApiFixture fixture) typeof(IMealplansStore), typeof(IPickupsStore), typeof(IWorkLocationsStore), + typeof(INightDutyStore), typeof(IBabysittersStore), typeof(IPrintTemplatesStore), typeof(IProgressStore), diff --git a/src/backend/buddy.IntegrationTests/EventShapeTests/EventShapeTestSupport.cs b/src/backend/buddy.IntegrationTests/EventShapeTests/EventShapeTestSupport.cs index 086411b0..83cfdfe9 100644 --- a/src/backend/buddy.IntegrationTests/EventShapeTests/EventShapeTestSupport.cs +++ b/src/backend/buddy.IntegrationTests/EventShapeTests/EventShapeTestSupport.cs @@ -2,6 +2,7 @@ using System.Text.Json.Serialization; using buddy.Features.Calendars; +using buddy.Features.NightDuty; using buddy.Features.Pickups; using buddy.Features.Users; using buddy.Features.WorkLocations; @@ -23,7 +24,8 @@ internal static class EventShapeTestSupport // options.UseSystemTextJsonForSerialization(enumStorage: EnumStorage.AsString, ...) for their // Marten stores -- enums as their name, strongly-typed ids unwrapped to their raw value, and // the union of the extra converters individual stores register: ValueTuples (Medicines, - // Mealplans, Pickups), PickupAssignee (Pickups), CompletionTarget (Calendars, Progress), Recurrence (Calendars) EmailVerification (Users, snapshot only) and WorkDayOverride (WorkLocations). + // Mealplans, Pickups), PickupAssignee (Pickups), CompletionTarget (Calendars, Progress), Recurrence (Calendars) EmailVerification (Users, snapshot only), WorkDayOverride (WorkLocations), and NightResponsible + // and NightOverride (NightDuty). // Each extra converter only handles its own type, so registering all of them here can't change // the shape of an event from a store that doesn't register it. public static JsonSerializerOptions CreateEventSerializerOptions() => new() @@ -37,7 +39,9 @@ internal static class EventShapeTestSupport new CompletionTargetJsonConverter(), new RecurrenceJsonConverter(), new EmailVerificationJsonConverter(), - new WorkDayOverrideJsonConverter() + new WorkDayOverrideJsonConverter(), + new NightResponsibleJsonConverter(), + new NightOverrideJsonConverter() } }; @@ -47,7 +51,7 @@ public static void AssertMatchesGoldenFile(TEvent @event, string goldenF var actual = JsonSerializer.Serialize(@event, options); var actualFormatted = Reformat(actual, options); - var goldenPath = Path.Combine(AppContext.BaseDirectory, "EventShapeTests", "GoldenFiles", goldenFileName); + var goldenPath = Path.Join(AppContext.BaseDirectory, "EventShapeTests", "GoldenFiles", goldenFileName); if (!File.Exists(goldenPath)) { @@ -66,12 +70,22 @@ public static void AssertMatchesGoldenFile(TEvent @event, string goldenF // different value still fails. Only for events whose equality is structural (no collections). public static void AssertGoldenFileReadsBackAs(TEvent expected, string goldenFileName) { - var goldenPath = Path.Combine(AppContext.BaseDirectory, "EventShapeTests", "GoldenFiles", goldenFileName); + var goldenPath = Path.Join(AppContext.BaseDirectory, "EventShapeTests", "GoldenFiles", goldenFileName); var actual = JsonSerializer.Deserialize(File.ReadAllText(goldenPath), CreateEventSerializerOptions()); Assert.Equal(expected, actual); } + // For events holding collections (records compare those by reference): the test asserts on + // what reads back itself. + public static TEvent ReadGoldenFile(string goldenFileName) + { + var goldenPath = Path.Join(AppContext.BaseDirectory, "EventShapeTests", "GoldenFiles", goldenFileName); + + return JsonSerializer.Deserialize(File.ReadAllText(goldenPath), CreateEventSerializerOptions()) + ?? throw new InvalidOperationException($"Golden file {goldenFileName} deserialized to null."); + } + private static string Reformat(string json, JsonSerializerOptions options) { using var document = JsonDocument.Parse(json); diff --git a/src/backend/buddy.IntegrationTests/EventShapeTests/GoldenFiles/NightDuty/NightOverridden_Cleared.json b/src/backend/buddy.IntegrationTests/EventShapeTests/GoldenFiles/NightDuty/NightOverridden_Cleared.json new file mode 100644 index 00000000..fc8a5b1a --- /dev/null +++ b/src/backend/buddy.IntegrationTests/EventShapeTests/GoldenFiles/NightDuty/NightOverridden_Cleared.json @@ -0,0 +1,9 @@ +{ + "Id": "00000000-0000-0000-0000-000000000002", + "Date": "2025-06-02", + "Override": { + "Kind": "Cleared" + }, + "ModifiedBy": "00000000-0000-0000-0000-000000000001", + "OccurredAt": "2025-01-01T12:00:00\u002B00:00" +} diff --git a/src/backend/buddy.IntegrationTests/EventShapeTests/GoldenFiles/NightDuty/NightOverridden_Planned.json b/src/backend/buddy.IntegrationTests/EventShapeTests/GoldenFiles/NightDuty/NightOverridden_Planned.json new file mode 100644 index 00000000..290cf900 --- /dev/null +++ b/src/backend/buddy.IntegrationTests/EventShapeTests/GoldenFiles/NightDuty/NightOverridden_Planned.json @@ -0,0 +1,16 @@ +{ + "Id": "00000000-0000-0000-0000-000000000002", + "Date": "2025-06-02", + "Override": { + "Kind": "Planned", + "Entry": { + "Responsible": { + "Kind": "Guardian", + "GuardianId": "00000000-0000-0000-0000-000000000001" + }, + "Note": "Screen night" + } + }, + "ModifiedBy": "00000000-0000-0000-0000-000000000001", + "OccurredAt": "2025-01-01T12:00:00\u002B00:00" +} diff --git a/src/backend/buddy.IntegrationTests/EventShapeTests/GoldenFiles/NightDuty/NightOverrideCleared.json b/src/backend/buddy.IntegrationTests/EventShapeTests/GoldenFiles/NightDuty/NightOverrideCleared.json new file mode 100644 index 00000000..296cfc8f --- /dev/null +++ b/src/backend/buddy.IntegrationTests/EventShapeTests/GoldenFiles/NightDuty/NightOverrideCleared.json @@ -0,0 +1,16 @@ +{ + "Id": "00000000-0000-0000-0000-000000000002", + "Date": "2025-06-02", + "Before": { + "Kind": "Planned", + "Entry": { + "Responsible": { + "Kind": "Guardian", + "GuardianId": "00000000-0000-0000-0000-000000000001" + }, + "Note": "" + } + }, + "ModifiedBy": "00000000-0000-0000-0000-000000000001", + "OccurredAt": "2025-01-01T12:00:00\u002B00:00" +} diff --git a/src/backend/buddy.IntegrationTests/EventShapeTests/GoldenFiles/NightDuty/NightPatternReplaced.json b/src/backend/buddy.IntegrationTests/EventShapeTests/GoldenFiles/NightDuty/NightPatternReplaced.json new file mode 100644 index 00000000..2d0eea96 --- /dev/null +++ b/src/backend/buddy.IntegrationTests/EventShapeTests/GoldenFiles/NightDuty/NightPatternReplaced.json @@ -0,0 +1,58 @@ +{ + "Id": "00000000-0000-0000-0000-000000000002", + "Pattern": { + "EffectiveFrom": "2025-06-02", + "CycleWeeks": 2, + "AnchorMonday": "2024-12-30", + "Days": [ + { + "Week": 0, + "Day": "Monday", + "Entry": { + "Responsible": { + "Kind": "Guardian", + "GuardianId": "00000000-0000-0000-0000-000000000001" + }, + "Note": "" + } + }, + { + "Week": 0, + "Day": "Tuesday", + "Entry": { + "Responsible": { + "Kind": "Babysitter", + "GuardianId": "00000000-0000-0000-0000-000000000001", + "BabysitterId": "00000000-0000-0000-0000-000000000090" + }, + "Note": "" + } + }, + { + "Week": 0, + "Day": "Friday", + "Entry": { + "Responsible": { + "Kind": "Unassigned" + }, + "Note": "Screen night \uD83D\uDCF1" + } + }, + { + "Week": 1, + "Day": "Saturday", + "Entry": { + "Responsible": { + "Kind": "Sleepover", + "HostName": "Grandma Lis", + "Location": "Skovvej 12", + "ContactInfo": "20 12 34 56" + }, + "Note": "" + } + } + ] + }, + "ModifiedBy": "00000000-0000-0000-0000-000000000001", + "OccurredAt": "2025-01-01T12:00:00\u002B00:00" +} diff --git a/src/backend/buddy.IntegrationTests/EventShapeTests/GoldenFiles/NightDuty/NightScheduleStarted.json b/src/backend/buddy.IntegrationTests/EventShapeTests/GoldenFiles/NightDuty/NightScheduleStarted.json new file mode 100644 index 00000000..f9e90f3b --- /dev/null +++ b/src/backend/buddy.IntegrationTests/EventShapeTests/GoldenFiles/NightDuty/NightScheduleStarted.json @@ -0,0 +1,5 @@ +{ + "Id": "00000000-0000-0000-0000-000000000002", + "ChildId": "00000000-0000-0000-0000-000000000002", + "OccurredAt": "2025-01-01T12:00:00\u002B00:00" +} diff --git a/src/backend/buddy.IntegrationTests/EventShapeTests/NightDutyEventShapeTests.cs b/src/backend/buddy.IntegrationTests/EventShapeTests/NightDutyEventShapeTests.cs new file mode 100644 index 00000000..214c7392 --- /dev/null +++ b/src/backend/buddy.IntegrationTests/EventShapeTests/NightDutyEventShapeTests.cs @@ -0,0 +1,77 @@ +using buddy.Features.Babysitters; +using buddy.Features.NightDuty; +using buddy.Features.Users; + +using Xunit; + +namespace buddy.IntegrationTests.EventShapeTests; + +public sealed class NightDutyEventShapeTests +{ + private static readonly NightScheduleId FixedScheduleId = new(Guid.Parse("00000000-0000-0000-0000-000000000002")); + private static readonly UserId FixedChildId = new(Guid.Parse("00000000-0000-0000-0000-000000000002")); + private static readonly UserId FixedGuardianId = new(Guid.Parse("00000000-0000-0000-0000-000000000001")); + private static readonly BabysitterId FixedBabysitterId = new(Guid.Parse("00000000-0000-0000-0000-000000000090")); + private static readonly DateTimeOffset FixedInstant = new(2025, 1, 1, 12, 0, 0, TimeSpan.Zero); + private static readonly DateOnly FixedDate = new(2025, 6, 2); + private static readonly DateOnly FixedMonday = new(2024, 12, 30); + + private static readonly NightEntry GuardianEntry = new(new NightResponsible.Guardian(FixedGuardianId), ""); + + [Fact] + public void NightScheduleStarted() => EventShapeTestSupport.AssertMatchesGoldenFile( + new NightScheduleStarted(FixedScheduleId, FixedChildId, FixedInstant), + "NightDuty/NightScheduleStarted.json"); + + // Every NightResponsible case appears once, so this file pins all four persisted shapes. + [Fact] + public void NightPatternReplaced() => EventShapeTestSupport.AssertMatchesGoldenFile( + new NightPatternReplaced( + FixedScheduleId, + new NightPattern(FixedDate, 2, FixedMonday, + [ + new NightPatternDay(0, DayOfWeek.Monday, GuardianEntry), + new NightPatternDay(0, DayOfWeek.Tuesday, new NightEntry(new NightResponsible.Babysitter(FixedGuardianId, FixedBabysitterId), "")), + new NightPatternDay(0, DayOfWeek.Friday, new NightEntry(new NightResponsible.Unassigned(), "Screen night 📱")), + new NightPatternDay(1, DayOfWeek.Saturday, new NightEntry(new NightResponsible.Sleepover("Grandma Lis", "Skovvej 12", "20 12 34 56"), "")) + ]), + FixedGuardianId, + FixedInstant), + "NightDuty/NightPatternReplaced.json"); + + [Fact] + public void NightPatternReplaced_ReadsBack() => Assert.True( + EventShapeTestSupport.ReadGoldenFile("NightDuty/NightPatternReplaced.json").Pattern.IsSameAs( + new NightPattern(FixedDate, 2, FixedMonday, + [ + new NightPatternDay(0, DayOfWeek.Monday, GuardianEntry), + new NightPatternDay(0, DayOfWeek.Tuesday, new NightEntry(new NightResponsible.Babysitter(FixedGuardianId, FixedBabysitterId), "")), + new NightPatternDay(0, DayOfWeek.Friday, new NightEntry(new NightResponsible.Unassigned(), "Screen night 📱")), + new NightPatternDay(1, DayOfWeek.Saturday, new NightEntry(new NightResponsible.Sleepover("Grandma Lis", "Skovvej 12", "20 12 34 56"), "")) + ]))); + + [Fact] + public void NightOverridden_planned() => EventShapeTestSupport.AssertMatchesGoldenFile( + new NightOverridden(FixedScheduleId, FixedDate, new NightOverride.Planned(GuardianEntry with { Note = "Screen night" }), FixedGuardianId, FixedInstant), + "NightDuty/NightOverridden_Planned.json"); + + [Fact] + public void NightOverridden_planned_ReadsBack() => EventShapeTestSupport.AssertGoldenFileReadsBackAs( + new NightOverridden(FixedScheduleId, FixedDate, new NightOverride.Planned(GuardianEntry with { Note = "Screen night" }), FixedGuardianId, FixedInstant), + "NightDuty/NightOverridden_Planned.json"); + + [Fact] + public void NightOverridden_cleared() => EventShapeTestSupport.AssertMatchesGoldenFile( + new NightOverridden(FixedScheduleId, FixedDate, new NightOverride.Cleared(), FixedGuardianId, FixedInstant), + "NightDuty/NightOverridden_Cleared.json"); + + [Fact] + public void NightOverridden_cleared_ReadsBack() => EventShapeTestSupport.AssertGoldenFileReadsBackAs( + new NightOverridden(FixedScheduleId, FixedDate, new NightOverride.Cleared(), FixedGuardianId, FixedInstant), + "NightDuty/NightOverridden_Cleared.json"); + + [Fact] + public void NightOverrideCleared() => EventShapeTestSupport.AssertMatchesGoldenFile( + new NightOverrideCleared(FixedScheduleId, FixedDate, new NightOverride.Planned(GuardianEntry), FixedGuardianId, FixedInstant), + "NightDuty/NightOverrideCleared.json"); +} diff --git a/src/backend/buddy.IntegrationTests/Features/NightDuty/ClearNightOverrides/ClearNightOverridesTests.cs b/src/backend/buddy.IntegrationTests/Features/NightDuty/ClearNightOverrides/ClearNightOverridesTests.cs new file mode 100644 index 00000000..db1897a4 --- /dev/null +++ b/src/backend/buddy.IntegrationTests/Features/NightDuty/ClearNightOverrides/ClearNightOverridesTests.cs @@ -0,0 +1,56 @@ +using buddy.Features.NightDuty; +using buddy.IntegrationTests.Features.Guardians; +using buddy.IntegrationTests.Fixtures; +using buddy.IntegrationTests.Meta; + +using Xunit; + +using static buddy.IntegrationTests.Features.NightDuty.NightDutyTestHelpers; + +namespace buddy.IntegrationTests.Features.NightDuty.ClearNightOverrides; + +[Collection(BuddyApiCollection.Name)] +public sealed class ClearNightOverridesTests(BuddyApiFixture fixture) +{ + [Fact] + [CoversEndpoint("ClearNightOverrides")] + public async Task Clearing_an_override_falls_back_to_the_schedule() + { + var (_, token, guardianId) = await fixture.CreateAuthenticatedUserAsync(); + var child = await GuardianTestHelpers.CreateChildAsync(fixture, token); + var monday = FutureMonday(); + await ReplacePatternAsync(fixture, token, child.Id, monday, 1, monday, Day(0, DayOfWeek.Monday, Guardian(guardianId))); + await SetOverridesAsync(fixture, token, child.Id, Override(monday, entry: null), Override(monday.AddDays(1), NoteOnly("Sleepover party"))); + + await ClearOverridesAsync(fixture, token, child.Id, monday, monday.AddDays(1)); + + var nights = await ListNightsAsync(fixture, token, child.Id, monday, monday.AddDays(1)); + Assert.Equal(NightSource.Pattern, nights[0].Status.Source); + Assert.Equal(guardianId, nights[0].Status.Entry!.Responsible.GuardianId); + Assert.Equal(KindUnplanned, nights[1].Status.Kind); + } + + [Fact] + public async Task Clearing_a_range_without_overrides_is_a_no_op() + { + var (_, token, _) = await fixture.CreateAuthenticatedUserAsync(); + var child = await GuardianTestHelpers.CreateChildAsync(fixture, token); + + await ClearOverridesAsync(fixture, token, child.Id, FutureMonday(), FutureMonday().AddDays(6)); + } + + [Fact] + public async Task An_invalid_range_is_rejected_the_child_is_forbidden_and_a_stranger_gets_not_found() + { + var (_, token, _) = await fixture.CreateAuthenticatedUserAsync(); + var child = await GuardianTestHelpers.CreateChildAsync(fixture, token); + var childToken = await GuardianTestHelpers.CompleteChildLoginAsync(fixture, child); + var (_, strangerToken, _) = await fixture.CreateAuthenticatedUserAsync(); + var monday = FutureMonday(); + + await ClearOverridesAsync(fixture, token, child.Id, monday, monday.AddDays(-1), 400); + await ClearOverridesAsync(fixture, token, child.Id, monday, monday.AddDays(32), 400); + await ClearOverridesAsync(fixture, childToken, child.Id, monday, monday, 403); + await ClearOverridesAsync(fixture, strangerToken, child.Id, monday, monday, 404); + } +} diff --git a/src/backend/buddy.IntegrationTests/Features/NightDuty/GetNightSchedule/GetNightScheduleTests.cs b/src/backend/buddy.IntegrationTests/Features/NightDuty/GetNightSchedule/GetNightScheduleTests.cs new file mode 100644 index 00000000..681ed63e --- /dev/null +++ b/src/backend/buddy.IntegrationTests/Features/NightDuty/GetNightSchedule/GetNightScheduleTests.cs @@ -0,0 +1,53 @@ +using buddy.Features.NightDuty; +using buddy.IntegrationTests.Features.Guardians; +using buddy.IntegrationTests.Fixtures; +using buddy.IntegrationTests.Meta; + +using Xunit; + +using static buddy.IntegrationTests.Features.NightDuty.NightDutyTestHelpers; + +namespace buddy.IntegrationTests.Features.NightDuty.GetNightSchedule; + +[Collection(BuddyApiCollection.Name)] +public sealed class GetNightScheduleTests(BuddyApiFixture fixture) +{ + [Fact] + [CoversEndpoint("GetNightSchedule")] + public async Task A_guardian_sees_every_version_flagged_current_or_upcoming() + { + var (_, token, guardianId) = await fixture.CreateAuthenticatedUserAsync(); + var child = await GuardianTestHelpers.CreateChildAsync(fixture, token); + var monday = FutureMonday(); + await ReplacePatternAsync(fixture, token, child.Id, effectiveFrom: null, 1, monday, Day(0, DayOfWeek.Monday, Guardian(guardianId))); + await ReplacePatternAsync(fixture, token, child.Id, monday, 2, monday, Day(1, DayOfWeek.Friday, NoteOnly("Screen night"))); + + var schedule = await GetScheduleAsync(fixture, token, child.Id); + + Assert.Equal(child.Id, schedule.ChildId); + Assert.Equal([NightPatternStatus.Current, NightPatternStatus.Upcoming], schedule.Patterns.Select(p => p.Status)); + Assert.Equal(2, schedule.Patterns[1].CycleWeeks); + Assert.Equal("Screen night", Assert.Single(schedule.Patterns[1].Days).Entry.Note); + } + + [Fact] + public async Task A_child_with_no_schedule_has_no_versions() + { + var (_, token, _) = await fixture.CreateAuthenticatedUserAsync(); + var child = await GuardianTestHelpers.CreateChildAsync(fixture, token); + + Assert.Empty((await GetScheduleAsync(fixture, token, child.Id)).Patterns); + } + + [Fact] + public async Task The_child_gets_forbidden_and_a_stranger_gets_not_found() + { + var (_, token, _) = await fixture.CreateAuthenticatedUserAsync(); + var child = await GuardianTestHelpers.CreateChildAsync(fixture, token); + var childToken = await GuardianTestHelpers.CompleteChildLoginAsync(fixture, child); + var (_, strangerToken, _) = await fixture.CreateAuthenticatedUserAsync(); + + await GetScheduleExpectingAsync(fixture, childToken, child.Id, 403); + await GetScheduleExpectingAsync(fixture, strangerToken, child.Id, 404); + } +} diff --git a/src/backend/buddy.IntegrationTests/Features/NightDuty/ListNights/ListNightsTests.cs b/src/backend/buddy.IntegrationTests/Features/NightDuty/ListNights/ListNightsTests.cs new file mode 100644 index 00000000..9a9a5611 --- /dev/null +++ b/src/backend/buddy.IntegrationTests/Features/NightDuty/ListNights/ListNightsTests.cs @@ -0,0 +1,87 @@ +using Alba; + +using buddy.IntegrationTests.Features.Babysitters; +using buddy.IntegrationTests.Features.Guardians; +using buddy.IntegrationTests.Features.WorkLocations; +using buddy.IntegrationTests.Fixtures; +using buddy.IntegrationTests.Meta; + +using Xunit; + +using static buddy.IntegrationTests.Features.NightDuty.NightDutyTestHelpers; + +namespace buddy.IntegrationTests.Features.NightDuty.ListNights; + +[Collection(BuddyApiCollection.Name)] +public sealed class ListNightsTests(BuddyApiFixture fixture) +{ + [Fact] + [CoversEndpoint("ListNights")] + public async Task The_child_sees_their_own_nights() + { + var (_, token, guardianId) = await fixture.CreateAuthenticatedUserAsync(); + var child = await GuardianTestHelpers.CreateChildAsync(fixture, token); + var childToken = await GuardianTestHelpers.CompleteChildLoginAsync(fixture, child); + var monday = FutureMonday(); + await SetOverridesAsync(fixture, token, child.Id, Override(monday, Guardian(guardianId, "Screen night"))); + + var night = Assert.Single(await ListNightsAsync(fixture, childToken, child.Id, monday, monday)); + + Assert.Equal(guardianId, night.Status.Entry!.Responsible.GuardianId); + Assert.Equal("Screen night", night.Status.Entry.Note); + } + + [Fact] + public async Task A_child_with_no_schedule_has_only_unplanned_nights() + { + var (_, token, _) = await fixture.CreateAuthenticatedUserAsync(); + var child = await GuardianTestHelpers.CreateChildAsync(fixture, token); + var monday = FutureMonday(); + + var nights = await ListNightsAsync(fixture, token, child.Id, monday, monday.AddDays(6)); + + Assert.Equal(7, nights.Count); + Assert.All(nights, n => Assert.Equal(KindUnplanned, n.Status.Kind)); + } + + [Fact] + public async Task A_babysitter_rename_reaches_every_night() + { + var (_, token, guardianId) = await fixture.CreateAuthenticatedUserAsync(); + var child = await GuardianTestHelpers.CreateChildAsync(fixture, token); + var maja = await BabysitterTestHelpers.AddAsync(fixture, token, "Maja"); + var monday = FutureMonday(); + await SetOverridesAsync(fixture, token, child.Id, Override(monday, Babysitter(guardianId, maja.Id))); + + await fixture.Host.Scenario(_ => + { + _.WithRequestHeader("Authorization", $"Bearer {token}"); + _.Patch.Json(new { Name = "Maja H.", ContactInfo = "" }).ToUrl($"/babysitters/me/{maja.Id}"); + _.StatusCodeShouldBeOk(); + }); + + var night = Assert.Single(await ListNightsAsync(fixture, token, child.Id, monday, monday)); + Assert.Equal("Maja H.", night.Status.Entry!.Responsible.Name); + } + + [Fact] + public async Task A_revoked_guardian_and_a_stranger_get_not_found_and_a_too_long_range_is_rejected() + { + var family = await WorkLocationTestHelpers.CreateCoGuardiansAsync(fixture); + var (_, strangerToken, _) = await fixture.CreateAuthenticatedUserAsync(); + var monday = FutureMonday(); + + await ListNightsExpectingAsync(fixture, family.SecondToken, family.Child.Id, monday, monday, 200); + await ListNightsExpectingAsync(fixture, family.FirstToken, family.Child.Id, monday, monday.AddDays(32), 400); + await ListNightsExpectingAsync(fixture, strangerToken, family.Child.Id, monday, monday, 404); + + await fixture.Host.Scenario(_ => + { + _.WithRequestHeader("Authorization", $"Bearer {family.SecondToken}"); + _.Delete.Url($"/users/me/children/{family.Child.Id}/guardian-link"); + _.StatusCodeShouldBe(204); + }); + + await ListNightsExpectingAsync(fixture, family.SecondToken, family.Child.Id, monday, monday, 404); + } +} diff --git a/src/backend/buddy.IntegrationTests/Features/NightDuty/NightDutyTestHelpers.cs b/src/backend/buddy.IntegrationTests/Features/NightDuty/NightDutyTestHelpers.cs new file mode 100644 index 00000000..b450db3c --- /dev/null +++ b/src/backend/buddy.IntegrationTests/Features/NightDuty/NightDutyTestHelpers.cs @@ -0,0 +1,150 @@ +using Alba; + +using buddy.Features.NightDuty; +using buddy.IntegrationTests.Fixtures; + +namespace buddy.IntegrationTests.Features.NightDuty; + +// Response shapes, matching Night / NightStatus / NightEntryDto / NightScheduleResponse +// (Features/NightDuty/Types). Enums travel as their member name; the "kind" discriminators stay numeric. +internal sealed record ResponsibleTestDto( + int Kind, Guid? GuardianId = null, Guid? BabysitterId = null, string? Name = null, string? HostName = null, string? Location = null, string? ContactInfo = null); + +internal sealed record NightEntryTestDto(ResponsibleTestDto Responsible, string Note); + +internal sealed record NightStatusTestDto(int Kind, NightEntryTestDto? Entry = null, NightSource? Source = null, NightEntryTestDto? Scheduled = null); + +internal sealed record NightTestDto(DateOnly Date, NightStatusTestDto Status); + +internal sealed record NightPatternDayTestDto(int Week, DayOfWeek Day, NightEntryTestDto Entry); + +internal sealed record NightPatternTestDto(DateOnly EffectiveFrom, int CycleWeeks, DateOnly AnchorMonday, List Days, NightPatternStatus Status); + +internal sealed record NightScheduleTestDto(Guid ChildId, DateOnly Today, List Patterns); + +internal static class NightDutyTestHelpers +{ + public const int KindUnplanned = 0; + public const int KindCleared = 1; + public const int KindPlanned = 2; + + public const int ResponsibleGuardian = 0; + public const int ResponsibleBabysitter = 1; + public const int ResponsibleSleepover = 2; + public const int ResponsibleUnassigned = 3; + + // Far enough ahead that no time zone puts it in the child's past, and a Monday so cycle weeks + // line up with calendar weeks. + public static DateOnly FutureMonday(int weeksAhead = 2) + { + var today = DateOnly.FromDateTime(DateTime.UtcNow); + var monday = today.AddDays(-(((int)today.DayOfWeek + 6) % 7)); + + return monday.AddDays(7 * weeksAhead); + } + + public static object Guardian(Guid guardianId, string? note = null) => new { Responsible = new { Kind = ResponsibleGuardian, GuardianId = guardianId }, Note = note }; + + public static object Babysitter(Guid guardianId, Guid babysitterId, string? note = null) => + new { Responsible = new { Kind = ResponsibleBabysitter, GuardianId = guardianId, BabysitterId = babysitterId }, Note = note }; + + public static object Sleepover(string hostName, string? location = null, string? note = null) => + new { Responsible = new { Kind = ResponsibleSleepover, HostName = hostName, Location = location }, Note = note }; + + public static object NoteOnly(string note) => new { Responsible = new { Kind = ResponsibleUnassigned }, Note = note }; + + public static object Day(int week, DayOfWeek day, object entry) => new { Week = week, Day = day, Entry = entry }; + + public static object Override(DateOnly date, object? entry) => new { Date = date, Entry = entry }; + + public static async Task ReplacePatternAsync( + BuddyApiFixture fixture, string token, Guid childId, DateOnly? effectiveFrom, int cycleWeeks, DateOnly anchorMonday, params object[] days) + { + var response = await fixture.Host.Scenario(_ => + { + _.WithRequestHeader("Authorization", $"Bearer {token}"); + _.Put.Json(new { EffectiveFrom = effectiveFrom, CycleWeeks = cycleWeeks, AnchorMonday = anchorMonday, Days = days }) + .ToUrl($"/night-duty/children/{childId}/pattern"); + _.StatusCodeShouldBeOk(); + }); + + return response.ReadAsJson(); + } + + public static async Task ReplacePatternExpectingAsync( + BuddyApiFixture fixture, string token, Guid childId, int status, object body) => + await fixture.Host.Scenario(_ => + { + _.WithRequestHeader("Authorization", $"Bearer {token}"); + _.Put.Json(body).ToUrl($"/night-duty/children/{childId}/pattern"); + _.StatusCodeShouldBe(status); + }); + + public static async Task> SetOverridesAsync(BuddyApiFixture fixture, string token, Guid childId, params object[] nights) + { + var response = await fixture.Host.Scenario(_ => + { + _.WithRequestHeader("Authorization", $"Bearer {token}"); + _.Put.Json(new { Nights = nights }).ToUrl($"/night-duty/children/{childId}/overrides"); + _.StatusCodeShouldBeOk(); + }); + + return response.ReadAsJson>(); + } + + public static async Task SetOverridesExpectingAsync(BuddyApiFixture fixture, string token, Guid childId, int status, params object[] nights) => + await fixture.Host.Scenario(_ => + { + _.WithRequestHeader("Authorization", $"Bearer {token}"); + _.Put.Json(new { Nights = nights }).ToUrl($"/night-duty/children/{childId}/overrides"); + _.StatusCodeShouldBe(status); + }); + + public static async Task ClearOverridesAsync(BuddyApiFixture fixture, string token, Guid childId, DateOnly from, DateOnly to, int status = 204) => + await fixture.Host.Scenario(_ => + { + _.WithRequestHeader("Authorization", $"Bearer {token}"); + _.Delete.Url($"/night-duty/children/{childId}/overrides?from={from:yyyy-MM-dd}&to={to:yyyy-MM-dd}"); + _.StatusCodeShouldBe(status); + }); + + public static async Task> ListNightsAsync(BuddyApiFixture fixture, string token, Guid childId, DateOnly from, DateOnly to) + { + var response = await fixture.Host.Scenario(_ => + { + _.WithRequestHeader("Authorization", $"Bearer {token}"); + _.Get.Url($"/night-duty/children/{childId}/nights?from={from:yyyy-MM-dd}&to={to:yyyy-MM-dd}"); + _.StatusCodeShouldBeOk(); + }); + + return response.ReadAsJson>(); + } + + public static async Task ListNightsExpectingAsync(BuddyApiFixture fixture, string token, Guid childId, DateOnly from, DateOnly to, int status) => + await fixture.Host.Scenario(_ => + { + _.WithRequestHeader("Authorization", $"Bearer {token}"); + _.Get.Url($"/night-duty/children/{childId}/nights?from={from:yyyy-MM-dd}&to={to:yyyy-MM-dd}"); + _.StatusCodeShouldBe(status); + }); + + public static async Task GetScheduleAsync(BuddyApiFixture fixture, string token, Guid childId) + { + var response = await fixture.Host.Scenario(_ => + { + _.WithRequestHeader("Authorization", $"Bearer {token}"); + _.Get.Url($"/night-duty/children/{childId}"); + _.StatusCodeShouldBeOk(); + }); + + return response.ReadAsJson(); + } + + public static async Task GetScheduleExpectingAsync(BuddyApiFixture fixture, string token, Guid childId, int status) => + await fixture.Host.Scenario(_ => + { + _.WithRequestHeader("Authorization", $"Bearer {token}"); + _.Get.Url($"/night-duty/children/{childId}"); + _.StatusCodeShouldBe(status); + }); +} diff --git a/src/backend/buddy.IntegrationTests/Features/NightDuty/NightScheduleResolutionTests.cs b/src/backend/buddy.IntegrationTests/Features/NightDuty/NightScheduleResolutionTests.cs new file mode 100644 index 00000000..b6612fff --- /dev/null +++ b/src/backend/buddy.IntegrationTests/Features/NightDuty/NightScheduleResolutionTests.cs @@ -0,0 +1,74 @@ +using System.Collections.Immutable; + +using buddy.Features.NightDuty; +using buddy.Features.Users; + +using Xunit; + +namespace buddy.IntegrationTests.Features.NightDuty; + +// Pure tests of the version and cycle arithmetic -- no containers needed, and no dependency on +// today's date (the HTTP tests can't save a version in the past). +public sealed class NightScheduleResolutionTests +{ + private static readonly UserId Child = new(Guid.Parse("00000000-0000-0000-0000-000000000002")); + private static readonly NightEntry Mom = new(new NightResponsible.Guardian(new UserId(Guid.Parse("00000000-0000-0000-0000-000000000001"))), ""); + private static readonly NightEntry Dad = new(new NightResponsible.Guardian(new UserId(Guid.Parse("00000000-0000-0000-0000-000000000003"))), ""); + + // Week A Mom on Mondays, week B Dad, anchored on 2026-12-14. ISO week 53 (2026-12-28) is week A + // and 2027-01-04 (ISO week 1, odd like 53) is week B -- parity would repeat a week here. + private static readonly NightPattern AlternatingMondays = new( + new DateOnly(2026, 12, 1), 2, new DateOnly(2026, 12, 14), + [new NightPatternDay(0, DayOfWeek.Monday, Mom), new NightPatternDay(1, DayOfWeek.Monday, Dad)]); + + [Theory] + [InlineData("2026-12-14", "Mom")] + [InlineData("2026-12-21", "Dad")] + [InlineData("2026-12-28", "Mom")] // ISO week 53 + [InlineData("2027-01-04", "Dad")] // ISO week 1 + [InlineData("2027-01-11", "Mom")] + [InlineData("2026-12-07", "Dad")] // before the anchor, after EffectiveFrom + public void An_alternating_schedule_crosses_week_53_and_the_year_boundary(string date, string expected) + { + var schedule = NightSchedule.Empty(Child) with { Patterns = [AlternatingMondays] }; + + Assert.Equal(expected == "Mom" ? Mom : Dad, schedule.ScheduledEntryFor(DateOnly.Parse(date, System.Globalization.CultureInfo.InvariantCulture))); + } + + [Fact] + public void A_date_before_the_first_version_has_no_entry() => + Assert.Null((NightSchedule.Empty(Child) with { Patterns = [AlternatingMondays] }).ScheduledEntryFor(new DateOnly(2026, 11, 30))); + + [Fact] + public void Each_date_resolves_against_the_latest_version_starting_on_or_before_it() + { + var later = new NightPattern(new DateOnly(2027, 1, 4), 1, new DateOnly(2027, 1, 4), [new NightPatternDay(0, DayOfWeek.Monday, Dad)]); + var schedule = NightSchedule.Empty(Child) with { Patterns = [AlternatingMondays, later] }; + + Assert.Equal(Mom, schedule.ScheduledEntryFor(new DateOnly(2026, 12, 28))); + Assert.Equal(Dad, schedule.ScheduledEntryFor(new DateOnly(2027, 1, 4))); + Assert.Equal(Dad, schedule.ScheduledEntryFor(new DateOnly(2027, 1, 11))); + } + + [Fact] + public void A_new_version_drops_every_version_starting_on_or_after_it() + { + var january = AlternatingMondays with { EffectiveFrom = new DateOnly(2027, 1, 4) }; + var february = AlternatingMondays with { EffectiveFrom = new DateOnly(2027, 2, 1) }; + var schedule = NightSchedule.Empty(Child) with { Patterns = [AlternatingMondays, january, february] }; + + var replaced = schedule.WithPattern(january with { CycleWeeks = 1 }); + + Assert.Equal([new DateOnly(2026, 12, 1), new DateOnly(2027, 1, 4)], replaced.Select(p => p.EffectiveFrom)); + Assert.Equal(1, replaced[^1].CycleWeeks); + } + + [Fact] + public void An_entry_with_neither_a_person_nor_a_note_is_empty() + { + Assert.True(new NightEntry(new NightResponsible.Unassigned(), "").IsEmpty); + Assert.False(new NightEntry(new NightResponsible.Unassigned(), "Screen night").IsEmpty); + Assert.False(Mom.IsEmpty); + Assert.Equal(ImmutableDictionary.Empty, NightSchedule.Empty(Child).Overrides); + } +} diff --git a/src/backend/buddy.IntegrationTests/Features/NightDuty/ReplaceNightPattern/ReplaceNightPatternTests.cs b/src/backend/buddy.IntegrationTests/Features/NightDuty/ReplaceNightPattern/ReplaceNightPatternTests.cs new file mode 100644 index 00000000..01542a86 --- /dev/null +++ b/src/backend/buddy.IntegrationTests/Features/NightDuty/ReplaceNightPattern/ReplaceNightPatternTests.cs @@ -0,0 +1,294 @@ +using buddy.Common; +using buddy.Features.NightDuty; +using buddy.Features.Users; +using buddy.IntegrationTests.Features.Babysitters; +using buddy.IntegrationTests.Features.Guardians; +using buddy.IntegrationTests.Fixtures; +using buddy.IntegrationTests.Meta; + +using Microsoft.Extensions.DependencyInjection; + +using Xunit; + +using static buddy.IntegrationTests.Features.NightDuty.NightDutyTestHelpers; + +namespace buddy.IntegrationTests.Features.NightDuty.ReplaceNightPattern; + +[Collection(BuddyApiCollection.Name)] +public sealed class ReplaceNightPatternTests(BuddyApiFixture fixture) +{ + [Fact] + [CoversEndpoint("ReplaceNightPattern")] + public async Task An_alternating_two_week_schedule_resolves_per_cycle_week() + { + var (_, token, guardianId) = await fixture.CreateAuthenticatedUserAsync(); + var child = await GuardianTestHelpers.CreateChildAsync(fixture, token); + var monday = FutureMonday(); + + // Week A: Monday with the guardian, Friday a screen night. Week B: Saturday at Grandma's. + var schedule = await ReplacePatternAsync(fixture, token, child.Id, monday, 2, monday, + Day(1, DayOfWeek.Saturday, Sleepover("Grandma Lis", "Skovvej 12")), + Day(0, DayOfWeek.Friday, NoteOnly("Screen night 📱")), + Day(0, DayOfWeek.Monday, Guardian(guardianId))); + + var version = Assert.Single(schedule.Patterns); + Assert.Equal(monday, version.EffectiveFrom); + Assert.Equal(NightPatternStatus.Upcoming, version.Status); + Assert.Equal( + [(0, DayOfWeek.Monday), (0, DayOfWeek.Friday), (1, DayOfWeek.Saturday)], + version.Days.Select(d => (d.Week, d.Day))); + + var nights = await ListNightsAsync(fixture, token, child.Id, monday, monday.AddDays(20)); + var planned = nights.Where(n => n.Status.Kind == KindPlanned).ToDictionary(n => n.Date); + + Assert.Equal([monday, monday.AddDays(4), monday.AddDays(12), monday.AddDays(14), monday.AddDays(18)], planned.Keys.Order()); + Assert.Equal(guardianId, planned[monday].Status.Entry!.Responsible.GuardianId); + Assert.Equal("Screen night 📱", planned[monday.AddDays(4)].Status.Entry!.Note); + Assert.Equal(ResponsibleUnassigned, planned[monday.AddDays(4)].Status.Entry!.Responsible.Kind); + Assert.Equal("Grandma Lis", planned[monday.AddDays(12)].Status.Entry!.Responsible.HostName); + Assert.Equal("", planned[monday.AddDays(12)].Status.Entry!.Responsible.ContactInfo); + Assert.All(planned.Values, n => Assert.Equal(NightSource.Pattern, n.Status.Source)); + Assert.All(planned.Values, n => Assert.Null(n.Status.Scheduled)); + Assert.All(nights.Where(n => !planned.ContainsKey(n.Date)), n => Assert.Equal(KindUnplanned, n.Status.Kind)); + } + + [Fact] + public async Task Without_an_effective_date_the_schedule_applies_from_the_childs_today() + { + var (_, token, guardianId) = await fixture.CreateAuthenticatedUserAsync(); + var child = await GuardianTestHelpers.CreateChildAsync(fixture, token); + + var schedule = await ReplacePatternAsync(fixture, token, child.Id, effectiveFrom: null, 1, FutureMonday(), + Day(0, DayOfWeek.Monday, Guardian(guardianId))); + + var version = Assert.Single(schedule.Patterns); + Assert.Equal(schedule.Today, version.EffectiveFrom); + Assert.Equal(NightPatternStatus.Current, version.Status); + } + + [Fact] + public async Task A_schedule_starting_in_the_past_is_rejected() + { + var (_, token, guardianId) = await fixture.CreateAuthenticatedUserAsync(); + var child = await GuardianTestHelpers.CreateChildAsync(fixture, token); + var yesterdayOrEarlier = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(-2); + + await ReplacePatternExpectingAsync(fixture, token, child.Id, 400, new + { + EffectiveFrom = yesterdayOrEarlier, + CycleWeeks = 1, + AnchorMonday = FutureMonday(), + Days = new[] { Day(0, DayOfWeek.Monday, Guardian(guardianId)) }, + }); + } + + [Fact] + public async Task A_new_version_leaves_the_nights_before_it_on_the_old_schedule() + { + var (_, token, guardianId) = await fixture.CreateAuthenticatedUserAsync(); + var child = await GuardianTestHelpers.CreateChildAsync(fixture, token); + var monday = FutureMonday(); + + await ReplacePatternAsync(fixture, token, child.Id, monday, 1, monday, Day(0, DayOfWeek.Monday, Guardian(guardianId))); + var schedule = await ReplacePatternAsync(fixture, token, child.Id, monday.AddDays(7), 1, monday, Day(0, DayOfWeek.Monday, NoteOnly("New rhythm"))); + + Assert.Equal([monday, monday.AddDays(7)], schedule.Patterns.Select(p => p.EffectiveFrom)); + + var nights = await ListNightsAsync(fixture, token, child.Id, monday, monday.AddDays(14)); + + Assert.Equal(guardianId, nights.Single(n => n.Date == monday).Status.Entry!.Responsible.GuardianId); + Assert.Equal("New rhythm", nights.Single(n => n.Date == monday.AddDays(7)).Status.Entry!.Note); + Assert.Equal("New rhythm", nights.Single(n => n.Date == monday.AddDays(14)).Status.Entry!.Note); + } + + [Fact] + public async Task Saving_from_an_earlier_date_replaces_an_upcoming_version() + { + var (_, token, guardianId) = await fixture.CreateAuthenticatedUserAsync(); + var child = await GuardianTestHelpers.CreateChildAsync(fixture, token); + var monday = FutureMonday(); + + await ReplacePatternAsync(fixture, token, child.Id, monday.AddDays(14), 1, monday, Day(0, DayOfWeek.Monday, NoteOnly("Later"))); + var schedule = await ReplacePatternAsync(fixture, token, child.Id, monday, 1, monday, Day(0, DayOfWeek.Monday, Guardian(guardianId))); + + var version = Assert.Single(schedule.Patterns); + Assert.Equal(monday, version.EffectiveFrom); + + var nights = await ListNightsAsync(fixture, token, child.Id, monday.AddDays(14), monday.AddDays(14)); + Assert.Equal(guardianId, Assert.Single(nights).Status.Entry!.Responsible.GuardianId); + } + + [Fact] + public async Task Saving_the_same_version_appends_no_event() + { + var (_, token, guardianId) = await fixture.CreateAuthenticatedUserAsync(); + var child = await GuardianTestHelpers.CreateChildAsync(fixture, token); + var monday = FutureMonday(); + await ReplacePatternAsync(fixture, token, child.Id, monday, 1, monday, Day(0, DayOfWeek.Monday, Guardian(guardianId))); + + var store = fixture.Host.Services.GetRequiredService(); + var id = NightScheduleId.ForChild(new UserId(child.Id)); + var before = (await store.ReadAsync(id, CancellationToken.None)).Count; + + await ReplacePatternAsync(fixture, token, child.Id, monday, 1, monday, Day(0, DayOfWeek.Monday, Guardian(guardianId))); + + Assert.Equal(before, (await store.ReadAsync(id, CancellationToken.None)).Count); + } + + [Fact] + public async Task Re_saving_the_version_in_effect_with_the_same_content_adds_no_version() + { + var (_, token, guardianId) = await fixture.CreateAuthenticatedUserAsync(); + var child = await GuardianTestHelpers.CreateChildAsync(fixture, token); + var monday = FutureMonday(); + await ReplacePatternAsync(fixture, token, child.Id, monday, 1, monday, Day(0, DayOfWeek.Monday, Guardian(guardianId))); + + var schedule = await ReplacePatternAsync(fixture, token, child.Id, monday.AddDays(7), 1, monday, Day(0, DayOfWeek.Monday, Guardian(guardianId))); + + Assert.Equal(monday, Assert.Single(schedule.Patterns).EffectiveFrom); + } + + [Fact] + public async Task A_babysitter_archived_since_the_version_was_saved_does_not_block_re_saving_it() + { + var (_, token, guardianId) = await fixture.CreateAuthenticatedUserAsync(); + var child = await GuardianTestHelpers.CreateChildAsync(fixture, token); + var anna = await BabysitterTestHelpers.AddAsync(fixture, token, "Anna"); + var monday = FutureMonday(); + await ReplacePatternAsync(fixture, token, child.Id, monday, 1, monday, Day(0, DayOfWeek.Tuesday, Babysitter(guardianId, anna.Id))); + await BabysitterTestHelpers.ArchiveAsync(fixture, token, anna.Id); + + var schedule = await ReplacePatternAsync(fixture, token, child.Id, monday, 1, monday, + Day(0, DayOfWeek.Tuesday, Babysitter(guardianId, anna.Id)), + Day(0, DayOfWeek.Friday, NoteOnly("Screen night"))); + + Assert.Equal(2, Assert.Single(schedule.Patterns).Days.Count); + Assert.Equal("Anna", schedule.Patterns[0].Days[0].Entry.Responsible.Name); + } + + [Theory] + [InlineData(5, 0, "Monday", "ok")] + [InlineData(2, 2, "Monday", "ok")] + [InlineData(1, 0, "Funday", "ok")] + [InlineData(1, 0, "Monday", "")] + [InlineData(1, 0, "Monday", "61")] + [InlineData(1, 0, "Monday", "sleepover-without-host")] + public async Task Structurally_invalid_schedules_are_rejected(int cycleWeeks, int week, string day, string note) + { + var (_, token, _) = await fixture.CreateAuthenticatedUserAsync(); + var child = await GuardianTestHelpers.CreateChildAsync(fixture, token); + + object entry = note switch + { + "61" => NoteOnly(new string('x', 61)), + "sleepover-without-host" => Sleepover(" "), + _ => NoteOnly(note), + }; + + await ReplacePatternExpectingAsync(fixture, token, child.Id, 400, new + { + CycleWeeks = cycleWeeks, + AnchorMonday = FutureMonday(), + Days = new[] { new { Week = week, Day = day, Entry = entry } }, + }); + } + + [Theory] + [InlineData("61", "days[0].entry.note")] + [InlineData("", "days[0].entry.responsible")] + [InlineData("sleepover-without-host", "days[0].entry.responsible")] + public async Task An_invalid_entry_is_reported_on_its_field(string note, string expectedKey) + { + var (_, token, _) = await fixture.CreateAuthenticatedUserAsync(); + var child = await GuardianTestHelpers.CreateChildAsync(fixture, token); + object entry = note switch + { + "61" => NoteOnly(new string('x', 61)), + "sleepover-without-host" => Sleepover(" "), + _ => NoteOnly(note), + }; + + var response = await ReplacePatternExpectingAsync(fixture, token, child.Id, 400, new + { + CycleWeeks = 1, + AnchorMonday = FutureMonday(), + Days = new[] { Day(0, DayOfWeek.Monday, entry) }, + }); + + Assert.Contains(expectedKey, response.ReadAsJson().Details.Keys); + } + + [Fact] + public async Task An_anchor_that_is_not_a_monday_and_duplicate_days_are_rejected() + { + var (_, token, _) = await fixture.CreateAuthenticatedUserAsync(); + var child = await GuardianTestHelpers.CreateChildAsync(fixture, token); + + await ReplacePatternExpectingAsync(fixture, token, child.Id, 400, new + { + CycleWeeks = 1, + AnchorMonday = FutureMonday().AddDays(1), + Days = Array.Empty(), + }); + + await ReplacePatternExpectingAsync(fixture, token, child.Id, 400, new + { + CycleWeeks = 1, + AnchorMonday = FutureMonday(), + Days = new[] { Day(0, DayOfWeek.Monday, NoteOnly("a")), Day(0, DayOfWeek.Monday, NoteOnly("b")) }, + }); + } + + [Fact] + public async Task A_guardian_who_is_not_a_guardian_of_the_child_is_rejected() + { + var (_, token, _) = await fixture.CreateAuthenticatedUserAsync(); + var child = await GuardianTestHelpers.CreateChildAsync(fixture, token); + var (_, _, strangerId) = await fixture.CreateAuthenticatedUserAsync(); + + await ReplacePatternExpectingAsync(fixture, token, child.Id, 400, new + { + CycleWeeks = 1, + AnchorMonday = FutureMonday(), + Days = new[] { Day(0, DayOfWeek.Monday, Guardian(strangerId)) }, + }); + } + + [Fact] + public async Task An_unknown_or_archived_babysitter_is_rejected_and_an_active_one_resolves_by_name() + { + var (_, token, guardianId) = await fixture.CreateAuthenticatedUserAsync(); + var child = await GuardianTestHelpers.CreateChildAsync(fixture, token); + var maja = await BabysitterTestHelpers.AddAsync(fixture, token, "Maja"); + var freja = await BabysitterTestHelpers.AddAsync(fixture, token, "Freja"); + await BabysitterTestHelpers.ArchiveAsync(fixture, token, freja.Id); + var monday = FutureMonday(); + + foreach (var babysitterId in new[] { Guid.CreateVersion7(), freja.Id }) + { + await ReplacePatternExpectingAsync(fixture, token, child.Id, 400, new + { + CycleWeeks = 1, + AnchorMonday = monday, + Days = new[] { Day(0, DayOfWeek.Monday, Babysitter(guardianId, babysitterId)) }, + }); + } + + var schedule = await ReplacePatternAsync(fixture, token, child.Id, monday, 1, monday, Day(0, DayOfWeek.Monday, Babysitter(guardianId, maja.Id))); + + Assert.Equal("Maja", Assert.Single(Assert.Single(schedule.Patterns).Days).Entry.Responsible.Name); + } + + [Fact] + public async Task The_child_gets_forbidden_and_a_stranger_gets_not_found() + { + var (_, token, guardianId) = await fixture.CreateAuthenticatedUserAsync(); + var child = await GuardianTestHelpers.CreateChildAsync(fixture, token); + var childToken = await GuardianTestHelpers.CompleteChildLoginAsync(fixture, child); + var (_, strangerToken, _) = await fixture.CreateAuthenticatedUserAsync(); + var body = new { CycleWeeks = 1, AnchorMonday = FutureMonday(), Days = new[] { Day(0, DayOfWeek.Monday, Guardian(guardianId)) } }; + + await ReplacePatternExpectingAsync(fixture, childToken, child.Id, 403, body); + await ReplacePatternExpectingAsync(fixture, strangerToken, child.Id, 404, body); + } +} diff --git a/src/backend/buddy.IntegrationTests/Features/NightDuty/SetNightOverrides/SetNightOverridesTests.cs b/src/backend/buddy.IntegrationTests/Features/NightDuty/SetNightOverrides/SetNightOverridesTests.cs new file mode 100644 index 00000000..a01a53bd --- /dev/null +++ b/src/backend/buddy.IntegrationTests/Features/NightDuty/SetNightOverrides/SetNightOverridesTests.cs @@ -0,0 +1,205 @@ +using buddy.Common; +using buddy.Features.NightDuty; +using buddy.Features.Users; +using buddy.IntegrationTests.Features.Babysitters; +using buddy.IntegrationTests.Features.Guardians; +using buddy.IntegrationTests.Features.WorkLocations; +using buddy.IntegrationTests.Fixtures; +using buddy.IntegrationTests.Meta; + +using Microsoft.Extensions.DependencyInjection; + +using Xunit; + +using static buddy.IntegrationTests.Features.NightDuty.NightDutyTestHelpers; + +namespace buddy.IntegrationTests.Features.NightDuty.SetNightOverrides; + +[Collection(BuddyApiCollection.Name)] +public sealed class SetNightOverridesTests(BuddyApiFixture fixture) +{ + [Fact] + [CoversEndpoint("SetNightOverrides")] + public async Task An_override_wins_over_the_schedule_and_shows_what_the_schedule_said() + { + var family = await WorkLocationTestHelpers.CreateCoGuardiansAsync(fixture); + var monday = FutureMonday(); + await ReplacePatternAsync(fixture, family.FirstToken, family.Child.Id, monday, 1, monday, + Day(0, DayOfWeek.Tuesday, Guardian(family.FirstId, "Screen night"))); + + var nights = await SetOverridesAsync(fixture, family.FirstToken, family.Child.Id, + Override(monday.AddDays(1), Guardian(family.SecondId, "Screen night"))); + + var night = Assert.Single(nights); + Assert.Equal(KindPlanned, night.Status.Kind); + Assert.Equal(NightSource.Override, night.Status.Source); + Assert.Equal(family.SecondId, night.Status.Entry!.Responsible.GuardianId); + Assert.Equal("Screen night", night.Status.Entry.Note); + Assert.Equal(family.FirstId, night.Status.Scheduled!.Responsible.GuardianId); + + // The week after still follows the schedule. + var nextWeek = Assert.Single(await ListNightsAsync(fixture, family.FirstToken, family.Child.Id, monday.AddDays(8), monday.AddDays(8))); + Assert.Equal(NightSource.Pattern, nextWeek.Status.Source); + Assert.Equal(family.FirstId, nextWeek.Status.Entry!.Responsible.GuardianId); + } + + [Fact] + public async Task A_cleared_override_blanks_a_scheduled_night() + { + var (_, token, guardianId) = await fixture.CreateAuthenticatedUserAsync(); + var child = await GuardianTestHelpers.CreateChildAsync(fixture, token); + var monday = FutureMonday(); + await ReplacePatternAsync(fixture, token, child.Id, monday, 1, monday, Day(0, DayOfWeek.Monday, Guardian(guardianId))); + + var night = Assert.Single(await SetOverridesAsync(fixture, token, child.Id, Override(monday, entry: null))); + + Assert.Equal(KindCleared, night.Status.Kind); + Assert.Null(night.Status.Entry); + Assert.Equal(guardianId, night.Status.Scheduled!.Responsible.GuardianId); + } + + [Fact] + public async Task Switching_two_nights_appends_exactly_two_events_in_one_request() + { + var family = await WorkLocationTestHelpers.CreateCoGuardiansAsync(fixture); + var monday = FutureMonday(); + await ReplacePatternAsync(fixture, family.FirstToken, family.Child.Id, monday, 1, monday, + Day(0, DayOfWeek.Tuesday, Guardian(family.SecondId)), + Day(0, DayOfWeek.Thursday, Guardian(family.FirstId))); + + var store = fixture.Host.Services.GetRequiredService(); + var id = NightScheduleId.ForChild(new UserId(family.Child.Id)); + var before = (await store.ReadAsync(id, CancellationToken.None)).Count; + + var nights = await SetOverridesAsync(fixture, family.FirstToken, family.Child.Id, + Override(monday.AddDays(3), Guardian(family.SecondId)), + Override(monday.AddDays(1), Guardian(family.FirstId))); + + var events = await store.ReadAsync(id, CancellationToken.None); + Assert.Equal(before + 2, events.Count); + Assert.Equal([monday.AddDays(1), monday.AddDays(3)], events.Skip(before).Select(e => ((NightOverridden)e.Value!).Date)); + Assert.All(events.Skip(before), e => Assert.Equal(new UserId(family.FirstId), ((NightOverridden)e.Value!).ModifiedBy)); + Assert.Equal([family.FirstId, family.SecondId], nights.OrderBy(n => n.Date).Select(n => n.Status.Entry!.Responsible.GuardianId!.Value)); + } + + [Fact] + public async Task Setting_an_override_to_the_value_it_already_has_appends_no_event() + { + var (_, token, guardianId) = await fixture.CreateAuthenticatedUserAsync(); + var child = await GuardianTestHelpers.CreateChildAsync(fixture, token); + var date = FutureMonday(); + await SetOverridesAsync(fixture, token, child.Id, Override(date, Guardian(guardianId))); + + var store = fixture.Host.Services.GetRequiredService(); + var id = NightScheduleId.ForChild(new UserId(child.Id)); + var before = (await store.ReadAsync(id, CancellationToken.None)).Count; + + await SetOverridesAsync(fixture, token, child.Id, Override(date, Guardian(guardianId))); + + Assert.Equal(before, (await store.ReadAsync(id, CancellationToken.None)).Count); + } + + [Fact] + public async Task A_note_on_its_own_and_a_past_night_can_be_set() + { + var (_, token, _) = await fixture.CreateAuthenticatedUserAsync(); + var child = await GuardianTestHelpers.CreateChildAsync(fixture, token); + var lastWeek = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(-7); + + var night = Assert.Single(await SetOverridesAsync(fixture, token, child.Id, Override(lastWeek, NoteOnly("Movie night 🎬")))); + + Assert.Equal(KindPlanned, night.Status.Kind); + Assert.Equal(ResponsibleUnassigned, night.Status.Entry!.Responsible.Kind); + Assert.Equal("Movie night 🎬", night.Status.Entry.Note); + Assert.Null(night.Status.Scheduled); + } + + [Fact] + public async Task A_change_by_one_guardian_is_visible_to_the_other() + { + var family = await WorkLocationTestHelpers.CreateCoGuardiansAsync(fixture); + var date = FutureMonday(); + + await SetOverridesAsync(fixture, family.SecondToken, family.Child.Id, Override(date, Sleepover("Oscar's family"))); + + var night = Assert.Single(await ListNightsAsync(fixture, family.FirstToken, family.Child.Id, date, date)); + Assert.Equal("Oscar's family", night.Status.Entry!.Responsible.HostName); + } + + [Fact] + public async Task Invalid_override_lists_are_rejected() + { + var (_, token, _) = await fixture.CreateAuthenticatedUserAsync(); + var child = await GuardianTestHelpers.CreateChildAsync(fixture, token); + var (_, _, strangerId) = await fixture.CreateAuthenticatedUserAsync(); + var date = FutureMonday(); + + await SetOverridesExpectingAsync(fixture, token, child.Id, 400); + await SetOverridesExpectingAsync(fixture, token, child.Id, 400, Override(date, NoteOnly("a")), Override(date, NoteOnly("b"))); + await SetOverridesExpectingAsync(fixture, token, child.Id, 400, + [.. Enumerable.Range(0, 32).Select(i => Override(date.AddDays(i), NoteOnly("x")))]); + await SetOverridesExpectingAsync(fixture, token, child.Id, 400, Override(date, NoteOnly(new string('x', 61)))); + await SetOverridesExpectingAsync(fixture, token, child.Id, 400, Override(date, NoteOnly(""))); + await SetOverridesExpectingAsync(fixture, token, child.Id, 400, Override(date, Guardian(strangerId))); + } + + [Fact] + public async Task Nights_more_than_31_days_apart_are_rejected() + { + var (_, token, _) = await fixture.CreateAuthenticatedUserAsync(); + var child = await GuardianTestHelpers.CreateChildAsync(fixture, token); + var date = FutureMonday(); + + var response = await SetOverridesExpectingAsync(fixture, token, child.Id, 400, + Override(date, NoteOnly("a")), Override(date.AddDays(32), NoteOnly("b"))); + + Assert.Contains("nights", response.ReadAsJson().Details.Keys); + await SetOverridesExpectingAsync(fixture, token, child.Id, 200, + Override(date, NoteOnly("a")), Override(date.AddDays(31), NoteOnly("b"))); + } + + [Fact] + public async Task A_switch_can_move_a_babysitter_archived_since_but_not_pick_a_new_archived_one() + { + var (_, token, guardianId) = await fixture.CreateAuthenticatedUserAsync(); + var child = await GuardianTestHelpers.CreateChildAsync(fixture, token); + var anna = await BabysitterTestHelpers.AddAsync(fixture, token, "Anna"); + var freja = await BabysitterTestHelpers.AddAsync(fixture, token, "Freja"); + var date = FutureMonday(); + await SetOverridesAsync(fixture, token, child.Id, Override(date, Babysitter(guardianId, anna.Id)), Override(date.AddDays(1), Guardian(guardianId))); + await BabysitterTestHelpers.ArchiveAsync(fixture, token, anna.Id); + await BabysitterTestHelpers.ArchiveAsync(fixture, token, freja.Id); + + var switched = await SetOverridesAsync(fixture, token, child.Id, + Override(date, Guardian(guardianId)), Override(date.AddDays(1), Babysitter(guardianId, anna.Id))); + + Assert.Equal("Anna", switched.Single(n => n.Date == date.AddDays(1)).Status.Entry!.Responsible.Name); + await SetOverridesExpectingAsync(fixture, token, child.Id, 400, Override(date.AddDays(2), Babysitter(guardianId, freja.Id))); + } + + [Fact] + public async Task An_invalid_entry_is_reported_on_its_field() + { + var (_, token, _) = await fixture.CreateAuthenticatedUserAsync(); + var child = await GuardianTestHelpers.CreateChildAsync(fixture, token); + var date = FutureMonday(); + + var response = await SetOverridesExpectingAsync(fixture, token, child.Id, 400, + Override(date, NoteOnly("ok")), Override(date.AddDays(1), NoteOnly(new string('x', 61)))); + + Assert.Contains("nights[1].entry.note", response.ReadAsJson().Details.Keys); + } + + [Fact] + public async Task The_child_gets_forbidden_and_a_stranger_gets_not_found() + { + var (_, token, guardianId) = await fixture.CreateAuthenticatedUserAsync(); + var child = await GuardianTestHelpers.CreateChildAsync(fixture, token); + var childToken = await GuardianTestHelpers.CompleteChildLoginAsync(fixture, child); + var (_, strangerToken, _) = await fixture.CreateAuthenticatedUserAsync(); + var night = Override(FutureMonday(), Guardian(guardianId)); + + await SetOverridesExpectingAsync(fixture, childToken, child.Id, 403, night); + await SetOverridesExpectingAsync(fixture, strangerToken, child.Id, 404, night); + } +} diff --git a/src/backend/buddy.IntegrationTests/Features/PrintTemplates/PrintTemplateTestHelpers.cs b/src/backend/buddy.IntegrationTests/Features/PrintTemplates/PrintTemplateTestHelpers.cs index ce5d7cbd..f2d6b282 100644 --- a/src/backend/buddy.IntegrationTests/Features/PrintTemplates/PrintTemplateTestHelpers.cs +++ b/src/backend/buddy.IntegrationTests/Features/PrintTemplates/PrintTemplateTestHelpers.cs @@ -53,6 +53,7 @@ internal static class PrintTemplateTestHelpers public const PrintRowKind TaskChecklist = PrintRowKind.TaskChecklist; public const PrintRowKind Blank = PrintRowKind.Blank; public const PrintRowKind CalendarAgenda = PrintRowKind.CalendarAgenda; + public const PrintRowKind NightDutyRow = PrintRowKind.NightDuty; public const MealSlot Dinner = MealSlot.Dinner; diff --git a/src/backend/buddy.IntegrationTests/Features/PrintTemplates/ReplacePrintTemplateRows/ReplacePrintTemplateRowsTests.cs b/src/backend/buddy.IntegrationTests/Features/PrintTemplates/ReplacePrintTemplateRows/ReplacePrintTemplateRowsTests.cs index b8309c1a..9425731c 100644 --- a/src/backend/buddy.IntegrationTests/Features/PrintTemplates/ReplacePrintTemplateRows/ReplacePrintTemplateRowsTests.cs +++ b/src/backend/buddy.IntegrationTests/Features/PrintTemplates/ReplacePrintTemplateRows/ReplacePrintTemplateRowsTests.cs @@ -41,12 +41,13 @@ await ReplaceRowsAsync(fixture, family.FirstToken, template.Id, new { Kind = CalendarMarker, Label = "Affald der tømmes", HeightWeight = 1, CalendarIds = new[] { calendarId }, TitleFilter = "Skrald" }, new { Kind = Blank, Label = "Aftaler / Andet", HeightWeight = 3 }, new { Kind = CalendarAgenda, Label = "Viggos uge", HeightWeight = 2, CalendarIds = new[] { calendarId }, AssignedToId = child, MaxItems = 5 }, + new { Kind = NightDutyRow, Label = "Nat", HeightWeight = 1, ChildId = child }, ]); var read = await GetAsync(fixture, family.FirstToken, template.Id); Assert.Equal( - [Meal, Pickup, WorkLocation, WorkLocation, CalendarEvents, TaskChecklist, CalendarMarker, Blank, CalendarAgenda], + [Meal, Pickup, WorkLocation, WorkLocation, CalendarEvents, TaskChecklist, CalendarMarker, Blank, CalendarAgenda, NightDutyRow], read.Rows.Select(r => r.Kind)); Assert.Equal("Aftensmad", read.Rows[0].Label); Assert.Equal(Dinner, read.Rows[0].MealSlot); @@ -56,6 +57,7 @@ await ReplaceRowsAsync(fixture, family.FirstToken, template.Id, Assert.Equal(3, read.Rows[7].HeightWeight); Assert.Equal(child, read.Rows[8].AssignedToId); Assert.Equal(5, read.Rows[8].MaxItems); + Assert.Equal(child, read.Rows[9].ChildId); } [Fact] @@ -87,6 +89,8 @@ public async Task Saving_the_same_rows_appends_no_event() { "rows[0].mealSlot", [new { Kind = Meal, Label = "Mad", HeightWeight = 1, ChildId = Guid.NewGuid() }] }, { "rows[0].childId", [new { Kind = Pickup, Label = "Hente", HeightWeight = 1 }] }, { "rows[0].guardianId", [new { Kind = WorkLocation, Label = "Far", HeightWeight = 1 }] }, + { "rows[0].childId", [new { Kind = NightDutyRow, Label = "Nat", HeightWeight = 1 }] }, + { "rows[0].kind", [new { Kind = NightDutyRow, Label = "Nat", HeightWeight = 1, ChildId = Guid.NewGuid(), GuardianId = Guid.NewGuid() }] }, { "rows[0].calendarIds", [new { Kind = CalendarEvents, Label = "Ting", HeightWeight = 1, CalendarIds = Array.Empty() }] }, { "rows[0].calendarIds", [new { Kind = CalendarMarker, Label = "X", HeightWeight = 1, CalendarIds = new[] { Guid.Empty, Guid.Empty } }] }, { "rows[0].calendarIds", [new { Kind = CalendarMarker, Label = "X", HeightWeight = 1, CalendarIds = new Guid?[] { null } }] }, diff --git a/src/backend/buddy.IntegrationTests/Meta/OpenApiDocumentTests.cs b/src/backend/buddy.IntegrationTests/Meta/OpenApiDocumentTests.cs index 380e2b09..6fde888b 100644 --- a/src/backend/buddy.IntegrationTests/Meta/OpenApiDocumentTests.cs +++ b/src/backend/buddy.IntegrationTests/Meta/OpenApiDocumentTests.cs @@ -30,7 +30,7 @@ public sealed class OpenApiDocumentTests(BuddyApiFixture fixture) private static readonly string[] PartialDocuments = [ "v1", "users", "guardians", "groups", "tasklibrary", "calendars", "medicines", "mealplans", - "babysitters", "pickups", "worklocations", "printtemplates", "progress", "sleepdiaries", "houserules", "transitionwarnings", "feelings", "firstthen", "visitsummaries", + "babysitters", "pickups", "worklocations", "nightduty", "printtemplates", "progress", "sleepdiaries", "houserules", "transitionwarnings", "feelings", "firstthen", "visitsummaries", ]; [Fact] diff --git a/src/backend/buddy.IntegrationTests/SnapshotTests/NightScheduleSnapshotTests.cs b/src/backend/buddy.IntegrationTests/SnapshotTests/NightScheduleSnapshotTests.cs new file mode 100644 index 00000000..a831a2ff --- /dev/null +++ b/src/backend/buddy.IntegrationTests/SnapshotTests/NightScheduleSnapshotTests.cs @@ -0,0 +1,51 @@ +using buddy.Features.NightDuty; +using buddy.Features.Users; +using buddy.IntegrationTests.Features.Babysitters; +using buddy.IntegrationTests.Features.Guardians; +using buddy.IntegrationTests.Fixtures; + +using Microsoft.Extensions.DependencyInjection; + +using Xunit; + +using static buddy.IntegrationTests.Features.NightDuty.NightDutyTestHelpers; + +namespace buddy.IntegrationTests.SnapshotTests; + +// Verifies the inline NightScheduleSnapshotProjection stays exactly consistent with a full replay +// after commands covering every event type and every NightResponsible case. +[Collection(BuddyApiCollection.Name)] +public sealed class NightScheduleSnapshotTests(BuddyApiFixture fixture) +{ + [Fact] + public async Task The_snapshot_matches_a_full_replay_after_several_commands() + { + var (_, token, guardianId) = await fixture.CreateAuthenticatedUserAsync(); + var child = await GuardianTestHelpers.CreateChildAsync(fixture, token); + var maja = await BabysitterTestHelpers.AddAsync(fixture, token, "Maja"); + var monday = FutureMonday(); + + await ReplacePatternAsync(fixture, token, child.Id, monday, 2, monday, + Day(0, DayOfWeek.Monday, Guardian(guardianId)), + Day(0, DayOfWeek.Friday, NoteOnly("Screen night")), + Day(1, DayOfWeek.Tuesday, Babysitter(guardianId, maja.Id))); + await ReplacePatternAsync(fixture, token, child.Id, monday.AddDays(14), 1, monday, Day(0, DayOfWeek.Saturday, Sleepover("Grandma Lis"))); + await SetOverridesAsync(fixture, token, child.Id, + Override(monday, entry: null), + Override(monday.AddDays(1), Guardian(guardianId, "Movie")), + Override(monday.AddDays(2), Sleepover("Oscar's family", "Skovvej 1"))); + await ClearOverridesAsync(fixture, token, child.Id, monday, monday); + + var store = fixture.Host.Services.GetRequiredService(); + var id = NightScheduleId.ForChild(new UserId(child.Id)); + + var replayed = NightSchedule.Rehydrate(await store.ReadAsync(id, CancellationToken.None)); + var snapshot = await store.FindSnapshotAsync(id, CancellationToken.None); + + Assert.NotNull(replayed); + Assert.NotNull(snapshot); + Assert.Equal(2, replayed.Patterns.Count); + Assert.Equal(2, replayed.Overrides.Count); + Assert.Equivalent(replayed, snapshot, strict: true); + } +} diff --git a/src/backend/buddy/Common/FeatureFlags/FeatureOptions.cs b/src/backend/buddy/Common/FeatureFlags/FeatureOptions.cs index 6e7bf8d4..fa865ac5 100644 --- a/src/backend/buddy/Common/FeatureFlags/FeatureOptions.cs +++ b/src/backend/buddy/Common/FeatureFlags/FeatureOptions.cs @@ -25,6 +25,7 @@ public sealed class FeatureOptions public bool Pickups { get; set; } = true; public bool Babysitters { get; set; } = true; public bool WorkLocations { get; set; } = true; + public bool NightDuty { get; set; } = true; public bool Printing { get; set; } = true; public bool Progress { get; set; } = true; public bool TaskLibrary { get; set; } = true; @@ -55,6 +56,7 @@ public sealed class FeatureOptions Pickups, Babysitters, WorkLocations, + NightDuty, Printing, Progress, TaskLibrary, @@ -105,6 +107,7 @@ public sealed record InstallationFeatures( bool Pickups, bool Babysitters, bool WorkLocations, + bool NightDuty, bool Printing, bool Progress, bool TaskLibrary, diff --git a/src/backend/buddy/Common/WeekCycle.cs b/src/backend/buddy/Common/WeekCycle.cs new file mode 100644 index 00000000..2f0a47ad --- /dev/null +++ b/src/backend/buddy/Common/WeekCycle.cs @@ -0,0 +1,24 @@ +namespace buddy.Common; + +// Weekday cycles of 1–4 weeks counted from an anchor Monday (cycle week 0), shared by work +// locations (WorkPattern) and night duty (NightPattern). Anchored rather than based on ISO week +// parity because ISO parity drifts across 53-week years (2026 has a week 53, followed by an odd +// week 1) -- see docs/backend/analysis/work-locations.md, Question 4. +public static class WeekCycle +{ + public const int MaxCycleWeeks = 4; + + public static DateOnly MondayOnOrBefore(DateOnly date) => + date.AddDays(-(((int)date.DayOfWeek + 6) % 7)); + + public static int CycleWeekOf(DateOnly anchorMonday, int cycleWeeks, DateOnly date) + { + var weeks = (MondayOnOrBefore(date).DayNumber - anchorMonday.DayNumber) / 7; + + // Floored modulo, so dates before the anchor still land in the right cycle week. + return ((weeks % cycleWeeks) + cycleWeeks) % cycleWeeks; + } + + // Monday-first ordering for (Week, Day) sorting: Monday = 0 ... Sunday = 6. + public static int MondayFirstIndex(DayOfWeek day) => ((int)day + 6) % 7; +} diff --git a/src/backend/buddy/Features/Babysitters/BabysitterNames.cs b/src/backend/buddy/Features/Babysitters/BabysitterNames.cs new file mode 100644 index 00000000..6a8c255d --- /dev/null +++ b/src/backend/buddy/Features/Babysitters/BabysitterNames.cs @@ -0,0 +1,48 @@ +using buddy.Features.Users; + +namespace buddy.Features.Babysitters; + +// Resolves the names of the babysitters that pickups or nights refer to, loading each owning +// guardian's BabysitterList snapshot once per request. A reference is (GuardianId, BabysitterId); +// callers pass the owning guardians and look names up by BabysitterId. Archived babysitters still +// resolve, and so does a deleted account's list (UserDeleted leaves it in place); only an id +// missing from its owner's list reads as "". See docs/backend/analysis/babysitters.md, Question 4, +// and docs/backend/analysis/night-duty.md, "Refactors". +public sealed class BabysitterNames +{ + public static readonly BabysitterNames None = new(new Dictionary()); + + private readonly IReadOnlyDictionary _names; + + private BabysitterNames(IReadOnlyDictionary names) => _names = names; + + public string NameOf(BabysitterId babysitterId) => _names.GetValueOrDefault(babysitterId, ""); + + public static async Task LoadAsync( + IEnumerable owners, IBabysitterListEventStore babysitters, CancellationToken cancellationToken) + { + var distinctOwners = owners.ToHashSet(); + + if (distinctOwners.Count == 0) + { + return None; + } + + var names = new Dictionary(); + + foreach (var owner in distinctOwners) + { + if (await babysitters.FindSnapshotAsync(BabysitterListId.ForGuardian(owner), cancellationToken) is not { } list) + { + continue; + } + + foreach (var babysitter in list.Babysitters) + { + names[babysitter.Id] = babysitter.Name; + } + } + + return new BabysitterNames(names); + } +} diff --git a/src/backend/buddy/Features/Medicines/LogMedicineEffectNote/LogMedicineEffectNote.Handler.cs b/src/backend/buddy/Features/Medicines/LogMedicineEffectNote/LogMedicineEffectNote.Handler.cs index 9c5341c7..e0eda368 100644 --- a/src/backend/buddy/Features/Medicines/LogMedicineEffectNote/LogMedicineEffectNote.Handler.cs +++ b/src/backend/buddy/Features/Medicines/LogMedicineEffectNote/LogMedicineEffectNote.Handler.cs @@ -31,7 +31,7 @@ public static async Task> Handle( return access.ToDeniedResult(); } - if (await MedicineEffectNotes.ChildTodayAsync(command.ChildId, users, time, cancellationToken) is not { } today) + if (await UserLocalDate.TodayAsync(command.ChildId, users, time, cancellationToken) is not { } today) { return new Result.NotFound(); } diff --git a/src/backend/buddy/Features/Medicines/MedicineEffectNotes.cs b/src/backend/buddy/Features/Medicines/MedicineEffectNotes.cs index 4e56286c..1ce14d2a 100644 --- a/src/backend/buddy/Features/Medicines/MedicineEffectNotes.cs +++ b/src/backend/buddy/Features/Medicines/MedicineEffectNotes.cs @@ -42,21 +42,6 @@ public static IReadOnlyList DosesOn(MedicineSchedule? sched .Order() .Select(time => new RegimenDoseResponse(time, schedule.DoseLog.GetValueOrDefault((date, time), DoseStatus.Pending)))]; - // "Today" on the child's own wall clock (User.TimeZoneId). Null for an unknown or deleted child. - public static async Task ChildTodayAsync(UserId childId, IUserEventStore users, TimeProvider time, CancellationToken cancellationToken) - { - var child = await users.FindSnapshotAsync(childId, cancellationToken); - - if (child is null || child.IsDeleted) - { - return null; - } - - var local = TimeZoneInfo.ConvertTime(time.GetUtcNow(), TimeZoneInfo.FindSystemTimeZoneById(child.TimeZoneId.Value)); - - return DateOnly.FromDateTime(local.DateTime); - } - private static bool IsInWindow(MedicineSchedule schedule, DateOnly date) => schedule.StartDate <= date && (schedule.EndDate is not { } end || date <= end); } diff --git a/src/backend/buddy/Features/NightDuty/ClearNightOverrides/ClearNightOverrides.Command.cs b/src/backend/buddy/Features/NightDuty/ClearNightOverrides/ClearNightOverrides.Command.cs new file mode 100644 index 00000000..d6eb9870 --- /dev/null +++ b/src/backend/buddy/Features/NightDuty/ClearNightOverrides/ClearNightOverrides.Command.cs @@ -0,0 +1,11 @@ +using System.Security.Claims; + +using buddy.Features.Users; + +namespace buddy.Features.NightDuty; + +public sealed record ClearNightOverrides(UserId UserId, UserId ChildId, DateOnly From, DateOnly To) +{ + public static ClearNightOverrides FromClaims(ClaimsPrincipal principal, UserId childId, DateOnly from, DateOnly to) => + new(principal.GetRequiredUserId(), childId, from, to); +} diff --git a/src/backend/buddy/Features/NightDuty/ClearNightOverrides/ClearNightOverrides.Endpoint.cs b/src/backend/buddy/Features/NightDuty/ClearNightOverrides/ClearNightOverrides.Endpoint.cs new file mode 100644 index 00000000..5548174f --- /dev/null +++ b/src/backend/buddy/Features/NightDuty/ClearNightOverrides/ClearNightOverrides.Endpoint.cs @@ -0,0 +1,40 @@ +using System.Security.Claims; + +using buddy.Common; +using buddy.Features.Users; + +using Microsoft.AspNetCore.Http.HttpResults; + +using Wolverine; + +namespace buddy.Features.NightDuty; + +public static class ClearNightOverridesEndpoint +{ + public static RouteGroupBuilder MapClearNightOverrides(this RouteGroupBuilder nightDuty) + { + nightDuty.MapDelete("/children/{childId:guid}/overrides", async Task>> ( + ClaimsPrincipal principal, + Guid childId, + DateOnly from, + DateOnly to, + IMessageBus bus, + HttpContext httpContext, + CancellationToken cancellationToken) => + { + var command = ClearNightOverrides.FromClaims(principal, new UserId(childId), from, to); + var result = await bus.InvokeAsync>(command, cancellationToken); + + return result switch + { + Result.Success => TypedResults.NoContent(), + Result.Forbidden => TypedResults.Forbid(), + Result.Validation(var problem) => TypedResults.BadRequest(problem.ToEnvelope(httpContext)), + Result.NotFound => TypedResults.NotFound(), + }; + }) + .WithName("ClearNightOverrides"); + + return nightDuty; + } +} diff --git a/src/backend/buddy/Features/NightDuty/ClearNightOverrides/ClearNightOverrides.Handler.cs b/src/backend/buddy/Features/NightDuty/ClearNightOverrides/ClearNightOverrides.Handler.cs new file mode 100644 index 00000000..0900299d --- /dev/null +++ b/src/backend/buddy/Features/NightDuty/ClearNightOverrides/ClearNightOverrides.Handler.cs @@ -0,0 +1,46 @@ +using buddy.Common; +using buddy.Common.Validation; +using buddy.Features.Guardians; + +using FluentValidation; + +namespace buddy.Features.NightDuty; + +public static class ClearNightOverridesHandler +{ + public static async Task> Handle( + ClearNightOverrides command, + IValidator validator, + INightScheduleEventStore store, + IGuardianLinkEventStore guardians, + TimeProvider time, + CancellationToken cancellationToken) + { + if (await validator.ValidateCommandAsync(command, cancellationToken) is { } problem) + { + return new Result.Validation(problem); + } + + var access = await NightDutyAuthorization.CheckManage(command.ChildId, command.UserId, guardians, cancellationToken); + + if (access != NightDutyAccess.Allowed) + { + return access.ToDeniedResult(); + } + + var now = time.GetUtcNow(); + var loaded = await NightScheduleWriter.LoadAsync(store, command.ChildId, cancellationToken); + var schedule = loaded.Schedule; + + // Idempotent: only dates that actually hold an override emit an event, so clearing an empty + // range (or a child with no stream at all) is a no-op Success. + List events = [.. schedule.Overrides + .Where(entry => entry.Key >= command.From && entry.Key <= command.To) + .OrderBy(entry => entry.Key) + .Select(entry => (NightDutyEvent)new NightOverrideCleared(schedule.Id, entry.Key, entry.Value, command.UserId, now))]; + + await NightScheduleWriter.SaveAsync(store, loaded, events, now, cancellationToken); + + return new Result.Success(Unit.Value); + } +} diff --git a/src/backend/buddy/Features/NightDuty/ClearNightOverrides/ClearNightOverrides.Validator.cs b/src/backend/buddy/Features/NightDuty/ClearNightOverrides/ClearNightOverrides.Validator.cs new file mode 100644 index 00000000..ed07334d --- /dev/null +++ b/src/backend/buddy/Features/NightDuty/ClearNightOverrides/ClearNightOverrides.Validator.cs @@ -0,0 +1,13 @@ +using buddy.Common.Validation; + +using FluentValidation; + +namespace buddy.Features.NightDuty; + +public sealed class ClearNightOverridesValidator : AbstractValidator +{ + public ClearNightOverridesValidator() + { + this.ValidDateRange(x => x.From, x => x.To, NightDutyRules.MaxRangeDays); + } +} diff --git a/src/backend/buddy/Features/NightDuty/GetNightSchedule/GetNightSchedule.Command.cs b/src/backend/buddy/Features/NightDuty/GetNightSchedule/GetNightSchedule.Command.cs new file mode 100644 index 00000000..ea03bbbd --- /dev/null +++ b/src/backend/buddy/Features/NightDuty/GetNightSchedule/GetNightSchedule.Command.cs @@ -0,0 +1,11 @@ +using System.Security.Claims; + +using buddy.Features.Users; + +namespace buddy.Features.NightDuty; + +public sealed record GetNightSchedule(UserId UserId, UserId ChildId) +{ + public static GetNightSchedule FromClaims(ClaimsPrincipal principal, UserId childId) => + new(principal.GetRequiredUserId(), childId); +} diff --git a/src/backend/buddy/Features/NightDuty/GetNightSchedule/GetNightSchedule.Endpoint.cs b/src/backend/buddy/Features/NightDuty/GetNightSchedule/GetNightSchedule.Endpoint.cs new file mode 100644 index 00000000..84ac59fc --- /dev/null +++ b/src/backend/buddy/Features/NightDuty/GetNightSchedule/GetNightSchedule.Endpoint.cs @@ -0,0 +1,38 @@ +using System.Security.Claims; + +using buddy.Common; +using buddy.Features.Users; + +using Microsoft.AspNetCore.Http.HttpResults; + +using Wolverine; + +namespace buddy.Features.NightDuty; + +public static class GetNightScheduleEndpoint +{ + public static RouteGroupBuilder MapGetNightSchedule(this RouteGroupBuilder nightDuty) + { + nightDuty.MapGet("/children/{childId:guid}", async Task, NotFound, ForbidHttpResult>> ( + ClaimsPrincipal principal, + Guid childId, + IMessageBus bus, + CancellationToken cancellationToken) => + { + var query = GetNightSchedule.FromClaims(principal, new UserId(childId)); + var result = await bus.InvokeAsync>(query, cancellationToken); + + return result switch + { + Result.Success(var response) => TypedResults.Ok(response), + Result.Forbidden => TypedResults.Forbid(), + Result.NotFound => TypedResults.NotFound(), + // The handler has no validator -- a route-bound guid is the only input. + Result.Validation => TypedResults.NotFound(), + }; + }) + .WithName("GetNightSchedule"); + + return nightDuty; + } +} diff --git a/src/backend/buddy/Features/NightDuty/GetNightSchedule/GetNightSchedule.Handler.cs b/src/backend/buddy/Features/NightDuty/GetNightSchedule/GetNightSchedule.Handler.cs new file mode 100644 index 00000000..4e1e82eb --- /dev/null +++ b/src/backend/buddy/Features/NightDuty/GetNightSchedule/GetNightSchedule.Handler.cs @@ -0,0 +1,41 @@ +using buddy.Common; +using buddy.Features.Babysitters; +using buddy.Features.Guardians; +using buddy.Features.Users; + +namespace buddy.Features.NightDuty; + +// The editor's read: every fixed-schedule version, flagged past/current/upcoming against the +// child's today. Manage only -- the child reads resolved nights through ListNights, not the plan +// for weeks ahead (docs/backend/analysis/night-duty.md, Question 6). +public static class GetNightScheduleHandler +{ + public static async Task> Handle( + GetNightSchedule query, + INightScheduleEventStore store, + IGuardianLinkEventStore guardians, + IBabysitterListEventStore babysitters, + IUserEventStore users, + TimeProvider time, + CancellationToken cancellationToken) + { + var access = await NightDutyAuthorization.CheckManage(query.ChildId, query.UserId, guardians, cancellationToken); + + if (access != NightDutyAccess.Allowed) + { + return access.ToDeniedResult(); + } + + if (await UserLocalDate.TodayAsync(query.ChildId, users, time, cancellationToken) is not { } today) + { + return new Result.NotFound(); + } + + var schedule = await store.FindSnapshotAsync(NightScheduleId.ForChild(query.ChildId), cancellationToken) + ?? NightSchedule.Empty(query.ChildId); + var names = await BabysitterNames.LoadAsync( + schedule.Patterns.SelectMany(p => p.Days.Select(d => d.Entry)).BabysitterOwners(), babysitters, cancellationToken); + + return new Result.Success(NightScheduleResponse.From(schedule, today, names)); + } +} diff --git a/src/backend/buddy/Features/NightDuty/INightDutyStore.cs b/src/backend/buddy/Features/NightDuty/INightDutyStore.cs new file mode 100644 index 00000000..21d41ab8 --- /dev/null +++ b/src/backend/buddy/Features/NightDuty/INightDutyStore.cs @@ -0,0 +1,5 @@ +using Marten; + +namespace buddy.Features.NightDuty; + +public interface INightDutyStore : IDocumentStore; diff --git a/src/backend/buddy/Features/NightDuty/INightScheduleEventStore.cs b/src/backend/buddy/Features/NightDuty/INightScheduleEventStore.cs new file mode 100644 index 00000000..77627741 --- /dev/null +++ b/src/backend/buddy/Features/NightDuty/INightScheduleEventStore.cs @@ -0,0 +1,12 @@ +namespace buddy.Features.NightDuty; + +public interface INightScheduleEventStore +{ + Task> ReadAsync(NightScheduleId id, CancellationToken cancellationToken); + + Task FindSnapshotAsync(NightScheduleId id, CancellationToken cancellationToken); + + Task> CreateAsync(NightScheduleId id, IReadOnlyCollection events, CancellationToken cancellationToken); + + Task AppendAsync(NightScheduleId id, IReadOnlyCollection events, CancellationToken cancellationToken); +} diff --git a/src/backend/buddy/Features/NightDuty/ListNights/ListNights.Command.cs b/src/backend/buddy/Features/NightDuty/ListNights/ListNights.Command.cs new file mode 100644 index 00000000..ba4e3d7f --- /dev/null +++ b/src/backend/buddy/Features/NightDuty/ListNights/ListNights.Command.cs @@ -0,0 +1,11 @@ +using System.Security.Claims; + +using buddy.Features.Users; + +namespace buddy.Features.NightDuty; + +public sealed record ListNights(UserId UserId, UserId ChildId, DateOnly From, DateOnly To) +{ + public static ListNights FromClaims(ClaimsPrincipal principal, UserId childId, DateOnly from, DateOnly to) => + new(principal.GetRequiredUserId(), childId, from, to); +} diff --git a/src/backend/buddy/Features/NightDuty/ListNights/ListNights.Endpoint.cs b/src/backend/buddy/Features/NightDuty/ListNights/ListNights.Endpoint.cs new file mode 100644 index 00000000..3af8152a --- /dev/null +++ b/src/backend/buddy/Features/NightDuty/ListNights/ListNights.Endpoint.cs @@ -0,0 +1,42 @@ +using System.Security.Claims; + +using buddy.Common; +using buddy.Features.Users; + +using Microsoft.AspNetCore.Http.HttpResults; + +using Wolverine; + +namespace buddy.Features.NightDuty; + +public static class ListNightsEndpoint +{ + public static RouteGroupBuilder MapListNights(this RouteGroupBuilder nightDuty) + { + nightDuty.MapGet("/children/{childId:guid}/nights", async Task>, NotFound, BadRequest>> ( + ClaimsPrincipal principal, + Guid childId, + DateOnly from, + DateOnly to, + IMessageBus bus, + HttpContext httpContext, + CancellationToken cancellationToken) => + { + var query = ListNights.FromClaims(principal, new UserId(childId), from, to); + var result = await bus.InvokeAsync>>(query, cancellationToken); + + return result switch + { + Result>.Success(var nights) => TypedResults.Ok(nights), + Result>.Validation(var problem) => TypedResults.BadRequest(problem.ToEnvelope(httpContext)), + Result>.NotFound => TypedResults.NotFound(), + // CheckView never returns Forbidden -- there's no ForbidHttpResult in this route's + // declared results, so it collapses to NotFound. + Result>.Forbidden => TypedResults.NotFound(), + }; + }) + .WithName("ListNights"); + + return nightDuty; + } +} diff --git a/src/backend/buddy/Features/NightDuty/ListNights/ListNights.Handler.cs b/src/backend/buddy/Features/NightDuty/ListNights/ListNights.Handler.cs new file mode 100644 index 00000000..50acf359 --- /dev/null +++ b/src/backend/buddy/Features/NightDuty/ListNights/ListNights.Handler.cs @@ -0,0 +1,38 @@ +using buddy.Common; +using buddy.Common.Validation; +using buddy.Features.Babysitters; +using buddy.Features.Guardians; + +using FluentValidation; + +namespace buddy.Features.NightDuty; + +// Resolved nights for a guardian (the editor's week, the print sheet) and for the child themself +// (the "Tonight" card). +public static class ListNightsHandler +{ + public static async Task>> Handle( + ListNights query, + IValidator validator, + INightScheduleEventStore store, + IGuardianLinkEventStore guardians, + IBabysitterListEventStore babysitters, + CancellationToken cancellationToken) + { + if (await validator.ValidateCommandAsync(query, cancellationToken) is { } problem) + { + return new Result>.Validation(problem); + } + + var access = await NightDutyAuthorization.CheckView(query.ChildId, query.UserId, guardians, cancellationToken); + + if (access != NightDutyAccess.Allowed) + { + return access.ToDeniedResult>(); + } + + var nights = await NightExpansion.ExpandAsync(query.ChildId, query.From, query.To, store, babysitters, cancellationToken); + + return new Result>.Success(nights); + } +} diff --git a/src/backend/buddy/Features/NightDuty/ListNights/ListNights.Validator.cs b/src/backend/buddy/Features/NightDuty/ListNights/ListNights.Validator.cs new file mode 100644 index 00000000..c7851de3 --- /dev/null +++ b/src/backend/buddy/Features/NightDuty/ListNights/ListNights.Validator.cs @@ -0,0 +1,13 @@ +using buddy.Common.Validation; + +using FluentValidation; + +namespace buddy.Features.NightDuty; + +public sealed class ListNightsValidator : AbstractValidator +{ + public ListNightsValidator() + { + this.ValidDateRange(x => x.From, x => x.To, NightDutyRules.MaxRangeDays); + } +} diff --git a/src/backend/buddy/Features/NightDuty/MartenNightScheduleEventStore.cs b/src/backend/buddy/Features/NightDuty/MartenNightScheduleEventStore.cs new file mode 100644 index 00000000..d0c3ef19 --- /dev/null +++ b/src/backend/buddy/Features/NightDuty/MartenNightScheduleEventStore.cs @@ -0,0 +1,54 @@ +using buddy.Common.Concurrency; + +namespace buddy.Features.NightDuty; + +public sealed class MartenNightScheduleEventStore(INightDutyStore store) : INightScheduleEventStore +{ + public async Task> ReadAsync(NightScheduleId id, CancellationToken cancellationToken) + { + await using var session = store.QuerySession(); + var events = await session.Events.FetchStreamAsync(id.Value, token: cancellationToken); + session.ObserveStream(id.Value, events); + + return [.. events.Select(e => NightDutyEvent.FromPayload(e.Data))]; + } + + public async Task FindSnapshotAsync(NightScheduleId id, CancellationToken cancellationToken) + { + await using var session = store.QuerySession(); + var snapshot = await session.LoadAsync(id.Value, cancellationToken); + + return snapshot?.NightSchedule; + } + + public async Task> CreateAsync(NightScheduleId id, IReadOnlyCollection events, CancellationToken cancellationToken) + { + if (events.FirstOrDefault() is not NightScheduleStarted) + { + throw new InvalidOperationException("The first event of a new night schedule stream must be NightScheduleStarted."); + } + + await using var session = store.LightweightSession(); + session.StartTrackedStream(id.Value, ToPayloads(events)); + + await session.SaveChangesAsync(cancellationToken); + + return events; + } + + public async Task AppendAsync(NightScheduleId id, IReadOnlyCollection events, CancellationToken cancellationToken) + { + if (events.Count == 0) + { + return; + } + + await using var session = store.LightweightSession(); + session.AppendTracked(id.Value, ToPayloads(events)); + + await session.SaveChangesAsync(cancellationToken); + } + + private static object[] ToPayloads(IReadOnlyCollection events) => + [.. events.Select(e => e.Value ?? throw new InvalidOperationException("Cannot persist an empty night duty event."))]; +} diff --git a/src/backend/buddy/Features/NightDuty/NightDuty.http b/src/backend/buddy/Features/NightDuty/NightDuty.http new file mode 100644 index 00000000..20d5f63b --- /dev/null +++ b/src/backend/buddy/Features/NightDuty/NightDuty.http @@ -0,0 +1,55 @@ +@buddy_HostAddress = http://localhost:5193 + +### Save a fixed night schedule from a date (guardian of the child only) +# A 2-week cycle anchored on a Monday: week A Mom on Mondays, Friday a screen night, week B Saturday +# a sleepover. Omit effectiveFrom to start today (in the child's time zone); a past date is a 400. +# responsible.kind: 0 guardian, 1 babysitter, 2 sleepover, 3 unassigned (a note on its own). + +PUT {{buddy_HostAddress}}/night-duty/children/{{childId}}/pattern +Authorization: Bearer {{guardianToken}} +Content-Type: application/json + +{ + "effectiveFrom": "2026-11-16", + "cycleWeeks": 2, + "anchorMonday": "2026-11-16", + "days": [ + { "week": 0, "day": "Monday", "entry": { "responsible": { "kind": 0, "guardianId": "{{guardianId}}" } } }, + { "week": 0, "day": "Friday", "entry": { "responsible": { "kind": 3 }, "note": "Screen night 📱" } }, + { "week": 1, "day": "Saturday", "entry": { "responsible": { "kind": 2, "hostName": "Grandma Lis", "location": "Skovvej 12" } } } + ] +} + +### + +# Change single nights. A switch is two nights in one request; "entry": null clears a night +# ("nothing planned"). Returns the resolved nights that were set. +PUT {{buddy_HostAddress}}/night-duty/children/{{childId}}/overrides +Authorization: Bearer {{guardianToken}} +Content-Type: application/json + +{ + "nights": [ + { "date": "2026-11-17", "entry": { "responsible": { "kind": 0, "guardianId": "{{otherGuardianId}}" } } }, + { "date": "2026-11-19", "entry": { "responsible": { "kind": 0, "guardianId": "{{guardianId}}" } } }, + { "date": "2026-11-21", "entry": null } + ] +} + +### + +# Remove overrides in a range; those nights follow the fixed schedule again (idempotent) +DELETE {{buddy_HostAddress}}/night-duty/children/{{childId}}/overrides?from=2026-11-16&to=2026-11-22 +Authorization: Bearer {{guardianToken}} + +### + +# Every fixed-schedule version, flagged Past / Current / Upcoming (guardian only) +GET {{buddy_HostAddress}}/night-duty/children/{{childId}} +Authorization: Bearer {{guardianToken}} + +### + +# Resolved nights (guardian or the child themself), at most 31 days +GET {{buddy_HostAddress}}/night-duty/children/{{childId}}/nights?from=2026-11-16&to=2026-11-22 +Authorization: Bearer {{guardianToken}} diff --git a/src/backend/buddy/Features/NightDuty/NightDutyAuthorization.cs b/src/backend/buddy/Features/NightDuty/NightDutyAuthorization.cs new file mode 100644 index 00000000..ddc22ee0 --- /dev/null +++ b/src/backend/buddy/Features/NightDuty/NightDutyAuthorization.cs @@ -0,0 +1,75 @@ +using System.Diagnostics; + +using buddy.Common; +using buddy.Features.Guardians; +using buddy.Features.Users; + +namespace buddy.Features.NightDuty; + +// The Pickups tiers (docs/backend/analysis/night-duty.md, Question 6): an active guardian of the +// child Manages, the child themself Views resolved nights, everyone else gets NotFound. Copied +// rather than shared with PickupAuthorization so the two features stay independent, the same +// trade-off WorkLocationAuthorization made. +public enum NightDutyAccessTier +{ + None, + // The child themself: read resolved nights only. + View, + // An active guardian: everything. + Manage +} + +public enum NightDutyAccess +{ + Allowed, + // No relationship to the child at all. + NotFound, + // The caller can View but the action needs Manage (the child calling a write or the editor read). + Forbidden +} + +public static class NightDutyAccessExtensions +{ + public static Result ToDeniedResult(this NightDutyAccess access) => access switch + { + NightDutyAccess.Forbidden => new Result.Forbidden(), + NightDutyAccess.NotFound => new Result.NotFound(), + NightDutyAccess.Allowed => throw new UnreachableException("ToDeniedResult called with NightDutyAccess.Allowed."), + _ => throw new UnreachableException($"Unrecognized NightDutyAccess value: {access}."), + }; +} + +public static class NightDutyAuthorization +{ + public static async Task CheckView(UserId childId, UserId callerId, IGuardianLinkEventStore guardians, CancellationToken cancellationToken) + { + var tier = await ResolveTier(childId, callerId, guardians, cancellationToken); + + return tier == NightDutyAccessTier.None ? NightDutyAccess.NotFound : NightDutyAccess.Allowed; + } + + public static async Task CheckManage(UserId childId, UserId callerId, IGuardianLinkEventStore guardians, CancellationToken cancellationToken) + { + var tier = await ResolveTier(childId, callerId, guardians, cancellationToken); + + return tier switch + { + NightDutyAccessTier.Manage => NightDutyAccess.Allowed, + NightDutyAccessTier.View => NightDutyAccess.Forbidden, + NightDutyAccessTier.None => NightDutyAccess.NotFound, + _ => throw new UnreachableException($"Unrecognized NightDutyAccessTier value: {tier}."), + }; + } + + private static async Task ResolveTier(UserId childId, UserId callerId, IGuardianLinkEventStore guardians, CancellationToken cancellationToken) + { + if (callerId == childId) + { + return NightDutyAccessTier.View; + } + + var link = await guardians.FindActiveLinkAsync(childId, callerId, cancellationToken); + + return link is not null ? NightDutyAccessTier.Manage : NightDutyAccessTier.None; + } +} diff --git a/src/backend/buddy/Features/NightDuty/NightDutyFeature.cs b/src/backend/buddy/Features/NightDuty/NightDutyFeature.cs new file mode 100644 index 00000000..0d3b4f5c --- /dev/null +++ b/src/backend/buddy/Features/NightDuty/NightDutyFeature.cs @@ -0,0 +1,92 @@ +using buddy.Common.Erasure; +using buddy.Common.Http; +using buddy.Common.OpenApi; +using buddy.Common.Postgres; +using buddy.Serialization; + +using JasperFx.Events; +using JasperFx.Events.Projections; + +using Marten; + +using Npgsql; + +using Weasel.Core; + +namespace buddy.Features.NightDuty; + +public static class NightDutyFeature +{ + public const string OpenApiDocumentName = "nightduty"; + + private static readonly Type[] EventTypes = + [ + typeof(NightScheduleStarted), + typeof(NightPatternReplaced), + typeof(NightOverridden), + typeof(NightOverrideCleared) + ]; + + // Depends on IGuardianLinkEventStore (authorization, guardian checks), IBabysitterListEventStore + // (babysitter checks and names) and IUserEventStore (the child's today), so the Guardians, + // Babysitters and Users features must be registered first. + public static IServiceCollection AddNightDutyFeature(this IServiceCollection services, IConfiguration configuration) + { + services.AddBuddyOpenApiDocument(OpenApiDocumentName); + + // Shared process-wide pool -- see PostgresDataSource. + services.AddPostgresDataSource(configuration); + // Erases this feature's part of a person (docs/backend/analysis/gdpr-data-protection.md). + services.AddSingleton(); + services.AddSingleton(); + + services.AddMartenStore(serviceProvider => + { + var options = new StoreOptions(); + options.Connection(serviceProvider.GetRequiredService()); + options.DatabaseSchemaName = "nightduty"; + options.Events.StreamIdentity = StreamIdentity.AsGuid; + options.Events.AddEventTypes(EventTypes); + + options.UseSystemTextJsonForSerialization( + enumStorage: EnumStorage.AsString, + configure: json => + { + json.Converters.Add(new StronglyTypedIdJsonConverterFactory()); + + // The two unions persisted in the events and the snapshot need explicit Kind + // discriminators -- see NightResponsibleJsonConverter. + json.Converters.Add(new NightResponsibleJsonConverter()); + json.Converters.Add(new NightOverrideJsonConverter()); + }); + + // Inline snapshot in the shared "snapshots" schema, registered via Register() for the + // same reason as WorkLocationsFeature. See docs/backend/analysis/event-stream-snapshots.md. + options.Projections.Register(new NightScheduleSnapshotProjection(), ProjectionLifecycle.Inline); + options.Schema.For().DatabaseSchemaName(SnapshotSchema.Name); + + return options; + }); + + services.AddSingleton(); + + return services; + } + + public static IEndpointRouteBuilder MapNightDutyFeature(this IEndpointRouteBuilder endpoints) + { + var nightDuty = endpoints.MapGroup("/night-duty") + .WithTags("NightDuty") + .RequireAuthorization() + .WithGroupName(OpenApiDocumentName) + .WithETag(); + + nightDuty.MapReplaceNightPattern(); + nightDuty.MapSetNightOverrides(); + nightDuty.MapClearNightOverrides(); + nightDuty.MapGetNightSchedule(); + nightDuty.MapListNights(); + + return endpoints; + } +} diff --git a/src/backend/buddy/Features/NightDuty/NightDutyPersonalData.cs b/src/backend/buddy/Features/NightDuty/NightDutyPersonalData.cs new file mode 100644 index 00000000..f9ed98d1 --- /dev/null +++ b/src/backend/buddy/Features/NightDuty/NightDutyPersonalData.cs @@ -0,0 +1,18 @@ +using buddy.Common.Erasure; +using buddy.Features.Users; + +namespace buddy.Features.NightDuty; + +// A child's night schedule -- with its sleepover hosts' names, addresses and contact details -- is +// the child's alone: erasing the child deletes it. Erasing a guardian leaves it in place, as for +// pickups; nights naming them stop resolving a name. See gdpr-data-protection.md and +// docs/backend/analysis/night-duty.md, "GDPR". +public sealed class NightDutyPersonalDataEraser(INightDutyStore store) : IPersonalDataEraser +{ + public Type Store => typeof(INightDutyStore); + + public Task EraseGuardianAsync(ErasureSubject guardian, CancellationToken cancellationToken) => Task.CompletedTask; + + public Task EraseChildAsync(ErasureSubject child, UserId? heir, CancellationToken cancellationToken) => + store.DeleteStreamAsync(NightScheduleId.ForChild(child.UserId).Value, cancellationToken); +} diff --git a/src/backend/buddy/Features/NightDuty/NightDutyPersonalDataExport.cs b/src/backend/buddy/Features/NightDuty/NightDutyPersonalDataExport.cs new file mode 100644 index 00000000..e7375e59 --- /dev/null +++ b/src/backend/buddy/Features/NightDuty/NightDutyPersonalDataExport.cs @@ -0,0 +1,49 @@ +using buddy.Common.Erasure; +using buddy.Features.Babysitters; + +namespace buddy.Features.NightDuty; + +// The "nightDuty" section: each child's fixed-schedule versions and per-night overrides. +public sealed class NightDutyPersonalDataExporter(INightScheduleEventStore schedules, IBabysitterListEventStore babysitters) : IPersonalDataExporter +{ + public Type Store => typeof(INightDutyStore); + + public string Section => "nightDuty"; + + public async Task ExportAsync(ExportSubject subject, CancellationToken cancellationToken) + { + List children = []; + + foreach (var childId in subject.Children) + { + if (await schedules.FindSnapshotAsync(NightScheduleId.ForChild(childId), cancellationToken) is not { } schedule) + { + continue; + } + + var entries = schedule.Patterns.SelectMany(p => p.Days.Select(d => d.Entry)) + .Concat(schedule.Overrides.Values.Select(o => o.PlannedEntry).OfType()); + var names = await BabysitterNames.LoadAsync(entries.BabysitterOwners(), babysitters, cancellationToken); + + children.Add(new ExportedNightSchedule( + childId.Value, + [.. schedule.Patterns.Select(p => new ExportedNightPattern( + p.EffectiveFrom, + p.CycleWeeks, + p.AnchorMonday, + [.. p.Days.Select(d => new NightPatternDayDto(d.Week, d.Day, NightEntryDto.FromDomain(d.Entry, names)))]))], + [.. schedule.Overrides.OrderBy(o => o.Key).Select(o => new ExportedNightOverride( + o.Key, + o.Value.PlannedEntry is { } entry ? NightEntryDto.FromDomain(entry, names) : null))])); + } + + return children; + } +} + +public sealed record ExportedNightSchedule(Guid ChildId, IReadOnlyList Patterns, IReadOnlyList Overrides); + +public sealed record ExportedNightPattern(DateOnly EffectiveFrom, int CycleWeeks, DateOnly AnchorMonday, IReadOnlyList Days); + +// Entry null = the night was cleared ("nothing planned"). +public sealed record ExportedNightOverride(DateOnly Date, NightEntryDto? Entry); diff --git a/src/backend/buddy/Features/NightDuty/NightDutyRules.cs b/src/backend/buddy/Features/NightDuty/NightDutyRules.cs new file mode 100644 index 00000000..cdf73848 --- /dev/null +++ b/src/backend/buddy/Features/NightDuty/NightDutyRules.cs @@ -0,0 +1,118 @@ +using buddy.Common.Validation; +using buddy.Features.Babysitters; +using buddy.Features.Guardians; +using buddy.Features.Users; + +using FluentValidation; + +namespace buddy.Features.NightDuty; + +// Limits from docs/backend/analysis/night-duty.md, "Validation limits". +internal static class NightDutyRules +{ + public const int MaxNoteLength = 60; + + // Same limits as a pickup Playdate's free-text fields. + public const int MaxHostNameLength = 200; + public const int MaxLocationLength = 200; + public const int MaxContactInfoLength = 2000; + + // Same cap as the work-location and pickup ranges, so one request never writes or reads more + // than a month of nights. + public const int MaxRangeDays = 31; + + // Checks the people an entry names, against the child's current guardian links and the + // babysitter lists -- the AssignPickup relationship checks. Each distinct responsible person is + // checked once, and only if the nights being replaced don't already name them (`unchanged`): a + // night that names a since archived babysitter (or a removed guardian) keeps resolving, so + // re-saving it -- or switching it -- must not be refused + // (docs/backend/analysis/babysitters.md, Question 3). Returns a validation problem, or null + // when every entry is acceptable. + public static async Task CheckRelationshipsAsync( + UserId childId, + IEnumerable entries, + IEnumerable unchanged, + IGuardianLinkEventStore guardians, + IBabysitterListEventStore babysitters, + CancellationToken cancellationToken) + { + var known = unchanged.Select(e => e.Responsible).ToHashSet(); + var responsibles = entries.Select(e => e.Responsible).Distinct().Where(r => !known.Contains(r)).ToList(); + + foreach (var responsible in responsibles) + { + if (await CheckRelationshipAsync(childId, responsible, guardians, babysitters, cancellationToken) is { } problem) + { + return problem; + } + } + + return null; + } + + private static async Task CheckRelationshipAsync( + UserId childId, + NightResponsible responsible, + IGuardianLinkEventStore guardians, + IBabysitterListEventStore babysitters, + CancellationToken cancellationToken) + { + var guardianId = responsible switch + { + NightResponsible.Guardian guardian => guardian.GuardianId, + NightResponsible.Babysitter babysitter => babysitter.GuardianId, + NightResponsible.Sleepover or NightResponsible.Unassigned => null, + }; + + if (guardianId is null) + { + return null; + } + + if (await guardians.FindActiveLinkAsync(childId, guardianId, cancellationToken) is null) + { + return ValidationProblem.Of("guardianId is not an active guardian of this child."); + } + + if (responsible is NightResponsible.Babysitter named && !await IsActiveBabysitterAsync(named, babysitters, cancellationToken)) + { + return ValidationProblem.Of("babysitterId is not an active babysitter of that guardian."); + } + + return null; + } + + // Archived babysitters can't be picked for a new entry; nights already naming one keep resolving + // (docs/backend/analysis/babysitters.md, Question 3). + private static async Task IsActiveBabysitterAsync(NightResponsible.Babysitter babysitter, IBabysitterListEventStore babysitters, CancellationToken cancellationToken) => + await babysitters.FindSnapshotAsync(BabysitterListId.ForGuardian(babysitter.GuardianId), cancellationToken) is { } list + && list.FindActive(babysitter.BabysitterId) is not null; +} + +// Structural rules for one entry, shared by ReplaceNightPattern (each day) and SetNightOverrides +// (each planned night). Error keys are camelCase JSON names. +public sealed class NightEntryValidator : AbstractValidator +{ + public NightEntryValidator() + { + RuleFor(e => e.Note) + .MaximumLength(NightDutyRules.MaxNoteLength) + .OverridePropertyName("note"); + + RuleFor(e => e) + .Must(e => !e.IsEmpty) + .WithMessage("A night needs a responsible person, a note, or both.") + .OverridePropertyName("responsible"); + + RuleFor(e => e.Responsible) + .Must(r => r is not NightResponsible.Sleepover sleepover || sleepover.HostName.Length > 0) + .WithMessage("A sleepover requires a hostName.") + .Must(r => r is not NightResponsible.Sleepover sleepover || sleepover.HostName.Length <= NightDutyRules.MaxHostNameLength) + .WithMessage($"hostName can be at most {NightDutyRules.MaxHostNameLength} characters.") + .Must(r => r is not NightResponsible.Sleepover sleepover || sleepover.Location.Length <= NightDutyRules.MaxLocationLength) + .WithMessage($"location can be at most {NightDutyRules.MaxLocationLength} characters.") + .Must(r => r is not NightResponsible.Sleepover sleepover || sleepover.ContactInfo.Length <= NightDutyRules.MaxContactInfoLength) + .WithMessage($"contactInfo can be at most {NightDutyRules.MaxContactInfoLength} characters.") + .OverridePropertyName("responsible"); + } +} diff --git a/src/backend/buddy/Features/NightDuty/NightExpansion.cs b/src/backend/buddy/Features/NightDuty/NightExpansion.cs new file mode 100644 index 00000000..ff78f97b --- /dev/null +++ b/src/backend/buddy/Features/NightDuty/NightExpansion.cs @@ -0,0 +1,94 @@ +using buddy.Features.Babysitters; +using buddy.Features.Users; + +namespace buddy.Features.NightDuty; + +// Resolves a child's Nights within [from, to]: override -> the fixed-schedule version in effect -> +// nothing. Recomputed from current aggregate state on every call, never persisted -- the same +// contract WorkLocationExpansion has. Dates are the child's wall-clock dates. +public static class NightExpansion +{ + public static async Task> ExpandAsync( + UserId childId, + DateOnly from, + DateOnly to, + INightScheduleEventStore store, + IBabysitterListEventStore babysitters, + CancellationToken cancellationToken) + { + var schedule = await store.FindSnapshotAsync(NightScheduleId.ForChild(childId), cancellationToken) + ?? NightSchedule.Empty(childId); + + return await ExpandAsync(schedule, from, to, babysitters, cancellationToken); + } + + public static async Task> ExpandAsync( + NightSchedule schedule, DateOnly from, DateOnly to, IBabysitterListEventStore babysitters, CancellationToken cancellationToken) + { + var resolved = EachDate(from, to).Select(date => Resolve(schedule, date)).ToList(); + var names = await BabysitterNames.LoadAsync( + resolved.SelectMany(r => new[] { r.Entry, r.Scheduled }).OfType().BabysitterOwners(), + babysitters, + cancellationToken); + + return [.. resolved.Select(r => r.ToNight(names))]; + } + + private static IEnumerable EachDate(DateOnly from, DateOnly to) + { + for (var date = from; date <= to; date = date.AddDays(1)) + { + yield return date; + } + } + + private static ResolvedNight Resolve(NightSchedule schedule, DateOnly date) + { + var scheduled = schedule.ScheduledEntryFor(date); + + if (schedule.Overrides.TryGetValue(date, out var @override)) + { + return @override switch + { + NightOverride.Planned planned => new ResolvedNight(date, planned.Entry, NightSource.Override, scheduled), + NightOverride.Cleared => new ResolvedNight(date, Entry: null, NightSource.Override, scheduled), + }; + } + + return new ResolvedNight(date, scheduled, NightSource.Pattern, Scheduled: null); + } + + // Entry null + Source Override = Cleared; Entry null + Source Pattern = Unplanned. + private sealed record ResolvedNight(DateOnly Date, NightEntry? Entry, NightSource Source, NightEntry? Scheduled) + { + public Night ToNight(BabysitterNames names) + { + var scheduled = Scheduled is null ? null : NightEntryDto.FromDomain(Scheduled, names); + + NightStatus status = (Entry, Source) switch + { + ({ } entry, _) => new NightStatus.NightPlanned(NightEntryDto.FromDomain(entry, names), Source, scheduled), + (null, NightSource.Override) => new NightStatus.NightCleared(scheduled), + (null, _) => new NightStatus.NightUnplanned(), + }; + + return new Night(Date, status); + } + } +} + +internal static class NightBabysitters +{ + // The guardians whose babysitter lists a set of entries refers to, for BabysitterNames.LoadAsync. + // A union boxes as itself, not as its case, so OfType() would never match. + public static IEnumerable BabysitterOwners(this IEnumerable entries) + { + foreach (var entry in entries) + { + if (entry.Responsible is NightResponsible.Babysitter babysitter) + { + yield return babysitter.GuardianId; + } + } + } +} diff --git a/src/backend/buddy/Features/NightDuty/NightScheduleWriter.cs b/src/backend/buddy/Features/NightDuty/NightScheduleWriter.cs new file mode 100644 index 00000000..7f6f4c84 --- /dev/null +++ b/src/backend/buddy/Features/NightDuty/NightScheduleWriter.cs @@ -0,0 +1,50 @@ +using buddy.Features.Users; + +namespace buddy.Features.NightDuty; + +// The read-decide-append steps every write slice shares. The stream is created lazily by the first +// write (NightScheduleStarted bundled into the same CreateAsync), as WorkLocationScheduleWriter +// does. Loading through ReadAsync keeps the append expected-version (StreamVersionTracker). +internal sealed record LoadedNightSchedule(NightSchedule Schedule, bool Exists); + +internal static class NightScheduleWriter +{ + public static async Task LoadAsync(INightScheduleEventStore store, UserId childId, CancellationToken cancellationToken) + { + var events = await store.ReadAsync(NightScheduleId.ForChild(childId), cancellationToken); + + return NightSchedule.Rehydrate(events) is { } schedule + ? new LoadedNightSchedule(schedule, Exists: true) + : new LoadedNightSchedule(NightSchedule.Empty(childId), Exists: false); + } + + // Persists newEvents (no-op when empty) and returns the schedule with them applied. + public static async Task SaveAsync( + INightScheduleEventStore store, + LoadedNightSchedule loaded, + IReadOnlyList newEvents, + DateTimeOffset now, + CancellationToken cancellationToken) + { + if (newEvents.Count == 0) + { + return loaded.Schedule; + } + + var schedule = loaded.Schedule; + + if (loaded.Exists) + { + await store.AppendAsync(schedule.Id, newEvents, cancellationToken); + } + else + { + await store.CreateAsync( + schedule.Id, + [new NightScheduleStarted(schedule.Id, schedule.ChildId, now), .. newEvents], + cancellationToken); + } + + return newEvents.Aggregate(schedule, (current, e) => NightSchedule.Advance(current, e)); + } +} diff --git a/src/backend/buddy/Features/NightDuty/ReplaceNightPattern/ReplaceNightPattern.Command.cs b/src/backend/buddy/Features/NightDuty/ReplaceNightPattern/ReplaceNightPattern.Command.cs new file mode 100644 index 00000000..e471c204 --- /dev/null +++ b/src/backend/buddy/Features/NightDuty/ReplaceNightPattern/ReplaceNightPattern.Command.cs @@ -0,0 +1,14 @@ +using System.Security.Claims; + +using buddy.Features.Users; + +namespace buddy.Features.NightDuty; + +// EffectiveFrom null = "from today" on the child's wall clock (resolved by the handler). +public sealed record ReplaceNightPattern( + UserId UserId, UserId ChildId, DateOnly? EffectiveFrom, int CycleWeeks, DateOnly AnchorMonday, IReadOnlyList Days) +{ + public static ReplaceNightPattern FromClaims( + ClaimsPrincipal principal, UserId childId, DateOnly? effectiveFrom, int cycleWeeks, DateOnly anchorMonday, IReadOnlyList days) => + new(principal.GetRequiredUserId(), childId, effectiveFrom, cycleWeeks, anchorMonday, days); +} diff --git a/src/backend/buddy/Features/NightDuty/ReplaceNightPattern/ReplaceNightPattern.Endpoint.cs b/src/backend/buddy/Features/NightDuty/ReplaceNightPattern/ReplaceNightPattern.Endpoint.cs new file mode 100644 index 00000000..ae8b7d84 --- /dev/null +++ b/src/backend/buddy/Features/NightDuty/ReplaceNightPattern/ReplaceNightPattern.Endpoint.cs @@ -0,0 +1,57 @@ +using System.Security.Claims; + +using buddy.Common; +using buddy.Features.Users; + +using Microsoft.AspNetCore.Http.HttpResults; + +using Wolverine; + +namespace buddy.Features.NightDuty; + +public static class ReplaceNightPatternEndpoint +{ + public static RouteGroupBuilder MapReplaceNightPattern(this RouteGroupBuilder nightDuty) + { + nightDuty.MapPut("/children/{childId:guid}/pattern", async Task, NotFound, ForbidHttpResult, BadRequest>> ( + ClaimsPrincipal principal, + Guid childId, + ReplaceNightPatternRequest request, + IMessageBus bus, + HttpContext httpContext, + CancellationToken cancellationToken) => + { + // A null element can't reach the validator (it would throw building the domain record). + // RespectNullableAnnotations doesn't cover collection elements, so this stays. + if (request.Days.Any(d => d is null || d.Entry is null || d.Entry.Responsible is null)) + { + return TypedResults.BadRequest(Common.Validation.ValidationProblem.Of("days must not contain null entries.").ToEnvelope(httpContext)); + } + + var command = ReplaceNightPattern.FromClaims( + principal, + new UserId(childId), + request.EffectiveFrom, + request.CycleWeeks, + request.AnchorMonday, + [.. request.Days.Select(d => new NightPatternDay(d.Week, d.Day, d.Entry.ToDomain()))]); + var result = await bus.InvokeAsync>(command, cancellationToken); + + return result switch + { + Result.Success(var response) => TypedResults.Ok(response), + Result.Forbidden => TypedResults.Forbid(), + Result.Validation(var problem) => TypedResults.BadRequest(problem.ToEnvelope(httpContext)), + Result.NotFound => TypedResults.NotFound(), + }; + }) + .WithName("ReplaceNightPattern"); + + return nightDuty; + } +} + +// EffectiveFrom is optional: omitted means "from today" in the child's time zone. +public sealed record ReplaceNightPatternRequest(int CycleWeeks, DateOnly AnchorMonday, IReadOnlyList Days, DateOnly? EffectiveFrom = null); + +public sealed record NightPatternDayRequest(int Week, DayOfWeek Day, NightEntryDto Entry); diff --git a/src/backend/buddy/Features/NightDuty/ReplaceNightPattern/ReplaceNightPattern.Handler.cs b/src/backend/buddy/Features/NightDuty/ReplaceNightPattern/ReplaceNightPattern.Handler.cs new file mode 100644 index 00000000..d8738f57 --- /dev/null +++ b/src/backend/buddy/Features/NightDuty/ReplaceNightPattern/ReplaceNightPattern.Handler.cs @@ -0,0 +1,85 @@ +using buddy.Common; +using buddy.Common.Validation; +using buddy.Features.Babysitters; +using buddy.Features.Guardians; +using buddy.Features.Users; + +using FluentValidation; + +namespace buddy.Features.NightDuty; + +public static class ReplaceNightPatternHandler +{ + public static async Task> Handle( + ReplaceNightPattern command, + IValidator validator, + INightScheduleEventStore store, + IGuardianLinkEventStore guardians, + IBabysitterListEventStore babysitters, + IUserEventStore users, + TimeProvider time, + CancellationToken cancellationToken) + { + if (await validator.ValidateCommandAsync(command, cancellationToken) is { } problem) + { + return new Result.Validation(problem); + } + + var access = await NightDutyAuthorization.CheckManage(command.ChildId, command.UserId, guardians, cancellationToken); + + if (access != NightDutyAccess.Allowed) + { + return access.ToDeniedResult(); + } + + if (await UserLocalDate.TodayAsync(command.ChildId, users, time, cancellationToken) is not { } today) + { + return new Result.NotFound(); + } + + // Past nights are frozen: a schedule save never reaches back before the child's today + // (docs/backend/analysis/night-duty.md, Question 4). + var effectiveFrom = command.EffectiveFrom ?? today; + + if (effectiveFrom < today) + { + return new Result.Validation(ValidationProblem.Of("effectiveFrom can't be in the past.")); + } + + var now = time.GetUtcNow(); + var loaded = await NightScheduleWriter.LoadAsync(store, command.ChildId, cancellationToken); + var schedule = loaded.Schedule; + + // People already on the versions this save replaces (the one in effect on effectiveFrom and + // any later one) aren't checked again. + var replaced = schedule.Patterns + .Where(p => p.EffectiveFrom >= effectiveFrom || p == schedule.PatternFor(effectiveFrom)) + .SelectMany(p => p.Days.Select(d => d.Entry)); + + if (await NightDutyRules.CheckRelationshipsAsync( + command.ChildId, command.Days.Select(d => d.Entry), replaced, guardians, babysitters, cancellationToken) is { } relationshipProblem) + { + return new Result.Validation(relationshipProblem); + } + + var pattern = new NightPattern(effectiveFrom, command.CycleWeeks, command.AnchorMonday, command.Days).Normalized(); + + // A no-op when the version in effect on that date is the last one and already says the same: + // saving it again would neither change a night nor drop a later version. + var inEffect = schedule.PatternFor(effectiveFrom); + var unchanged = inEffect is not null + && ReferenceEquals(inEffect, schedule.Patterns[^1]) + && inEffect.IsSameAs(pattern with { EffectiveFrom = inEffect.EffectiveFrom }); + + if (!unchanged) + { + schedule = await NightScheduleWriter.SaveAsync( + store, loaded, [new NightPatternReplaced(schedule.Id, pattern, command.UserId, now)], now, cancellationToken); + } + + var names = await BabysitterNames.LoadAsync( + schedule.Patterns.SelectMany(p => p.Days.Select(d => d.Entry)).BabysitterOwners(), babysitters, cancellationToken); + + return new Result.Success(NightScheduleResponse.From(schedule, today, names)); + } +} diff --git a/src/backend/buddy/Features/NightDuty/ReplaceNightPattern/ReplaceNightPattern.Validator.cs b/src/backend/buddy/Features/NightDuty/ReplaceNightPattern/ReplaceNightPattern.Validator.cs new file mode 100644 index 00000000..66989b47 --- /dev/null +++ b/src/backend/buddy/Features/NightDuty/ReplaceNightPattern/ReplaceNightPattern.Validator.cs @@ -0,0 +1,36 @@ +using FluentValidation; + +namespace buddy.Features.NightDuty; + +// Structural rules only. "EffectiveFrom is not in the past" needs the child's time zone, and the +// guardian/babysitter checks need other aggregates -- both stay in ReplaceNightPatternHandler. +public sealed class ReplaceNightPatternValidator : AbstractValidator +{ + public ReplaceNightPatternValidator() + { + RuleFor(x => x.CycleWeeks) + .InclusiveBetween(1, NightPattern.MaxCycleWeeks) + .OverridePropertyName("cycleWeeks"); + + RuleFor(x => x.AnchorMonday) + .Must(date => date.DayOfWeek == DayOfWeek.Monday) + .WithMessage("anchorMonday must be a Monday.") + .OverridePropertyName("anchorMonday"); + + RuleForEach(x => x.Days) + .Must((command, day) => day.Week >= 0 && day.Week < command.CycleWeeks) + .WithMessage("Each day's week must be between 0 and cycleWeeks - 1.") + .Must(day => Enum.IsDefined(day.Day)) + .WithMessage("Each day must be a valid weekday.") + .OverridePropertyName("days"); + + RuleForEach(x => x.Days) + .ChildRules(day => day.RuleFor(d => d.Entry).SetValidator(new NightEntryValidator()).OverridePropertyName("entry")) + .OverridePropertyName("days"); + + RuleFor(x => x.Days) + .Must(days => days.Select(d => (d.Week, d.Day)).Distinct().Count() == days.Count) + .WithMessage("A schedule can have at most one entry per week and weekday.") + .OverridePropertyName("days"); + } +} diff --git a/src/backend/buddy/Features/NightDuty/SetNightOverrides/SetNightOverrides.Command.cs b/src/backend/buddy/Features/NightDuty/SetNightOverrides/SetNightOverrides.Command.cs new file mode 100644 index 00000000..545e06ab --- /dev/null +++ b/src/backend/buddy/Features/NightDuty/SetNightOverrides/SetNightOverrides.Command.cs @@ -0,0 +1,16 @@ +using System.Security.Claims; + +using buddy.Features.Users; + +namespace buddy.Features.NightDuty; + +// A list of (date, override) pairs rather than one value over a range, so a switch between two +// guardians -- two dates, two different values -- is one request and one append +// (docs/backend/analysis/night-duty.md, Question 5). +public sealed record SetNightOverrides(UserId UserId, UserId ChildId, IReadOnlyList Nights) +{ + public static SetNightOverrides FromClaims(ClaimsPrincipal principal, UserId childId, IReadOnlyList nights) => + new(principal.GetRequiredUserId(), childId, nights); +} + +public sealed record NightOverrideItem(DateOnly Date, NightOverride Override); diff --git a/src/backend/buddy/Features/NightDuty/SetNightOverrides/SetNightOverrides.Endpoint.cs b/src/backend/buddy/Features/NightDuty/SetNightOverrides/SetNightOverrides.Endpoint.cs new file mode 100644 index 00000000..91b0eb88 --- /dev/null +++ b/src/backend/buddy/Features/NightDuty/SetNightOverrides/SetNightOverrides.Endpoint.cs @@ -0,0 +1,57 @@ +using System.Security.Claims; + +using buddy.Common; +using buddy.Features.Users; + +using Microsoft.AspNetCore.Http.HttpResults; + +using Wolverine; + +namespace buddy.Features.NightDuty; + +public static class SetNightOverridesEndpoint +{ + public static RouteGroupBuilder MapSetNightOverrides(this RouteGroupBuilder nightDuty) + { + nightDuty.MapPut("/children/{childId:guid}/overrides", async Task>, NotFound, ForbidHttpResult, BadRequest>> ( + ClaimsPrincipal principal, + Guid childId, + SetNightOverridesRequest request, + IMessageBus bus, + HttpContext httpContext, + CancellationToken cancellationToken) => + { + // A null element can't reach the validator (it would throw building the domain record). + if (request.Nights.Any(n => n is null || (n.Entry is not null && n.Entry.Responsible is null))) + { + return TypedResults.BadRequest(Common.Validation.ValidationProblem.Of("nights must not contain null entries.").ToEnvelope(httpContext)); + } + + var command = SetNightOverrides.FromClaims( + principal, + new UserId(childId), + [.. request.Nights.Select(n => new NightOverrideItem( + n.Date, + n.Entry is { } entry ? new NightOverride.Planned(entry.ToDomain()) : new NightOverride.Cleared()))]); + var result = await bus.InvokeAsync>>(command, cancellationToken); + + return result switch + { + Result>.Success(var nights) => TypedResults.Ok(nights), + Result>.Forbidden => TypedResults.Forbid(), + Result>.Validation(var problem) => TypedResults.BadRequest(problem.ToEnvelope(httpContext)), + Result>.NotFound => TypedResults.NotFound(), + }; + }) + .WithName("SetNightOverrides"); + + return nightDuty; + } +} + +public sealed record SetNightOverridesRequest(IReadOnlyList Nights); + +// Entry null = "nothing planned this night" (NightOverride.Cleared) -- the wire's convention, like +// SetWorkLocationOverrides' locationId null = day off. To follow the fixed schedule again, clear the +// override with ClearNightOverrides instead. +public sealed record NightOverrideRequest(DateOnly Date, NightEntryDto? Entry = null); diff --git a/src/backend/buddy/Features/NightDuty/SetNightOverrides/SetNightOverrides.Handler.cs b/src/backend/buddy/Features/NightDuty/SetNightOverrides/SetNightOverrides.Handler.cs new file mode 100644 index 00000000..904a5eee --- /dev/null +++ b/src/backend/buddy/Features/NightDuty/SetNightOverrides/SetNightOverrides.Handler.cs @@ -0,0 +1,67 @@ +using buddy.Common; +using buddy.Common.Validation; +using buddy.Features.Babysitters; +using buddy.Features.Guardians; + +using FluentValidation; + +namespace buddy.Features.NightDuty; + +public static class SetNightOverridesHandler +{ + public static async Task>> Handle( + SetNightOverrides command, + IValidator validator, + INightScheduleEventStore store, + IGuardianLinkEventStore guardians, + IBabysitterListEventStore babysitters, + TimeProvider time, + CancellationToken cancellationToken) + { + if (await validator.ValidateCommandAsync(command, cancellationToken) is { } problem) + { + return new Result>.Validation(problem); + } + + var access = await NightDutyAuthorization.CheckManage(command.ChildId, command.UserId, guardians, cancellationToken); + + if (access != NightDutyAccess.Allowed) + { + return access.ToDeniedResult>(); + } + + var now = time.GetUtcNow(); + var loaded = await NightScheduleWriter.LoadAsync(store, command.ChildId, cancellationToken); + var schedule = loaded.Schedule; + var plannedEntries = command.Nights.Select(n => n.Override.PlannedEntry).OfType(); + + // People already on the nights being set -- their current override or scheduled entry -- aren't + // checked again, so a switch can move a babysitter who has since been archived. + var current = command.Nights + .SelectMany(n => new[] + { + schedule.Overrides.TryGetValue(n.Date, out var existing) ? existing.PlannedEntry : null, + schedule.ScheduledEntryFor(n.Date), + }) + .OfType(); + + if (await NightDutyRules.CheckRelationshipsAsync(command.ChildId, plannedEntries, current, guardians, babysitters, cancellationToken) is { } relationshipProblem) + { + return new Result>.Validation(relationshipProblem); + } + + // One event per date that actually changes, in date order; dates already holding this + // override emit nothing. + List events = [.. command.Nights + .Where(n => !schedule.Overrides.TryGetValue(n.Date, out var existing) || !existing.Equals(n.Override)) + .OrderBy(n => n.Date) + .Select(n => (NightDutyEvent)new NightOverridden(schedule.Id, n.Date, n.Override, command.UserId, now))]; + + schedule = await NightScheduleWriter.SaveAsync(store, loaded, events, now, cancellationToken); + + var dates = command.Nights.Select(n => n.Date).ToHashSet(); + var nights = await NightExpansion.ExpandAsync(schedule, dates.Min(), dates.Max(), babysitters, cancellationToken); + + return new Result>.Success([.. nights.Where(n => dates.Contains(n.Date))]); + } +} diff --git a/src/backend/buddy/Features/NightDuty/SetNightOverrides/SetNightOverrides.Validator.cs b/src/backend/buddy/Features/NightDuty/SetNightOverrides/SetNightOverrides.Validator.cs new file mode 100644 index 00000000..f9d982fd --- /dev/null +++ b/src/backend/buddy/Features/NightDuty/SetNightOverrides/SetNightOverrides.Validator.cs @@ -0,0 +1,30 @@ +using FluentValidation; + +namespace buddy.Features.NightDuty; + +public sealed class SetNightOverridesValidator : AbstractValidator +{ + public SetNightOverridesValidator() + { + RuleFor(x => x.Nights) + .NotEmpty() + .WithMessage("nights must contain at least one night.") + .Must(nights => nights.Count <= NightDutyRules.MaxRangeDays) + .WithMessage($"At most {NightDutyRules.MaxRangeDays} nights can be set at once.") + .Must(nights => nights.Select(n => n.Date).Distinct().Count() == nights.Count) + .WithMessage("Each date can appear only once.") + // The response resolves every night between the first and last date, so the span is + // capped like a list range. + .Must(nights => nights.Count == 0 + || nights.Max(n => n.Date).DayNumber - nights.Min(n => n.Date).DayNumber <= NightDutyRules.MaxRangeDays) + .WithMessage($"The nights must lie within {NightDutyRules.MaxRangeDays} days of each other.") + .OverridePropertyName("nights"); + + RuleForEach(x => x.Nights) + .ChildRules(night => night.RuleFor(n => n.Override.PlannedEntry!) + .SetValidator(new NightEntryValidator()) + .OverridePropertyName("entry") + .When(n => n.Override.PlannedEntry is not null)) + .OverridePropertyName("nights"); + } +} diff --git a/src/backend/buddy/Features/NightDuty/Types/Night.cs b/src/backend/buddy/Features/NightDuty/Types/Night.cs new file mode 100644 index 00000000..97bb5a29 --- /dev/null +++ b/src/backend/buddy/Features/NightDuty/Types/Night.cs @@ -0,0 +1,41 @@ +using System.Text.Json.Serialization; + +using buddy.Serialization; + +namespace buddy.Features.NightDuty; + +// Where a planned night came from: the fixed schedule in effect, or a per-date override. +public enum NightSource +{ + Pattern, + Override +} + +// One resolved night for ListNights: the night of Date runs into the next morning. Babysitter names +// are resolved inline, so the print sheet and the child's card need one call. +public sealed record Night(DateOnly Date, NightStatus Status); + +// What a child's night resolves to. Unplanned: no override and no entry in the version in effect. +// Cleared: an override to "nothing planned". Planned: the schedule's or an override's entry. +// Scheduled is what the fixed schedule says for an overridden night (null when it says nothing, or +// when the night isn't overridden), so the editor can show "Schedule: Dad". Wire shape +// { "kind": 0 } | { "kind": 1, "scheduled" } | { "kind": 2, "entry", "source", "scheduled" }. +[JsonConverter(typeof(NightStatusJsonConverter))] +public abstract record NightStatus +{ + public sealed record NightUnplanned : NightStatus; + + public sealed record NightCleared(NightEntryDto? Scheduled) : NightStatus; + + public sealed record NightPlanned(NightEntryDto Entry, NightSource Source, NightEntryDto? Scheduled) : NightStatus; +} + +public sealed class NightStatusJsonConverter : KindDiscriminatedJsonConverter +{ + protected override IReadOnlyDictionary Cases { get; } = new Dictionary + { + [0] = typeof(NightStatus.NightUnplanned), + [1] = typeof(NightStatus.NightCleared), + [2] = typeof(NightStatus.NightPlanned), + }; +} diff --git a/src/backend/buddy/Features/NightDuty/Types/NightDutyEvents.cs b/src/backend/buddy/Features/NightDuty/Types/NightDutyEvents.cs new file mode 100644 index 00000000..51fa3b2e --- /dev/null +++ b/src/backend/buddy/Features/NightDuty/Types/NightDutyEvents.cs @@ -0,0 +1,47 @@ +using buddy.Features.Users; + +namespace buddy.Features.NightDuty; + +public union NightDutyEvent( + NightScheduleStarted, + NightPatternReplaced, + NightOverridden, + NightOverrideCleared +) +{ + public static NightDutyEvent FromPayload(object payload) => payload switch + { + NightScheduleStarted e => e, + NightPatternReplaced e => e, + NightOverridden e => e, + NightOverrideCleared e => e, + _ => throw new ArgumentException($"Unknown night duty event payload: {payload.GetType().Name}", nameof(payload)), + }; + + public string EventType => this switch + { + NightScheduleStarted => nameof(NightScheduleStarted), + NightPatternReplaced => nameof(NightPatternReplaced), + NightOverridden => nameof(NightOverridden), + NightOverrideCleared => nameof(NightOverrideCleared), + }; +} + +// Every write carries ModifiedBy: any active guardian of the child can write, so the writer isn't +// implied by the stream (unlike the /me-only work-location events). See +// docs/backend/analysis/night-duty.md, "Events". + +// Appended lazily together with the first real write, never on its own -- same as +// WorkLocationScheduleStarted. Seeds a schedule with no versions and no overrides. +public sealed record NightScheduleStarted(NightScheduleId Id, UserId ChildId, DateTimeOffset OccurredAt); + +// One save of the fixed schedule. Replaces every stored version whose EffectiveFrom is on or after +// Pattern.EffectiveFrom ("from this date on, this is the schedule"). The versions it replaced +// aren't carried: they are already in the stream, and nothing reads them. +public sealed record NightPatternReplaced(NightScheduleId Id, NightPattern Pattern, UserId ModifiedBy, DateTimeOffset OccurredAt); + +// One event per date, even when several dates are set at once (a switch is two), like +// WorkLocationOverridden. The override replaced isn't carried. +public sealed record NightOverridden(NightScheduleId Id, DateOnly Date, NightOverride Override, UserId ModifiedBy, DateTimeOffset OccurredAt); + +public sealed record NightOverrideCleared(NightScheduleId Id, DateOnly Date, NightOverride Before, UserId ModifiedBy, DateTimeOffset OccurredAt); diff --git a/src/backend/buddy/Features/NightDuty/Types/NightOverride.cs b/src/backend/buddy/Features/NightDuty/Types/NightOverride.cs new file mode 100644 index 00000000..52115d59 --- /dev/null +++ b/src/backend/buddy/Features/NightDuty/Types/NightOverride.cs @@ -0,0 +1,20 @@ +namespace buddy.Features.NightDuty; + +// A one-off exception to the fixed schedule for one date: a planned entry, or Cleared ("the +// schedule says Dad, but nothing is planned this night"). Having no override at all means "follow +// the fixed schedule". Persisted through NightOverrideJsonConverter. See +// docs/backend/analysis/night-duty.md, Question 5. +public union NightOverride(NightOverride.Planned, NightOverride.Cleared) +{ + public sealed record Planned(NightEntry Entry); + + public sealed record Cleared; + + // The planned entry, or null for Cleared. Not persisted: NightOverrideJsonConverter writes only + // the case. + public NightEntry? PlannedEntry => this switch + { + Planned planned => planned.Entry, + Cleared => null, + }; +} diff --git a/src/backend/buddy/Features/NightDuty/Types/NightOverrideJsonConverter.cs b/src/backend/buddy/Features/NightDuty/Types/NightOverrideJsonConverter.cs new file mode 100644 index 00000000..e24fd54c --- /dev/null +++ b/src/backend/buddy/Features/NightDuty/Types/NightOverrideJsonConverter.cs @@ -0,0 +1,55 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace buddy.Features.NightDuty; + +// Marten's (de)serialization of NightOverride in NightOverridden/-Cleared and the NightSchedule +// snapshot, with an explicit Kind discriminator: {"Kind":"Cleared"} or +// {"Kind":"Planned","Entry":{...}}. Entry goes through the configured options, so its +// NightResponsible uses NightResponsibleJsonConverter. Same shape as WorkDayOverrideJsonConverter. +public sealed class NightOverrideJsonConverter : JsonConverter +{ + public override NightOverride Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + using var document = JsonDocument.ParseValue(ref reader); + var root = document.RootElement; + var kind = Required(root, "Kind").GetString(); + + return kind switch + { + nameof(NightOverride.Planned) => new NightOverride.Planned( + Required(root, "Entry").Deserialize(options) + ?? throw new JsonException("NightOverride Entry is null.")), + nameof(NightOverride.Cleared) => new NightOverride.Cleared(), + _ => throw new JsonException($"Unknown NightOverride Kind discriminator: '{kind}'."), + }; + } + + public override void Write(Utf8JsonWriter writer, NightOverride value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + + switch (value) + { + case NightOverride.Planned planned: + writer.WriteString("Kind", nameof(NightOverride.Planned)); + writer.WritePropertyName("Entry"); + JsonSerializer.Serialize(writer, planned.Entry, options); + break; + + case NightOverride.Cleared: + writer.WriteString("Kind", nameof(NightOverride.Cleared)); + break; + + default: + throw new JsonException("Cannot serialize an uninitialized NightOverride."); + } + + writer.WriteEndObject(); + } + + private static JsonElement Required(JsonElement element, string name) => + element.TryGetProperty(name, out var value) + ? value + : throw new JsonException($"NightOverride is missing its '{name}' property."); +} diff --git a/src/backend/buddy/Features/NightDuty/Types/NightPattern.cs b/src/backend/buddy/Features/NightDuty/Types/NightPattern.cs new file mode 100644 index 00000000..ecb1d5f5 --- /dev/null +++ b/src/backend/buddy/Features/NightDuty/Types/NightPattern.cs @@ -0,0 +1,36 @@ +using buddy.Common; + +namespace buddy.Features.NightDuty; + +// One version of a child's fixed night schedule: from EffectiveFrom on, a cycle of CycleWeeks weeks +// counted from AnchorMonday (the shared WeekCycle arithmetic WorkPattern uses), with one entry per +// (cycle week, weekday) that has one. A version applies until the next version's EffectiveFrom, so +// earlier nights keep the schedule they had (docs/backend/analysis/night-duty.md, Question 4). +// +// Days is a flat list rather than a nested dictionary so it serializes predictably inside a +// persisted event. Always kept sorted by (Week, Day) via Normalized(), so two versions with the +// same content compare equal with IsSameAs. +public sealed record NightPattern(DateOnly EffectiveFrom, int CycleWeeks, DateOnly AnchorMonday, IReadOnlyList Days) +{ + public const int MaxCycleWeeks = WeekCycle.MaxCycleWeeks; + + public NightEntry? EntryFor(DateOnly date) + { + var week = WeekCycle.CycleWeekOf(AnchorMonday, CycleWeeks, date); + var day = date.DayOfWeek; + + return Days.FirstOrDefault(d => d.Week == week && d.Day == day)?.Entry; + } + + public NightPattern Normalized() => + this with { Days = [.. Days.OrderBy(d => d.Week).ThenBy(d => WeekCycle.MondayFirstIndex(d.Day))] }; + + // Records compare IReadOnlyList members by reference, so content equality is spelled out. + public bool IsSameAs(NightPattern other) => + EffectiveFrom == other.EffectiveFrom + && CycleWeeks == other.CycleWeeks + && AnchorMonday == other.AnchorMonday + && Days.SequenceEqual(other.Days); +} + +public sealed record NightPatternDay(int Week, DayOfWeek Day, NightEntry Entry); diff --git a/src/backend/buddy/Features/NightDuty/Types/NightResponsible.cs b/src/backend/buddy/Features/NightDuty/Types/NightResponsible.cs new file mode 100644 index 00000000..416f0c97 --- /dev/null +++ b/src/backend/buddy/Features/NightDuty/Types/NightResponsible.cs @@ -0,0 +1,36 @@ +using System.Text.Json.Serialization; + +using buddy.Features.Babysitters; +using buddy.Features.Users; + +namespace buddy.Features.NightDuty; + +// Who is on duty for a night. The cases copy PickupAssignee's shapes (Playdate becomes Sleepover) +// but it is a separate union: SelfEscort/Sibling make no sense at night, and Unassigned (a note on +// its own) makes none for a pickup. Persisted through NightResponsibleJsonConverter (an explicit +// Kind discriminator: Guardian and Babysitter both flatten to bare guids). On the wire it is +// NightResponsibleDto. See docs/backend/analysis/night-duty.md, Question 3. +public union NightResponsible(NightResponsible.Guardian, NightResponsible.Babysitter, NightResponsible.Sleepover, NightResponsible.Unassigned) +{ + // An active guardian of the child. + public sealed record Guardian(UserId GuardianId); + + // A babysitter on GuardianId's BabysitterList. The name is resolved on every read, so a rename + // reaches every night (docs/backend/analysis/babysitters.md, Question 4). + public sealed record Babysitter(UserId GuardianId, BabysitterId BabysitterId); + + // The child sleeps somewhere else. Location and ContactInfo are optional free text: "" = not given. + public sealed record Sleepover(string HostName, string Location, string ContactInfo); + + // Nobody named -- only valid with a note ("Screen night"). + public sealed record Unassigned; +} + +// One night's plan: the responsible person plus a short note ("" = none). Never Unassigned with an +// empty note -- that isn't an entry (a pattern leaves the day out, an override uses Cleared). +public sealed record NightEntry(NightResponsible Responsible, string Note) +{ + // Derived, so kept out of the persisted event and snapshot JSON. + [JsonIgnore] + public bool IsEmpty => Responsible is NightResponsible.Unassigned && Note.Length == 0; +} diff --git a/src/backend/buddy/Features/NightDuty/Types/NightResponsibleDto.cs b/src/backend/buddy/Features/NightDuty/Types/NightResponsibleDto.cs new file mode 100644 index 00000000..d680508a --- /dev/null +++ b/src/backend/buddy/Features/NightDuty/Types/NightResponsibleDto.cs @@ -0,0 +1,77 @@ +using System.Diagnostics; +using System.Text.Json.Serialization; + +using buddy.Common; +using buddy.Features.Babysitters; +using buddy.Features.Users; +using buddy.Serialization; + +namespace buddy.Features.NightDuty; + +// The wire discriminator of NightResponsibleDto ("kind"), as a numeric ordinal like every other +// kind on this API. The domain uses the NightResponsible union instead. +public enum NightResponsibleKind +{ + Guardian, + Babysitter, + Sleepover, + Unassigned +} + +// The HTTP shape of a NightResponsible: an object whose numeric "kind" picks the case, with only +// that case's fields beside it -- e.g. { "kind": 0, "guardianId": "..." } or +// { "kind": 2, "hostName": "...", "location": "", "contactInfo": "" }. A babysitter's "name" is +// output only: ignored on the way in, resolved through BabysitterNames on the way out. Same shape +// as PickupAssigneeDto. +[JsonConverter(typeof(NightResponsibleDtoJsonConverter))] +public abstract record NightResponsibleDto +{ + public NightResponsible ToDomain() => this switch + { + GuardianNightResponsibleDto guardian => new NightResponsible.Guardian(new UserId(guardian.GuardianId)), + BabysitterNightResponsibleDto babysitter => new NightResponsible.Babysitter(new UserId(babysitter.GuardianId), new BabysitterId(babysitter.BabysitterId)), + SleepoverNightResponsibleDto sleepover => new NightResponsible.Sleepover( + FreeText.Normalize(sleepover.HostName), FreeText.Normalize(sleepover.Location), FreeText.Normalize(sleepover.ContactInfo)), + UnassignedNightResponsibleDto => new NightResponsible.Unassigned(), + _ => throw new UnreachableException($"Unmapped NightResponsibleDto case: {GetType().Name}."), + }; + + public static NightResponsibleDto FromDomain(NightResponsible responsible, BabysitterNames babysitterNames) => responsible switch + { + NightResponsible.Guardian guardian => new GuardianNightResponsibleDto(guardian.GuardianId.Value), + NightResponsible.Babysitter babysitter => new BabysitterNightResponsibleDto( + babysitter.GuardianId.Value, babysitter.BabysitterId.Value, babysitterNames.NameOf(babysitter.BabysitterId)), + NightResponsible.Sleepover sleepover => new SleepoverNightResponsibleDto(sleepover.HostName, sleepover.Location, sleepover.ContactInfo), + NightResponsible.Unassigned => new UnassignedNightResponsibleDto(), + }; +} + +public sealed record GuardianNightResponsibleDto(Guid GuardianId) : NightResponsibleDto; + +// Name is output only ("" when the babysitter can't be resolved any more); a value sent in is ignored. +public sealed record BabysitterNightResponsibleDto(Guid GuardianId, Guid BabysitterId, string? Name = null) : NightResponsibleDto; + +// Location and ContactInfo are optional on the way in; they always come back, "" meaning none. +public sealed record SleepoverNightResponsibleDto(string HostName, string? Location = null, string? ContactInfo = null) : NightResponsibleDto; + +public sealed record UnassignedNightResponsibleDto : NightResponsibleDto; + +public sealed class NightResponsibleDtoJsonConverter : KindDiscriminatedJsonConverter +{ + protected override IReadOnlyDictionary Cases { get; } = new Dictionary + { + [(int)NightResponsibleKind.Guardian] = typeof(GuardianNightResponsibleDto), + [(int)NightResponsibleKind.Babysitter] = typeof(BabysitterNightResponsibleDto), + [(int)NightResponsibleKind.Sleepover] = typeof(SleepoverNightResponsibleDto), + [(int)NightResponsibleKind.Unassigned] = typeof(UnassignedNightResponsibleDto), + }; +} + +// The HTTP shape of a NightEntry. Note is optional on the way in ("" = none) and always comes back. +public sealed record NightEntryDto(NightResponsibleDto Responsible, string? Note = null) +{ + public NightEntry ToDomain() => new(Responsible.ToDomain(), FreeText.Normalize(Note)); + + public static NightEntryDto FromDomain(NightEntry entry, BabysitterNames babysitterNames) => + new(NightResponsibleDto.FromDomain(entry.Responsible, babysitterNames), entry.Note); +} diff --git a/src/backend/buddy/Features/NightDuty/Types/NightResponsibleJsonConverter.cs b/src/backend/buddy/Features/NightDuty/Types/NightResponsibleJsonConverter.cs new file mode 100644 index 00000000..0da705b4 --- /dev/null +++ b/src/backend/buddy/Features/NightDuty/Types/NightResponsibleJsonConverter.cs @@ -0,0 +1,82 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +using buddy.Features.Babysitters; +using buddy.Features.Users; + +namespace buddy.Features.NightDuty; + +// Marten's (de)serialization of NightResponsible inside the night duty events and the NightSchedule +// snapshot, with an explicit Kind discriminator: Guardian and Babysitter both carry bare guids, so +// the union's shape-based classifier can't tell them apart -- the same fix as +// PickupAssigneeJsonConverter. +public sealed class NightResponsibleJsonConverter : JsonConverter +{ + // Property names of the persisted shape -- part of the stored event contract (golden files). + private const string KindProperty = "Kind"; + private const string GuardianIdProperty = "GuardianId"; + private const string BabysitterIdProperty = "BabysitterId"; + private const string HostNameProperty = "HostName"; + private const string LocationProperty = "Location"; + private const string ContactInfoProperty = "ContactInfo"; + + public override NightResponsible Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + using var document = JsonDocument.ParseValue(ref reader); + var root = document.RootElement; + var kind = String(root, KindProperty); + + return kind switch + { + nameof(NightResponsible.Guardian) => new NightResponsible.Guardian(new UserId(Required(root, GuardianIdProperty).GetGuid())), + nameof(NightResponsible.Babysitter) => new NightResponsible.Babysitter( + new UserId(Required(root, GuardianIdProperty).GetGuid()), new BabysitterId(Required(root, BabysitterIdProperty).GetGuid())), + nameof(NightResponsible.Sleepover) => new NightResponsible.Sleepover(String(root, HostNameProperty), String(root, LocationProperty), String(root, ContactInfoProperty)), + nameof(NightResponsible.Unassigned) => new NightResponsible.Unassigned(), + _ => throw new JsonException($"Unknown NightResponsible Kind discriminator: '{kind}'.") + }; + } + + public override void Write(Utf8JsonWriter writer, NightResponsible value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + + switch (value) + { + case NightResponsible.Guardian guardian: + writer.WriteString(KindProperty, nameof(NightResponsible.Guardian)); + writer.WriteString(GuardianIdProperty, guardian.GuardianId.Value); + break; + + case NightResponsible.Babysitter babysitter: + writer.WriteString(KindProperty, nameof(NightResponsible.Babysitter)); + writer.WriteString(GuardianIdProperty, babysitter.GuardianId.Value); + writer.WriteString(BabysitterIdProperty, babysitter.BabysitterId.Value); + break; + + case NightResponsible.Sleepover sleepover: + writer.WriteString(KindProperty, nameof(NightResponsible.Sleepover)); + writer.WriteString(HostNameProperty, sleepover.HostName); + writer.WriteString(LocationProperty, sleepover.Location); + writer.WriteString(ContactInfoProperty, sleepover.ContactInfo); + break; + + case NightResponsible.Unassigned: + writer.WriteString(KindProperty, nameof(NightResponsible.Unassigned)); + break; + + // A default(NightResponsible) holds no case: refuse it here rather than persisting an + // object that can never be read back. + default: + throw new JsonException("Cannot serialize a NightResponsible that holds no case."); + } + + writer.WriteEndObject(); + } + + private static JsonElement Required(JsonElement root, string name) => + root.TryGetProperty(name, out var value) ? value : throw new JsonException($"NightResponsible is missing '{name}'."); + + private static string String(JsonElement root, string name) => + Required(root, name).GetString() ?? throw new JsonException($"NightResponsible '{name}' is null."); +} diff --git a/src/backend/buddy/Features/NightDuty/Types/NightSchedule.cs b/src/backend/buddy/Features/NightDuty/Types/NightSchedule.cs new file mode 100644 index 00000000..03495ad7 --- /dev/null +++ b/src/backend/buddy/Features/NightDuty/Types/NightSchedule.cs @@ -0,0 +1,58 @@ +using System.Collections.Immutable; + +using buddy.Common.Aggregates; +using buddy.Features.Users; + +namespace buddy.Features.NightDuty; + +public sealed record NightSchedule( + NightScheduleId Id, + UserId ChildId, + // Fixed-schedule versions, ordered by EffectiveFrom (NightPatternReplaced keeps it sorted). + ImmutableList Patterns, + // Sparse: only dates with an explicit exception hold a key, like WorkLocationSchedule.Overrides. + ImmutableDictionary Overrides) +{ + // What a child whose guardians have never written anything looks like -- the same state + // NightScheduleStarted folds to, so handlers can treat "no stream yet" uniformly. + public static NightSchedule Empty(UserId childId) => new( + NightScheduleId.ForChild(childId), + childId, + [], + ImmutableDictionary.Empty); + + // The version in effect on date: the latest one starting on or before it. Null before the first. + public NightPattern? PatternFor(DateOnly date) => Patterns.LastOrDefault(p => p.EffectiveFrom <= date); + + // What the fixed schedule says for date, ignoring overrides. + public NightEntry? ScheduledEntryFor(DateOnly date) => PatternFor(date)?.EntryFor(date); + + // "From this date on, this is the schedule": drops every version starting on or after it. + public ImmutableList WithPattern(NightPattern pattern) => + [.. Patterns.Where(p => p.EffectiveFrom < pattern.EffectiveFrom), pattern]; + + public static NightSchedule? Rehydrate(IEnumerable events) => EventReplay.Rehydrate(events, Start, Advance); + + public static NightSchedule Replay(IEnumerable events) => EventReplay.Replay(events, Start, Advance); + + // Not named Apply/Create -- see WorkLocationSchedule.Start for why. + public static NightSchedule Start(NightDutyEvent @event) => @event switch + { + NightScheduleStarted started => Empty(started.ChildId), + _ => throw EventReplay.NotAStartEvent(nameof(NightSchedule), @event.EventType) + }; + + public static NightSchedule Advance(NightSchedule schedule, NightDutyEvent @event) => @event switch + { + NightPatternReplaced replaced => schedule with { Patterns = schedule.WithPattern(replaced.Pattern) }, + NightOverridden overridden => schedule with + { + Overrides = schedule.Overrides.SetItem(overridden.Date, overridden.Override) + }, + NightOverrideCleared cleared => schedule with + { + Overrides = schedule.Overrides.Remove(cleared.Date) + }, + NightScheduleStarted => throw EventReplay.AlreadyStarted(nameof(NightSchedule), @event.EventType) + }; +} diff --git a/src/backend/buddy/Features/NightDuty/Types/NightScheduleId.cs b/src/backend/buddy/Features/NightDuty/Types/NightScheduleId.cs new file mode 100644 index 00000000..76d0898a --- /dev/null +++ b/src/backend/buddy/Features/NightDuty/Types/NightScheduleId.cs @@ -0,0 +1,12 @@ +using buddy.Features.Users; + +namespace buddy.Features.NightDuty; + +// Deliberately equal to the child's own UserId -- a NightSchedule is a genuine 1:1 relationship +// with its child (same reasoning as ProgressId.ForChild and WorkLocationScheduleId.ForGuardian), so +// no index document is needed to find "this child's schedule". See +// docs/backend/analysis/night-duty.md, Question 2. +public sealed record NightScheduleId(Guid Value) +{ + public static NightScheduleId ForChild(UserId childId) => new(childId.Value); +} diff --git a/src/backend/buddy/Features/NightDuty/Types/NightScheduleResponse.cs b/src/backend/buddy/Features/NightDuty/Types/NightScheduleResponse.cs new file mode 100644 index 00000000..caf2c233 --- /dev/null +++ b/src/backend/buddy/Features/NightDuty/Types/NightScheduleResponse.cs @@ -0,0 +1,54 @@ +using buddy.Features.Babysitters; + +namespace buddy.Features.NightDuty; + +// Whether a fixed-schedule version applies to the child's today (Current), only to earlier nights +// (Past) or only from a later date (Upcoming). +public enum NightPatternStatus +{ + Past, + Current, + Upcoming +} + +// The editor's view of a child's fixed schedule: every version, oldest first. Overrides are +// deliberately not included -- they're read resolved, through ListNights. +public sealed record NightScheduleResponse(Guid ChildId, DateOnly Today, IReadOnlyList Patterns) +{ + public static NightScheduleResponse From(NightSchedule schedule, DateOnly today, BabysitterNames babysitterNames) + { + var current = schedule.PatternFor(today); + + return new( + schedule.ChildId.Value, + today, + [.. schedule.Patterns.Select(p => NightPatternResponse.From( + p, + StatusOf(p, current, today), + babysitterNames))]); + } + + private static NightPatternStatus StatusOf(NightPattern pattern, NightPattern? current, DateOnly today) + { + if (pattern.EffectiveFrom > today) + { + return NightPatternStatus.Upcoming; + } + + return pattern == current ? NightPatternStatus.Current : NightPatternStatus.Past; + } +} + +public sealed record NightPatternResponse( + DateOnly EffectiveFrom, int CycleWeeks, DateOnly AnchorMonday, IReadOnlyList Days, NightPatternStatus Status) +{ + public static NightPatternResponse From(NightPattern pattern, NightPatternStatus status, BabysitterNames babysitterNames) => + new( + pattern.EffectiveFrom, + pattern.CycleWeeks, + pattern.AnchorMonday, + [.. pattern.Days.Select(d => new NightPatternDayDto(d.Week, d.Day, NightEntryDto.FromDomain(d.Entry, babysitterNames)))], + status); +} + +public sealed record NightPatternDayDto(int Week, DayOfWeek Day, NightEntryDto Entry); diff --git a/src/backend/buddy/Features/NightDuty/Types/NightScheduleSnapshotProjection.cs b/src/backend/buddy/Features/NightDuty/Types/NightScheduleSnapshotProjection.cs new file mode 100644 index 00000000..00d85d24 --- /dev/null +++ b/src/backend/buddy/Features/NightDuty/Types/NightScheduleSnapshotProjection.cs @@ -0,0 +1,24 @@ +using Marten.Events.Aggregation; + +namespace buddy.Features.NightDuty; + +// Wrapper document for the same reason as WorkLocationScheduleSnapshot: Marten can't use a +// sealed-record id class as a document Id. See docs/backend/analysis/event-stream-snapshots.md, Question 5. +public sealed record NightScheduleSnapshot(Guid Id, NightSchedule NightSchedule); + +// Inline snapshot of NightSchedule, maintained in the same transaction as every event append and +// stored in the shared "snapshots" schema -- derived, rebuildable state. +public sealed class NightScheduleSnapshotProjection : SingleStreamProjection +{ + public static NightScheduleSnapshot Create(NightScheduleStarted started) => + new(started.Id.Value, NightSchedule.Start(NightDutyEvent.FromPayload(started))); + + public NightScheduleSnapshot Apply(NightScheduleSnapshot current, NightPatternReplaced e) => Next(current, e); + + public NightScheduleSnapshot Apply(NightScheduleSnapshot current, NightOverridden e) => Next(current, e); + + public NightScheduleSnapshot Apply(NightScheduleSnapshot current, NightOverrideCleared e) => Next(current, e); + + private static NightScheduleSnapshot Next(NightScheduleSnapshot current, NightDutyEvent e) => + current with { NightSchedule = NightSchedule.Advance(current.NightSchedule, e) }; +} diff --git a/src/backend/buddy/Features/Pickups/AssignPickup/AssignPickup.Handler.cs b/src/backend/buddy/Features/Pickups/AssignPickup/AssignPickup.Handler.cs index 65caeef9..4e7cfc48 100644 --- a/src/backend/buddy/Features/Pickups/AssignPickup/AssignPickup.Handler.cs +++ b/src/backend/buddy/Features/Pickups/AssignPickup/AssignPickup.Handler.cs @@ -72,7 +72,7 @@ await pickups.CreateAsync( } } - var babysitterNames = await BabysitterNames.LoadAsync([after.Assignee], babysitters, cancellationToken); + var babysitterNames = await BabysitterNames.LoadAsync(new[] { after.Assignee }.BabysitterOwners(), babysitters, cancellationToken); return new Result.Success(PickupOccurrence.FromAssignment(command.Date, command.Slot, after, babysitterNames)); } diff --git a/src/backend/buddy/Features/Pickups/BabysitterNames.cs b/src/backend/buddy/Features/Pickups/BabysitterNames.cs deleted file mode 100644 index 885f424b..00000000 --- a/src/backend/buddy/Features/Pickups/BabysitterNames.cs +++ /dev/null @@ -1,57 +0,0 @@ -using buddy.Features.Babysitters; -using buddy.Features.Users; - -namespace buddy.Features.Pickups; - -// Resolves the names of the babysitters a set of assignments refers to, loading each owning -// guardian's BabysitterList snapshot once per request. Archived babysitters still resolve, and so -// does a deleted account's list (UserDeleted leaves it in place); only an id missing from its -// owner's list reads as "". See -// docs/backend/analysis/babysitters.md, Question 4. -public sealed class BabysitterNames -{ - public static readonly BabysitterNames None = new(new Dictionary()); - - private readonly IReadOnlyDictionary _names; - - private BabysitterNames(IReadOnlyDictionary names) => _names = names; - - public string NameOf(PickupAssignee.Babysitter babysitter) => _names.GetValueOrDefault(babysitter.BabysitterId, ""); - - public static async Task LoadAsync( - IEnumerable assignees, IBabysitterListEventStore babysitters, CancellationToken cancellationToken) - { - // A union boxes as itself, not as its case, so OfType() would never match. - var owners = new HashSet(); - - foreach (var assignee in assignees) - { - if (assignee is PickupAssignee.Babysitter babysitter) - { - owners.Add(babysitter.GuardianId); - } - } - - if (owners.Count == 0) - { - return None; - } - - var names = new Dictionary(); - - foreach (var owner in owners) - { - if (await babysitters.FindSnapshotAsync(BabysitterListId.ForGuardian(owner), cancellationToken) is not { } list) - { - continue; - } - - foreach (var babysitter in list.Babysitters) - { - names[babysitter.Id] = babysitter.Name; - } - } - - return new BabysitterNames(names); - } -} diff --git a/src/backend/buddy/Features/Pickups/PickupBabysitters.cs b/src/backend/buddy/Features/Pickups/PickupBabysitters.cs new file mode 100644 index 00000000..f1f6eb16 --- /dev/null +++ b/src/backend/buddy/Features/Pickups/PickupBabysitters.cs @@ -0,0 +1,19 @@ +using buddy.Features.Users; + +namespace buddy.Features.Pickups; + +internal static class PickupBabysitters +{ + // The guardians whose babysitter lists a set of assignees refers to, for BabysitterNames.LoadAsync. + // A union boxes as itself, not as its case, so OfType() would never match. + public static IEnumerable BabysitterOwners(this IEnumerable assignees) + { + foreach (var assignee in assignees) + { + if (assignee is PickupAssignee.Babysitter babysitter) + { + yield return babysitter.GuardianId; + } + } + } +} diff --git a/src/backend/buddy/Features/Pickups/PickupScheduleExpansion.cs b/src/backend/buddy/Features/Pickups/PickupScheduleExpansion.cs index c67f7aef..d8bc8ef5 100644 --- a/src/backend/buddy/Features/Pickups/PickupScheduleExpansion.cs +++ b/src/backend/buddy/Features/Pickups/PickupScheduleExpansion.cs @@ -30,7 +30,7 @@ public static async Task> ExpandAsync( } var inRange = schedule.Assignments.Where(entry => entry.Key.Date >= from && entry.Key.Date <= to).ToList(); - var babysitterNames = await BabysitterNames.LoadAsync(inRange.Select(entry => entry.Value.Assignee), babysitters, cancellationToken); + var babysitterNames = await BabysitterNames.LoadAsync(inRange.Select(entry => entry.Value.Assignee).BabysitterOwners(), babysitters, cancellationToken); var occurrences = inRange .Select(entry => PickupOccurrence.FromAssignment(entry.Key.Date, entry.Key.Slot, entry.Value, babysitterNames)) diff --git a/src/backend/buddy/Features/Pickups/PickupsPersonalDataExport.cs b/src/backend/buddy/Features/Pickups/PickupsPersonalDataExport.cs index 795b0017..114aec1d 100644 --- a/src/backend/buddy/Features/Pickups/PickupsPersonalDataExport.cs +++ b/src/backend/buddy/Features/Pickups/PickupsPersonalDataExport.cs @@ -20,7 +20,7 @@ public sealed class PickupsPersonalDataExporter(IPickupScheduleEventStore schedu ? await schedules.FindSnapshotAsync(id, cancellationToken) : null; var assignments = schedule?.Assignments.OrderBy(a => a.Key.Date).ThenBy(a => a.Key.Slot).ToArray() ?? []; - var names = await BabysitterNames.LoadAsync(assignments.Select(a => a.Value.Assignee), babysitters, cancellationToken); + var names = await BabysitterNames.LoadAsync(assignments.Select(a => a.Value.Assignee).BabysitterOwners(), babysitters, cancellationToken); children.Add(new ExportedPickupSchedule( childId.Value, diff --git a/src/backend/buddy/Features/Pickups/Types/PickupAssigneeDto.cs b/src/backend/buddy/Features/Pickups/Types/PickupAssigneeDto.cs index 4930f92f..11e7f1b7 100644 --- a/src/backend/buddy/Features/Pickups/Types/PickupAssigneeDto.cs +++ b/src/backend/buddy/Features/Pickups/Types/PickupAssigneeDto.cs @@ -35,7 +35,7 @@ public abstract record PickupAssigneeDto PickupAssignee.Sibling sibling => new SiblingAssigneeDto(sibling.SiblingChildId.Value), PickupAssignee.Playdate playdate => new PlaydateAssigneeDto(playdate.HostName, playdate.Location, playdate.ContactInfo), PickupAssignee.Babysitter babysitter => new BabysitterAssigneeDto( - babysitter.GuardianId.Value, babysitter.BabysitterId.Value, babysitterNames.NameOf(babysitter)), + babysitter.GuardianId.Value, babysitter.BabysitterId.Value, babysitterNames.NameOf(babysitter.BabysitterId)), }; } diff --git a/src/backend/buddy/Features/Pickups/Types/PickupOccurrence.cs b/src/backend/buddy/Features/Pickups/Types/PickupOccurrence.cs index b1376f67..53a769c6 100644 --- a/src/backend/buddy/Features/Pickups/Types/PickupOccurrence.cs +++ b/src/backend/buddy/Features/Pickups/Types/PickupOccurrence.cs @@ -1,3 +1,5 @@ +using buddy.Features.Babysitters; + namespace buddy.Features.Pickups; // The read/wire shape for one assigned slot -- PickupAssignment plus its Date/Slot, with the diff --git a/src/backend/buddy/Features/PrintTemplates/PrintTemplateRules.cs b/src/backend/buddy/Features/PrintTemplates/PrintTemplateRules.cs index cb7b70cb..6c116f10 100644 --- a/src/backend/buddy/Features/PrintTemplates/PrintTemplateRules.cs +++ b/src/backend/buddy/Features/PrintTemplates/PrintTemplateRules.cs @@ -67,6 +67,12 @@ public PrintTemplateRowValidator() .OverridePropertyName("childId") .When(r => r.Kind == PrintRowKind.Pickup); + RuleFor(r => r.ChildId) + .NotNull() + .WithMessage("A night duty row needs childId.") + .OverridePropertyName("childId") + .When(r => r.Kind == PrintRowKind.NightDuty); + RuleFor(r => r.GuardianId) .NotNull() .WithMessage("A work location row needs guardianId.") @@ -104,6 +110,7 @@ private static List StrayFields(PrintTemplateRow row) { PrintRowKind.Meal => new[] { "childId", "mealGroupId", "mealSlot" }, PrintRowKind.Pickup => ["childId"], + PrintRowKind.NightDuty => ["childId"], PrintRowKind.WorkLocation => ["guardianId", "workLocationId"], PrintRowKind.CalendarMarker => ["calendarIds", "titleFilter"], PrintRowKind.CalendarEvents => ["calendarIds", "assignedToId", "titleFilter", "maxItems", "showTime", "showAssignee"], diff --git a/src/backend/buddy/Features/PrintTemplates/Types/PrintRowKind.cs b/src/backend/buddy/Features/PrintTemplates/Types/PrintRowKind.cs index 28d141db..5ef9c064 100644 --- a/src/backend/buddy/Features/PrintTemplates/Types/PrintRowKind.cs +++ b/src/backend/buddy/Features/PrintTemplates/Types/PrintRowKind.cs @@ -14,5 +14,8 @@ public enum PrintRowKind TaskChecklist, Blank, // Tasks (tick boxes) and events (a clock) of the same calendars, together in one cell. - CalendarAgenda + CalendarAgenda, + // Who is on duty each night, with any note (docs/backend/analysis/night-duty.md, Question 7). + // Appended last: the ordinal is the wire value. + NightDuty } diff --git a/src/backend/buddy/Features/Users/UserLocalDate.cs b/src/backend/buddy/Features/Users/UserLocalDate.cs new file mode 100644 index 00000000..1d082db1 --- /dev/null +++ b/src/backend/buddy/Features/Users/UserLocalDate.cs @@ -0,0 +1,22 @@ +namespace buddy.Features.Users; + +// "Today" on a user's own wall clock (User.TimeZoneId) -- for a child, the time zone a guardian set +// (docs/backend/analysis/child-timezone-settings.md). Shared by medicine effect notes (no notes for +// future days) and night duty (the "Tonight" card, the earliest EffectiveFrom). +public static class UserLocalDate +{ + // Null for an unknown or deleted user. + public static async Task TodayAsync(UserId userId, IUserEventStore users, TimeProvider time, CancellationToken cancellationToken) + { + var user = await users.FindSnapshotAsync(userId, cancellationToken); + + if (user is null || user.IsDeleted) + { + return null; + } + + var local = TimeZoneInfo.ConvertTime(time.GetUtcNow(), TimeZoneInfo.FindSystemTimeZoneById(user.TimeZoneId.Value)); + + return DateOnly.FromDateTime(local.DateTime); + } +} diff --git a/src/backend/buddy/Features/WorkLocations/Types/WorkPattern.cs b/src/backend/buddy/Features/WorkLocations/Types/WorkPattern.cs index 75bb2f53..4ef8ec9c 100644 --- a/src/backend/buddy/Features/WorkLocations/Types/WorkPattern.cs +++ b/src/backend/buddy/Features/WorkLocations/Types/WorkPattern.cs @@ -1,28 +1,22 @@ +using buddy.Common; + namespace buddy.Features.WorkLocations; -// A repeating cycle of CycleWeeks weeks, counted from AnchorMonday (cycle week 0). Anchored rather -// than based on ISO week parity because ISO parity drifts across 53-week years (2026 has a week -// 53, followed by an odd week 1) -- see docs/backend/analysis/work-locations.md, Question 4. +// A repeating cycle of CycleWeeks weeks, counted from AnchorMonday (cycle week 0); the cycle +// arithmetic is the shared WeekCycle, which NightPattern uses too. // // Days is a flat list rather than a nested dictionary so it serializes predictably inside a // persisted event; days not listed have no location. Always kept sorted by (Week, Day) via // Normalized(), so two patterns with the same content compare equal with IsSameAs. public sealed record WorkPattern(int CycleWeeks, DateOnly AnchorMonday, IReadOnlyList Days) { - public const int MaxCycleWeeks = 4; + public const int MaxCycleWeeks = WeekCycle.MaxCycleWeeks; public static WorkPattern Empty(DateOnly today) => new(1, MondayOnOrBefore(today), []); - public static DateOnly MondayOnOrBefore(DateOnly date) => - date.AddDays(-(((int)date.DayOfWeek + 6) % 7)); - - public int CycleWeekOf(DateOnly date) - { - var weeks = (MondayOnOrBefore(date).DayNumber - AnchorMonday.DayNumber) / 7; + public static DateOnly MondayOnOrBefore(DateOnly date) => WeekCycle.MondayOnOrBefore(date); - // Floored modulo, so dates before the anchor still land in the right cycle week. - return ((weeks % CycleWeeks) + CycleWeeks) % CycleWeeks; - } + public int CycleWeekOf(DateOnly date) => WeekCycle.CycleWeekOf(AnchorMonday, CycleWeeks, date); public WorkLocationId? LocationFor(DateOnly date) { @@ -38,7 +32,7 @@ public WorkPattern Without(WorkLocationId locationId) => this with { Days = [.. Days.Where(d => d.LocationId != locationId)] }; public WorkPattern Normalized() => - this with { Days = [.. Days.OrderBy(d => d.Week).ThenBy(d => ((int)d.Day + 6) % 7)] }; + this with { Days = [.. Days.OrderBy(d => d.Week).ThenBy(d => WeekCycle.MondayFirstIndex(d.Day))] }; // Records compare IReadOnlyList members by reference, so content equality is spelled out. public bool IsSameAs(WorkPattern other) => diff --git a/src/backend/buddy/Internal/Generated/WolverineHandlers/ClearNightOverridesHandler706950647.cs b/src/backend/buddy/Internal/Generated/WolverineHandlers/ClearNightOverridesHandler706950647.cs new file mode 100644 index 00000000..6d0cbc47 --- /dev/null +++ b/src/backend/buddy/Internal/Generated/WolverineHandlers/ClearNightOverridesHandler706950647.cs @@ -0,0 +1,53 @@ +// +#pragma warning disable +using buddy.Features.Guardians; +using buddy.Features.NightDuty; +using System; + +namespace Internal.Generated.WolverineHandlers +{ + // START: ClearNightOverridesHandler706950647 + [global::System.CodeDom.Compiler.GeneratedCode("JasperFx", "1.0.0")] + public sealed class ClearNightOverridesHandler706950647 : Wolverine.Runtime.Handlers.MessageHandler + { + private readonly System.TimeProvider _timeProvider; + private readonly buddy.Features.Guardians.IGuardianLinkEventStore _guardianLinkEventStore; + private readonly buddy.Features.NightDuty.INightScheduleEventStore _nightScheduleEventStore; + + public ClearNightOverridesHandler706950647(System.TimeProvider timeProvider, buddy.Features.Guardians.IGuardianLinkEventStore guardianLinkEventStore, buddy.Features.NightDuty.INightScheduleEventStore nightScheduleEventStore) + { + _timeProvider = timeProvider; + _guardianLinkEventStore = guardianLinkEventStore; + _nightScheduleEventStore = nightScheduleEventStore; + } + + public override async System.Threading.Tasks.Task HandleAsync(Wolverine.Runtime.MessageContext context, System.Threading.CancellationToken cancellation) + { + var clearNightOverridesValidator = new buddy.Features.NightDuty.ClearNightOverridesValidator(); + // The actual message body + var clearNightOverrides = (buddy.Features.NightDuty.ClearNightOverrides)context.Envelope.Message; + + using var disposable = buddy.Common.Concurrency.StreamVersionScopeMiddleware.Before(); + try + { + Wolverine.Runtime.WolverineTracing.MessageActivity(context.Envelope)?.SetTag("message.handler", "buddy.Features.NightDuty.ClearNightOverridesHandler"); + Wolverine.Runtime.WolverineTracing.MessageActivity(context.Envelope)?.SetTag("handler.type", "buddy.Features.NightDuty.ClearNightOverridesHandler"); + + // The actual message execution + var outgoing1 = await buddy.Features.NightDuty.ClearNightOverridesHandler.Handle(clearNightOverrides, clearNightOverridesValidator, _nightScheduleEventStore, _guardianLinkEventStore, _timeProvider, cancellation).ConfigureAwait(false); + + + // Outgoing, cascaded message + await context.EnqueueCascadingAsync(outgoing1).ConfigureAwait(false); + } + finally + { + buddy.Common.Concurrency.StreamVersionScopeMiddleware.Finally(disposable); + } + } + } + + // END: ClearNightOverridesHandler706950647 + + +} diff --git a/src/backend/buddy/Internal/Generated/WolverineHandlers/GeneratedHandlerRegistry.cs b/src/backend/buddy/Internal/Generated/WolverineHandlers/GeneratedHandlerRegistry.cs index 8eaa144e..4abd0990 100644 --- a/src/backend/buddy/Internal/Generated/WolverineHandlers/GeneratedHandlerRegistry.cs +++ b/src/backend/buddy/Internal/Generated/WolverineHandlers/GeneratedHandlerRegistry.cs @@ -9,7 +9,7 @@ public sealed class GeneratedHandlerRegistry : Wolverine.Runtime.Handlers.Handle { public override System.Type[] HandlerTypes() { - return new System.Type[] { typeof(buddy.Features.Babysitters.AddBabysitterHandler), typeof(buddy.Features.Babysitters.ArchiveBabysitterHandler), typeof(buddy.Features.Babysitters.ListChildBabysittersHandler), typeof(buddy.Features.Babysitters.ListMyBabysittersHandler), typeof(buddy.Features.Babysitters.UpdateBabysitterHandler), typeof(buddy.Features.Calendars.CreateCalendarHandler), typeof(buddy.Features.Calendars.CreateIcalTokenHandler), typeof(buddy.Features.Calendars.CreateItemHandler), typeof(buddy.Features.Calendars.DeleteCalendarHandler), typeof(buddy.Features.Calendars.DeleteItemHandler), typeof(buddy.Features.Calendars.GetCalendarHandler), typeof(buddy.Features.Calendars.GetIcalFeedHandler), typeof(buddy.Features.Calendars.ListAssignableMembersHandler), typeof(buddy.Features.Calendars.ListCalendarsHandler), typeof(buddy.Features.Calendars.ListIcalTokensHandler), typeof(buddy.Features.Calendars.ListItemsHandler), typeof(buddy.Features.Calendars.ListOccurrencesHandler), typeof(buddy.Features.Calendars.RemoveMemberHandler), typeof(buddy.Features.Calendars.RescheduleItemHandler), typeof(buddy.Features.Calendars.RevokeIcalTokenHandler), typeof(buddy.Features.Calendars.ScheduleTaskFromTemplateHandler), typeof(buddy.Features.Calendars.SetMemberRoleHandler), typeof(buddy.Features.Calendars.SetOccurrenceSkipHandler), typeof(buddy.Features.Calendars.SetTaskCompletionHandler), typeof(buddy.Features.Calendars.TransferCalendarToGroupHandler), typeof(buddy.Features.Calendars.UpdateCalendarIconHandler), typeof(buddy.Features.Calendars.UpdateItemAlarmsHandler), typeof(buddy.Features.Calendars.UpdateItemDetailsHandler), typeof(buddy.Features.Calendars.UpdateItemRecurrenceHandler), typeof(buddy.Features.Feelings.CheckInHandler), typeof(buddy.Features.Feelings.CreateFeelingsShareLinkHandler), typeof(buddy.Features.Feelings.EditFeelingCheckInHandler), typeof(buddy.Features.Feelings.GetMyFeelingsTodayHandler), typeof(buddy.Features.Feelings.GetSharedFeelingsHandler), typeof(buddy.Features.Feelings.ListFeelingCheckInsHandler), typeof(buddy.Features.Feelings.ListFeelingsShareLinksHandler), typeof(buddy.Features.Feelings.LogFeelingCheckInHandler), typeof(buddy.Features.Feelings.RemoveFeelingCheckInHandler), typeof(buddy.Features.Feelings.RevokeFeelingsShareLinkHandler), typeof(buddy.Features.Feelings.SetCheckInPromptTimesHandler), typeof(buddy.Features.Feelings.SetFeelingsDisplayModeHandler), typeof(buddy.Features.Feelings.UndoCheckInHandler), typeof(buddy.Features.FirstThen.CompleteFirstThenFirstHandler), typeof(buddy.Features.FirstThen.EndFirstThenBoardHandler), typeof(buddy.Features.FirstThen.GetFirstThenBoardHandler), typeof(buddy.Features.FirstThen.GetMyFirstThenBoardHandler), typeof(buddy.Features.FirstThen.ReopenFirstThenFirstHandler), typeof(buddy.Features.FirstThen.SetFirstThenBoardHandler), typeof(buddy.Features.Groups.AcceptGroupInviteHandler), typeof(buddy.Features.Groups.AddChildToGroupHandler), typeof(buddy.Features.Groups.CreateGroupHandler), typeof(buddy.Features.Groups.DeleteGroupHandler), typeof(buddy.Features.Groups.GetGroupHandler), typeof(buddy.Features.Groups.InviteToGroupHandler), typeof(buddy.Features.Groups.ListGroupInvitesHandler), typeof(buddy.Features.Groups.ListGroupsHandler), typeof(buddy.Features.Groups.PreviewGroupInviteHandler), typeof(buddy.Features.Groups.RemoveGroupMemberHandler), typeof(buddy.Features.Groups.RevokeGroupInviteHandler), typeof(buddy.Features.Groups.SetGroupMemberRoleHandler), typeof(buddy.Features.Groups.UpdateCalendarPermissionPolicyHandler), typeof(buddy.Features.Groups.UpdateMealplanPermissionPolicyHandler), typeof(buddy.Features.Groups.UpdateMedicinePermissionPolicyHandler), typeof(buddy.Features.Guardians.AcceptGuardianInviteHandler), typeof(buddy.Features.Guardians.CreateChildHandler), typeof(buddy.Features.Guardians.DeleteChildHandler), typeof(buddy.Features.Guardians.InviteGuardianHandler), typeof(buddy.Features.Guardians.ListChildGuardiansHandler), typeof(buddy.Features.Guardians.ListGuardianInvitesHandler), typeof(buddy.Features.Guardians.ListMyChildrenHandler), typeof(buddy.Features.Guardians.ListMyGuardiansHandler), typeof(buddy.Features.Guardians.ListMySiblingsHandler), typeof(buddy.Features.Guardians.PreviewGuardianInviteHandler), typeof(buddy.Features.Guardians.ResetChildPasswordHandler), typeof(buddy.Features.Guardians.RevokeGuardianInviteHandler), typeof(buddy.Features.Guardians.RevokeGuardianLinkHandler), typeof(buddy.Features.Guardians.UpdateChildChimeHandler), typeof(buddy.Features.Guardians.UpdateChildHomeworkChunksHandler), typeof(buddy.Features.Guardians.UpdateChildLanguageHandler), typeof(buddy.Features.Guardians.UpdateChildTimeZoneHandler), typeof(buddy.Features.Guardians.UpdateChildVisualTimersHandler), typeof(buddy.Features.HouseRules.AcknowledgeRuleHandler), typeof(buddy.Features.HouseRules.AddRuleHandler), typeof(buddy.Features.HouseRules.DiscardRuleDraftHandler), typeof(buddy.Features.HouseRules.EditRuleHandler), typeof(buddy.Features.HouseRules.GetChildRulesHandler), typeof(buddy.Features.HouseRules.ListRulesHandler), typeof(buddy.Features.HouseRules.PublishRuleHandler), typeof(buddy.Features.HouseRules.PublishRulesHandler), typeof(buddy.Features.HouseRules.RemoveRuleHandler), typeof(buddy.Features.HouseRules.ReorderRulesHandler), typeof(buddy.Features.Mealplans.AcknowledgeAiDataSharingHandler), typeof(buddy.Features.Mealplans.AddMealPlanToMealieShoppingListHandler), typeof(buddy.Features.Mealplans.ApplyAiSessionDraftHandler), typeof(buddy.Features.Mealplans.ArchiveMealForGroupHandler), typeof(buddy.Features.Mealplans.ArchiveMealHandler), typeof(buddy.Features.Mealplans.AssignMealToSlotForGroupHandler), typeof(buddy.Features.Mealplans.AssignMealToSlotHandler), typeof(buddy.Features.Mealplans.ClearMealSlotForGroupHandler), typeof(buddy.Features.Mealplans.ClearMealSlotHandler), typeof(buddy.Features.Mealplans.CommitMealPlanImportForGroupHandler), typeof(buddy.Features.Mealplans.CommitMealPlanImportHandler), typeof(buddy.Features.Mealplans.ConnectMealieHandler), typeof(buddy.Features.Mealplans.CreateMealForGroupHandler), typeof(buddy.Features.Mealplans.CreateMealFromRecipeHandler), typeof(buddy.Features.Mealplans.CreateMealHandler), typeof(buddy.Features.Mealplans.CreateMealPlanIcalTokenHandler), typeof(buddy.Features.Mealplans.DiscardAiSessionHandler), typeof(buddy.Features.Mealplans.DisconnectMealieHandler), typeof(buddy.Features.Mealplans.GetCurrentAiSessionHandler), typeof(buddy.Features.Mealplans.GetGroupMealplanStatusHandler), typeof(buddy.Features.Mealplans.GetMealPlanIcalFeedHandler), typeof(buddy.Features.Mealplans.GetMealieConnectionHandler), typeof(buddy.Features.Mealplans.GetMealieRecipeHandler), typeof(buddy.Features.Mealplans.GetMealieRecipeImageHandler), typeof(buddy.Features.Mealplans.GetSharedGroupHandler), typeof(buddy.Features.Mealplans.LinkMealToRecipeHandler), typeof(buddy.Features.Mealplans.ListMealPlanForGroupHandler), typeof(buddy.Features.Mealplans.ListMealPlanHandler), typeof(buddy.Features.Mealplans.ListMealPlanIcalTokensHandler), typeof(buddy.Features.Mealplans.ListMealPlanImportsForGroupHandler), typeof(buddy.Features.Mealplans.ListMealPlanImportsHandler), typeof(buddy.Features.Mealplans.ListMealieShoppingListsHandler), typeof(buddy.Features.Mealplans.ListMealsForGroupHandler), typeof(buddy.Features.Mealplans.ListMealsHandler), typeof(buddy.Features.Mealplans.ListProvidersHandler), typeof(buddy.Features.Mealplans.PreviewMealPlanImportForGroupHandler), typeof(buddy.Features.Mealplans.PreviewMealPlanImportHandler), typeof(buddy.Features.Mealplans.PreviewMealieImportHandler), typeof(buddy.Features.Mealplans.RateMealHandler), typeof(buddy.Features.Mealplans.RemoveProviderApiKeyHandler), typeof(buddy.Features.Mealplans.RevertMealPlanImportForGroupHandler), typeof(buddy.Features.Mealplans.RevertMealPlanImportHandler), typeof(buddy.Features.Mealplans.RevokeMealPlanIcalTokenHandler), typeof(buddy.Features.Mealplans.SearchMealieRecipesHandler), typeof(buddy.Features.Mealplans.SendAiSessionMessageHandler), typeof(buddy.Features.Mealplans.SetActiveProviderHandler), typeof(buddy.Features.Mealplans.SetProviderApiKeyHandler), typeof(buddy.Features.Mealplans.ShareMealPlanWithGroupHandler), typeof(buddy.Features.Mealplans.StartAiSessionHandler), typeof(buddy.Features.Mealplans.TestMealieConnectionHandler), typeof(buddy.Features.Mealplans.TestProviderConnectionHandler), typeof(buddy.Features.Mealplans.UnlinkMealRecipeHandler), typeof(buddy.Features.Mealplans.UnshareMealPlanFromGroupHandler), typeof(buddy.Features.Mealplans.UpdateMealDetailsForGroupHandler), typeof(buddy.Features.Mealplans.UpdateMealDetailsHandler), typeof(buddy.Features.Mealplans.UpdateMealSlotTimesHandler), typeof(buddy.Features.Medicines.ClearMedicineEffectNoteHandler), typeof(buddy.Features.Medicines.CreateMedicineScheduleForGroupHandler), typeof(buddy.Features.Medicines.CreateMedicineScheduleHandler), typeof(buddy.Features.Medicines.GetSharedMedicineGroupHandler), typeof(buddy.Features.Medicines.ListMedicineEffectNotesHandler), typeof(buddy.Features.Medicines.ListMedicineSchedulesForGroupHandler), typeof(buddy.Features.Medicines.ListMedicineSchedulesHandler), typeof(buddy.Features.Medicines.ListTodaysDosesForGroupHandler), typeof(buddy.Features.Medicines.ListTodaysDosesHandler), typeof(buddy.Features.Medicines.LogMedicineEffectNoteHandler), typeof(buddy.Features.Medicines.RescheduleMedicineForGroupHandler), typeof(buddy.Features.Medicines.RescheduleMedicineHandler), typeof(buddy.Features.Medicines.SetDoseStatusForGroupHandler), typeof(buddy.Features.Medicines.SetDoseStatusHandler), typeof(buddy.Features.Medicines.SetMedicineEffectInclusionHandler), typeof(buddy.Features.Medicines.ShareMedicineWithGroupHandler), typeof(buddy.Features.Medicines.StopMedicineScheduleForGroupHandler), typeof(buddy.Features.Medicines.StopMedicineScheduleHandler), typeof(buddy.Features.Medicines.UnshareMedicineFromGroupHandler), typeof(buddy.Features.Medicines.UpdateMedicineDetailsForGroupHandler), typeof(buddy.Features.Medicines.UpdateMedicineDetailsHandler), typeof(buddy.Features.Pickups.AssignPickupHandler), typeof(buddy.Features.Pickups.ClearPickupHandler), typeof(buddy.Features.Pickups.CreatePickupIcalTokenHandler), typeof(buddy.Features.Pickups.GetPickupIcalFeedHandler), typeof(buddy.Features.Pickups.ListPickupIcalTokensHandler), typeof(buddy.Features.Pickups.ListPickupScheduleHandler), typeof(buddy.Features.Pickups.RevokePickupIcalTokenHandler), typeof(buddy.Features.PrintTemplates.CreatePrintTemplateHandler), typeof(buddy.Features.PrintTemplates.DeletePrintTemplateHandler), typeof(buddy.Features.PrintTemplates.GetPrintTemplateHandler), typeof(buddy.Features.PrintTemplates.ListPrintTemplatesHandler), typeof(buddy.Features.PrintTemplates.RenamePrintTemplateHandler), typeof(buddy.Features.PrintTemplates.ReplacePrintTemplateBabysitterColorsHandler), typeof(buddy.Features.PrintTemplates.ReplacePrintTemplateGuardianColorsHandler), typeof(buddy.Features.PrintTemplates.ReplacePrintTemplateRowsHandler), typeof(buddy.Features.PrintTemplates.UpdatePrintTemplateLayoutHandler), typeof(buddy.Features.Progress.ApproveRewardRequestHandler), typeof(buddy.Features.Progress.CancelRewardRequestHandler), typeof(buddy.Features.Progress.ConfigureGoalPostsHandler), typeof(buddy.Features.Progress.ConfigureRewardsHandler), typeof(buddy.Features.Progress.DeclineRewardRequestHandler), typeof(buddy.Features.Progress.GetChildProgressHandler), typeof(buddy.Features.Progress.GetMyProgressHandler), typeof(buddy.Features.Progress.RecordStarChangeHandler), typeof(buddy.Features.Progress.RequestRewardHandler), typeof(buddy.Features.SleepDiaries.ClearSleepEntryHandler), typeof(buddy.Features.SleepDiaries.CreateSleepDiaryShareLinkHandler), typeof(buddy.Features.SleepDiaries.GetSharedSleepDiaryHandler), typeof(buddy.Features.SleepDiaries.ListSleepDiaryEntriesHandler), typeof(buddy.Features.SleepDiaries.ListSleepDiaryShareLinksHandler), typeof(buddy.Features.SleepDiaries.LogSleepEntryHandler), typeof(buddy.Features.SleepDiaries.RevokeSleepDiaryShareLinkHandler), typeof(buddy.Features.SleepDiaries.UpdateSleepHygieneNotesHandler), typeof(buddy.Features.TaskLibrary.AddSubtaskHandler), typeof(buddy.Features.TaskLibrary.ApproveChecklistSuggestionHandler), typeof(buddy.Features.TaskLibrary.ArchiveTaskTemplateHandler), typeof(buddy.Features.TaskLibrary.CopyTaskTemplateHandler), typeof(buddy.Features.TaskLibrary.CreateTaskTemplateHandler), typeof(buddy.Features.TaskLibrary.DeclineChecklistSuggestionHandler), typeof(buddy.Features.TaskLibrary.ListTaskTemplatesHandler), typeof(buddy.Features.TaskLibrary.RemoveSubtaskHandler), typeof(buddy.Features.TaskLibrary.ReorderSubtasksHandler), typeof(buddy.Features.TaskLibrary.SetChecklistOptionsHandler), typeof(buddy.Features.TaskLibrary.SuggestChecklistItemHandler), typeof(buddy.Features.TaskLibrary.UpdateSubtaskHandler), typeof(buddy.Features.TaskLibrary.UpdateTaskTemplateHandler), typeof(buddy.Features.TaskLibrary.WithdrawChecklistSuggestionHandler), typeof(buddy.Features.TransitionWarnings.ClearItemWarningOverrideHandler), typeof(buddy.Features.TransitionWarnings.ConfigureTransitionWarningsHandler), typeof(buddy.Features.TransitionWarnings.GetChildTransitionWarningsHandler), typeof(buddy.Features.TransitionWarnings.GetMyTransitionWarningsHandler), typeof(buddy.Features.TransitionWarnings.SetItemWarningOverrideHandler), typeof(buddy.Features.Users.DeleteUserHandler), typeof(buddy.Features.Users.ExportPersonalDataHandler), typeof(buddy.Features.Users.GetAccountDeletionPreviewHandler), typeof(buddy.Features.Users.GetOnboardingProgressHandler), typeof(buddy.Features.Users.GetOrCreateUserHandler), typeof(buddy.Features.Users.ResendEmailVerificationHandler), typeof(buddy.Features.Users.UpdateCallingNameHandler), typeof(buddy.Features.Users.UpdateEmailHandler), typeof(buddy.Features.Users.UpdateLanguageHandler), typeof(buddy.Features.Users.UpdateNameHandler), typeof(buddy.Features.Users.UpdateOnboardingProgressHandler), typeof(buddy.Features.Users.UpdateTimeZoneHandler), typeof(buddy.Features.Users.UpdateTimerPreferencesHandler), typeof(buddy.Features.Users.VerifyEmailHandler), typeof(buddy.Features.VisitSummaries.CreateVisitSummaryHandler), typeof(buddy.Features.VisitSummaries.CreateVisitSummaryShareLinkHandler), typeof(buddy.Features.VisitSummaries.DeleteVisitSummaryHandler), typeof(buddy.Features.VisitSummaries.GetSharedVisitSummaryHandler), typeof(buddy.Features.VisitSummaries.GetVisitSummaryHandler), typeof(buddy.Features.VisitSummaries.ListVisitSummariesHandler), typeof(buddy.Features.VisitSummaries.RevokeVisitSummaryShareLinkHandler), typeof(buddy.Features.VisitSummaries.UpdateVisitSummaryHandler), typeof(buddy.Features.WorkLocations.AddWorkLocationHandler), typeof(buddy.Features.WorkLocations.ArchiveWorkLocationHandler), typeof(buddy.Features.WorkLocations.ClearWorkLocationOverridesHandler), typeof(buddy.Features.WorkLocations.GetWorkLocationScheduleHandler), typeof(buddy.Features.WorkLocations.ListWorkDaysHandler), typeof(buddy.Features.WorkLocations.ReplaceWorkPatternHandler), typeof(buddy.Features.WorkLocations.SetWorkLocationOverridesHandler), typeof(buddy.Features.WorkLocations.UpdateWorkLocationHandler) }; + return new System.Type[] { typeof(buddy.Features.Babysitters.AddBabysitterHandler), typeof(buddy.Features.Babysitters.ArchiveBabysitterHandler), typeof(buddy.Features.Babysitters.ListChildBabysittersHandler), typeof(buddy.Features.Babysitters.ListMyBabysittersHandler), typeof(buddy.Features.Babysitters.UpdateBabysitterHandler), typeof(buddy.Features.Calendars.CreateCalendarHandler), typeof(buddy.Features.Calendars.CreateIcalTokenHandler), typeof(buddy.Features.Calendars.CreateItemHandler), typeof(buddy.Features.Calendars.DeleteCalendarHandler), typeof(buddy.Features.Calendars.DeleteItemHandler), typeof(buddy.Features.Calendars.GetCalendarHandler), typeof(buddy.Features.Calendars.GetIcalFeedHandler), typeof(buddy.Features.Calendars.ListAssignableMembersHandler), typeof(buddy.Features.Calendars.ListCalendarsHandler), typeof(buddy.Features.Calendars.ListIcalTokensHandler), typeof(buddy.Features.Calendars.ListItemsHandler), typeof(buddy.Features.Calendars.ListOccurrencesHandler), typeof(buddy.Features.Calendars.RemoveMemberHandler), typeof(buddy.Features.Calendars.RescheduleItemHandler), typeof(buddy.Features.Calendars.RevokeIcalTokenHandler), typeof(buddy.Features.Calendars.ScheduleTaskFromTemplateHandler), typeof(buddy.Features.Calendars.SetMemberRoleHandler), typeof(buddy.Features.Calendars.SetOccurrenceSkipHandler), typeof(buddy.Features.Calendars.SetTaskCompletionHandler), typeof(buddy.Features.Calendars.TransferCalendarToGroupHandler), typeof(buddy.Features.Calendars.UpdateCalendarIconHandler), typeof(buddy.Features.Calendars.UpdateItemAlarmsHandler), typeof(buddy.Features.Calendars.UpdateItemDetailsHandler), typeof(buddy.Features.Calendars.UpdateItemRecurrenceHandler), typeof(buddy.Features.Feelings.CheckInHandler), typeof(buddy.Features.Feelings.CreateFeelingsShareLinkHandler), typeof(buddy.Features.Feelings.EditFeelingCheckInHandler), typeof(buddy.Features.Feelings.GetMyFeelingsTodayHandler), typeof(buddy.Features.Feelings.GetSharedFeelingsHandler), typeof(buddy.Features.Feelings.ListFeelingCheckInsHandler), typeof(buddy.Features.Feelings.ListFeelingsShareLinksHandler), typeof(buddy.Features.Feelings.LogFeelingCheckInHandler), typeof(buddy.Features.Feelings.RemoveFeelingCheckInHandler), typeof(buddy.Features.Feelings.RevokeFeelingsShareLinkHandler), typeof(buddy.Features.Feelings.SetCheckInPromptTimesHandler), typeof(buddy.Features.Feelings.SetFeelingsDisplayModeHandler), typeof(buddy.Features.Feelings.UndoCheckInHandler), typeof(buddy.Features.FirstThen.CompleteFirstThenFirstHandler), typeof(buddy.Features.FirstThen.EndFirstThenBoardHandler), typeof(buddy.Features.FirstThen.GetFirstThenBoardHandler), typeof(buddy.Features.FirstThen.GetMyFirstThenBoardHandler), typeof(buddy.Features.FirstThen.ReopenFirstThenFirstHandler), typeof(buddy.Features.FirstThen.SetFirstThenBoardHandler), typeof(buddy.Features.Groups.AcceptGroupInviteHandler), typeof(buddy.Features.Groups.AddChildToGroupHandler), typeof(buddy.Features.Groups.CreateGroupHandler), typeof(buddy.Features.Groups.DeleteGroupHandler), typeof(buddy.Features.Groups.GetGroupHandler), typeof(buddy.Features.Groups.InviteToGroupHandler), typeof(buddy.Features.Groups.ListGroupInvitesHandler), typeof(buddy.Features.Groups.ListGroupsHandler), typeof(buddy.Features.Groups.PreviewGroupInviteHandler), typeof(buddy.Features.Groups.RemoveGroupMemberHandler), typeof(buddy.Features.Groups.RevokeGroupInviteHandler), typeof(buddy.Features.Groups.SetGroupMemberRoleHandler), typeof(buddy.Features.Groups.UpdateCalendarPermissionPolicyHandler), typeof(buddy.Features.Groups.UpdateMealplanPermissionPolicyHandler), typeof(buddy.Features.Groups.UpdateMedicinePermissionPolicyHandler), typeof(buddy.Features.Guardians.AcceptGuardianInviteHandler), typeof(buddy.Features.Guardians.CreateChildHandler), typeof(buddy.Features.Guardians.DeleteChildHandler), typeof(buddy.Features.Guardians.InviteGuardianHandler), typeof(buddy.Features.Guardians.ListChildGuardiansHandler), typeof(buddy.Features.Guardians.ListGuardianInvitesHandler), typeof(buddy.Features.Guardians.ListMyChildrenHandler), typeof(buddy.Features.Guardians.ListMyGuardiansHandler), typeof(buddy.Features.Guardians.ListMySiblingsHandler), typeof(buddy.Features.Guardians.PreviewGuardianInviteHandler), typeof(buddy.Features.Guardians.ResetChildPasswordHandler), typeof(buddy.Features.Guardians.RevokeGuardianInviteHandler), typeof(buddy.Features.Guardians.RevokeGuardianLinkHandler), typeof(buddy.Features.Guardians.UpdateChildChimeHandler), typeof(buddy.Features.Guardians.UpdateChildHomeworkChunksHandler), typeof(buddy.Features.Guardians.UpdateChildLanguageHandler), typeof(buddy.Features.Guardians.UpdateChildTimeZoneHandler), typeof(buddy.Features.Guardians.UpdateChildVisualTimersHandler), typeof(buddy.Features.HouseRules.AcknowledgeRuleHandler), typeof(buddy.Features.HouseRules.AddRuleHandler), typeof(buddy.Features.HouseRules.DiscardRuleDraftHandler), typeof(buddy.Features.HouseRules.EditRuleHandler), typeof(buddy.Features.HouseRules.GetChildRulesHandler), typeof(buddy.Features.HouseRules.ListRulesHandler), typeof(buddy.Features.HouseRules.PublishRuleHandler), typeof(buddy.Features.HouseRules.PublishRulesHandler), typeof(buddy.Features.HouseRules.RemoveRuleHandler), typeof(buddy.Features.HouseRules.ReorderRulesHandler), typeof(buddy.Features.Mealplans.AcknowledgeAiDataSharingHandler), typeof(buddy.Features.Mealplans.AddMealPlanToMealieShoppingListHandler), typeof(buddy.Features.Mealplans.ApplyAiSessionDraftHandler), typeof(buddy.Features.Mealplans.ArchiveMealForGroupHandler), typeof(buddy.Features.Mealplans.ArchiveMealHandler), typeof(buddy.Features.Mealplans.AssignMealToSlotForGroupHandler), typeof(buddy.Features.Mealplans.AssignMealToSlotHandler), typeof(buddy.Features.Mealplans.ClearMealSlotForGroupHandler), typeof(buddy.Features.Mealplans.ClearMealSlotHandler), typeof(buddy.Features.Mealplans.CommitMealPlanImportForGroupHandler), typeof(buddy.Features.Mealplans.CommitMealPlanImportHandler), typeof(buddy.Features.Mealplans.ConnectMealieHandler), typeof(buddy.Features.Mealplans.CreateMealForGroupHandler), typeof(buddy.Features.Mealplans.CreateMealFromRecipeHandler), typeof(buddy.Features.Mealplans.CreateMealHandler), typeof(buddy.Features.Mealplans.CreateMealPlanIcalTokenHandler), typeof(buddy.Features.Mealplans.DiscardAiSessionHandler), typeof(buddy.Features.Mealplans.DisconnectMealieHandler), typeof(buddy.Features.Mealplans.GetCurrentAiSessionHandler), typeof(buddy.Features.Mealplans.GetGroupMealplanStatusHandler), typeof(buddy.Features.Mealplans.GetMealPlanIcalFeedHandler), typeof(buddy.Features.Mealplans.GetMealieConnectionHandler), typeof(buddy.Features.Mealplans.GetMealieRecipeHandler), typeof(buddy.Features.Mealplans.GetMealieRecipeImageHandler), typeof(buddy.Features.Mealplans.GetSharedGroupHandler), typeof(buddy.Features.Mealplans.LinkMealToRecipeHandler), typeof(buddy.Features.Mealplans.ListMealPlanForGroupHandler), typeof(buddy.Features.Mealplans.ListMealPlanHandler), typeof(buddy.Features.Mealplans.ListMealPlanIcalTokensHandler), typeof(buddy.Features.Mealplans.ListMealPlanImportsForGroupHandler), typeof(buddy.Features.Mealplans.ListMealPlanImportsHandler), typeof(buddy.Features.Mealplans.ListMealieShoppingListsHandler), typeof(buddy.Features.Mealplans.ListMealsForGroupHandler), typeof(buddy.Features.Mealplans.ListMealsHandler), typeof(buddy.Features.Mealplans.ListProvidersHandler), typeof(buddy.Features.Mealplans.PreviewMealPlanImportForGroupHandler), typeof(buddy.Features.Mealplans.PreviewMealPlanImportHandler), typeof(buddy.Features.Mealplans.PreviewMealieImportHandler), typeof(buddy.Features.Mealplans.RateMealHandler), typeof(buddy.Features.Mealplans.RemoveProviderApiKeyHandler), typeof(buddy.Features.Mealplans.RevertMealPlanImportForGroupHandler), typeof(buddy.Features.Mealplans.RevertMealPlanImportHandler), typeof(buddy.Features.Mealplans.RevokeMealPlanIcalTokenHandler), typeof(buddy.Features.Mealplans.SearchMealieRecipesHandler), typeof(buddy.Features.Mealplans.SendAiSessionMessageHandler), typeof(buddy.Features.Mealplans.SetActiveProviderHandler), typeof(buddy.Features.Mealplans.SetProviderApiKeyHandler), typeof(buddy.Features.Mealplans.ShareMealPlanWithGroupHandler), typeof(buddy.Features.Mealplans.StartAiSessionHandler), typeof(buddy.Features.Mealplans.TestMealieConnectionHandler), typeof(buddy.Features.Mealplans.TestProviderConnectionHandler), typeof(buddy.Features.Mealplans.UnlinkMealRecipeHandler), typeof(buddy.Features.Mealplans.UnshareMealPlanFromGroupHandler), typeof(buddy.Features.Mealplans.UpdateMealDetailsForGroupHandler), typeof(buddy.Features.Mealplans.UpdateMealDetailsHandler), typeof(buddy.Features.Mealplans.UpdateMealSlotTimesHandler), typeof(buddy.Features.Medicines.ClearMedicineEffectNoteHandler), typeof(buddy.Features.Medicines.CreateMedicineScheduleForGroupHandler), typeof(buddy.Features.Medicines.CreateMedicineScheduleHandler), typeof(buddy.Features.Medicines.GetSharedMedicineGroupHandler), typeof(buddy.Features.Medicines.ListMedicineEffectNotesHandler), typeof(buddy.Features.Medicines.ListMedicineSchedulesForGroupHandler), typeof(buddy.Features.Medicines.ListMedicineSchedulesHandler), typeof(buddy.Features.Medicines.ListTodaysDosesForGroupHandler), typeof(buddy.Features.Medicines.ListTodaysDosesHandler), typeof(buddy.Features.Medicines.LogMedicineEffectNoteHandler), typeof(buddy.Features.Medicines.RescheduleMedicineForGroupHandler), typeof(buddy.Features.Medicines.RescheduleMedicineHandler), typeof(buddy.Features.Medicines.SetDoseStatusForGroupHandler), typeof(buddy.Features.Medicines.SetDoseStatusHandler), typeof(buddy.Features.Medicines.SetMedicineEffectInclusionHandler), typeof(buddy.Features.Medicines.ShareMedicineWithGroupHandler), typeof(buddy.Features.Medicines.StopMedicineScheduleForGroupHandler), typeof(buddy.Features.Medicines.StopMedicineScheduleHandler), typeof(buddy.Features.Medicines.UnshareMedicineFromGroupHandler), typeof(buddy.Features.Medicines.UpdateMedicineDetailsForGroupHandler), typeof(buddy.Features.Medicines.UpdateMedicineDetailsHandler), typeof(buddy.Features.NightDuty.ClearNightOverridesHandler), typeof(buddy.Features.NightDuty.GetNightScheduleHandler), typeof(buddy.Features.NightDuty.ListNightsHandler), typeof(buddy.Features.NightDuty.ReplaceNightPatternHandler), typeof(buddy.Features.NightDuty.SetNightOverridesHandler), typeof(buddy.Features.Pickups.AssignPickupHandler), typeof(buddy.Features.Pickups.ClearPickupHandler), typeof(buddy.Features.Pickups.CreatePickupIcalTokenHandler), typeof(buddy.Features.Pickups.GetPickupIcalFeedHandler), typeof(buddy.Features.Pickups.ListPickupIcalTokensHandler), typeof(buddy.Features.Pickups.ListPickupScheduleHandler), typeof(buddy.Features.Pickups.RevokePickupIcalTokenHandler), typeof(buddy.Features.PrintTemplates.CreatePrintTemplateHandler), typeof(buddy.Features.PrintTemplates.DeletePrintTemplateHandler), typeof(buddy.Features.PrintTemplates.GetPrintTemplateHandler), typeof(buddy.Features.PrintTemplates.ListPrintTemplatesHandler), typeof(buddy.Features.PrintTemplates.RenamePrintTemplateHandler), typeof(buddy.Features.PrintTemplates.ReplacePrintTemplateBabysitterColorsHandler), typeof(buddy.Features.PrintTemplates.ReplacePrintTemplateGuardianColorsHandler), typeof(buddy.Features.PrintTemplates.ReplacePrintTemplateRowsHandler), typeof(buddy.Features.PrintTemplates.UpdatePrintTemplateLayoutHandler), typeof(buddy.Features.Progress.ApproveRewardRequestHandler), typeof(buddy.Features.Progress.CancelRewardRequestHandler), typeof(buddy.Features.Progress.ConfigureGoalPostsHandler), typeof(buddy.Features.Progress.ConfigureRewardsHandler), typeof(buddy.Features.Progress.DeclineRewardRequestHandler), typeof(buddy.Features.Progress.GetChildProgressHandler), typeof(buddy.Features.Progress.GetMyProgressHandler), typeof(buddy.Features.Progress.RecordStarChangeHandler), typeof(buddy.Features.Progress.RequestRewardHandler), typeof(buddy.Features.SleepDiaries.ClearSleepEntryHandler), typeof(buddy.Features.SleepDiaries.CreateSleepDiaryShareLinkHandler), typeof(buddy.Features.SleepDiaries.GetSharedSleepDiaryHandler), typeof(buddy.Features.SleepDiaries.ListSleepDiaryEntriesHandler), typeof(buddy.Features.SleepDiaries.ListSleepDiaryShareLinksHandler), typeof(buddy.Features.SleepDiaries.LogSleepEntryHandler), typeof(buddy.Features.SleepDiaries.RevokeSleepDiaryShareLinkHandler), typeof(buddy.Features.SleepDiaries.UpdateSleepHygieneNotesHandler), typeof(buddy.Features.TaskLibrary.AddSubtaskHandler), typeof(buddy.Features.TaskLibrary.ApproveChecklistSuggestionHandler), typeof(buddy.Features.TaskLibrary.ArchiveTaskTemplateHandler), typeof(buddy.Features.TaskLibrary.CopyTaskTemplateHandler), typeof(buddy.Features.TaskLibrary.CreateTaskTemplateHandler), typeof(buddy.Features.TaskLibrary.DeclineChecklistSuggestionHandler), typeof(buddy.Features.TaskLibrary.ListTaskTemplatesHandler), typeof(buddy.Features.TaskLibrary.RemoveSubtaskHandler), typeof(buddy.Features.TaskLibrary.ReorderSubtasksHandler), typeof(buddy.Features.TaskLibrary.SetChecklistOptionsHandler), typeof(buddy.Features.TaskLibrary.SuggestChecklistItemHandler), typeof(buddy.Features.TaskLibrary.UpdateSubtaskHandler), typeof(buddy.Features.TaskLibrary.UpdateTaskTemplateHandler), typeof(buddy.Features.TaskLibrary.WithdrawChecklistSuggestionHandler), typeof(buddy.Features.TransitionWarnings.ClearItemWarningOverrideHandler), typeof(buddy.Features.TransitionWarnings.ConfigureTransitionWarningsHandler), typeof(buddy.Features.TransitionWarnings.GetChildTransitionWarningsHandler), typeof(buddy.Features.TransitionWarnings.GetMyTransitionWarningsHandler), typeof(buddy.Features.TransitionWarnings.SetItemWarningOverrideHandler), typeof(buddy.Features.Users.DeleteUserHandler), typeof(buddy.Features.Users.ExportPersonalDataHandler), typeof(buddy.Features.Users.GetAccountDeletionPreviewHandler), typeof(buddy.Features.Users.GetOnboardingProgressHandler), typeof(buddy.Features.Users.GetOrCreateUserHandler), typeof(buddy.Features.Users.ResendEmailVerificationHandler), typeof(buddy.Features.Users.UpdateCallingNameHandler), typeof(buddy.Features.Users.UpdateEmailHandler), typeof(buddy.Features.Users.UpdateLanguageHandler), typeof(buddy.Features.Users.UpdateNameHandler), typeof(buddy.Features.Users.UpdateOnboardingProgressHandler), typeof(buddy.Features.Users.UpdateTimeZoneHandler), typeof(buddy.Features.Users.UpdateTimerPreferencesHandler), typeof(buddy.Features.Users.VerifyEmailHandler), typeof(buddy.Features.VisitSummaries.CreateVisitSummaryHandler), typeof(buddy.Features.VisitSummaries.CreateVisitSummaryShareLinkHandler), typeof(buddy.Features.VisitSummaries.DeleteVisitSummaryHandler), typeof(buddy.Features.VisitSummaries.GetSharedVisitSummaryHandler), typeof(buddy.Features.VisitSummaries.GetVisitSummaryHandler), typeof(buddy.Features.VisitSummaries.ListVisitSummariesHandler), typeof(buddy.Features.VisitSummaries.RevokeVisitSummaryShareLinkHandler), typeof(buddy.Features.VisitSummaries.UpdateVisitSummaryHandler), typeof(buddy.Features.WorkLocations.AddWorkLocationHandler), typeof(buddy.Features.WorkLocations.ArchiveWorkLocationHandler), typeof(buddy.Features.WorkLocations.ClearWorkLocationOverridesHandler), typeof(buddy.Features.WorkLocations.GetWorkLocationScheduleHandler), typeof(buddy.Features.WorkLocations.ListWorkDaysHandler), typeof(buddy.Features.WorkLocations.ReplaceWorkPatternHandler), typeof(buddy.Features.WorkLocations.SetWorkLocationOverridesHandler), typeof(buddy.Features.WorkLocations.UpdateWorkLocationHandler) }; } public override System.Type[] MessageTypes() @@ -55,6 +55,7 @@ public sealed class AotRoots [global::System.Diagnostics.CodeAnalysis.DynamicDependency(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.All, typeof(global::Internal.Generated.WolverineHandlers.ClearMealSlotForGroupHandler592960298))] [global::System.Diagnostics.CodeAnalysis.DynamicDependency(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.All, typeof(global::Internal.Generated.WolverineHandlers.ClearMealSlotHandler1473083488))] [global::System.Diagnostics.CodeAnalysis.DynamicDependency(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.All, typeof(global::Internal.Generated.WolverineHandlers.ClearMedicineEffectNoteHandler1969288734))] + [global::System.Diagnostics.CodeAnalysis.DynamicDependency(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.All, typeof(global::Internal.Generated.WolverineHandlers.ClearNightOverridesHandler706950647))] [global::System.Diagnostics.CodeAnalysis.DynamicDependency(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.All, typeof(global::Internal.Generated.WolverineHandlers.ClearPickupHandler1910157263))] [global::System.Diagnostics.CodeAnalysis.DynamicDependency(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.All, typeof(global::Internal.Generated.WolverineHandlers.ClearSleepEntryHandler2037211561))] [global::System.Diagnostics.CodeAnalysis.DynamicDependency(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.All, typeof(global::Internal.Generated.WolverineHandlers.ClearWorkLocationOverridesHandler783886448))] @@ -118,6 +119,7 @@ public sealed class AotRoots [global::System.Diagnostics.CodeAnalysis.DynamicDependency(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.All, typeof(global::Internal.Generated.WolverineHandlers.GetMyFirstThenBoardHandler919653799))] [global::System.Diagnostics.CodeAnalysis.DynamicDependency(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.All, typeof(global::Internal.Generated.WolverineHandlers.GetMyProgressHandler504791611))] [global::System.Diagnostics.CodeAnalysis.DynamicDependency(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.All, typeof(global::Internal.Generated.WolverineHandlers.GetMyTransitionWarningsHandler1922016037))] + [global::System.Diagnostics.CodeAnalysis.DynamicDependency(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.All, typeof(global::Internal.Generated.WolverineHandlers.GetNightScheduleHandler705851564))] [global::System.Diagnostics.CodeAnalysis.DynamicDependency(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.All, typeof(global::Internal.Generated.WolverineHandlers.GetOnboardingProgressHandler57393541))] [global::System.Diagnostics.CodeAnalysis.DynamicDependency(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.All, typeof(global::Internal.Generated.WolverineHandlers.GetOrCreateUserHandler114717089))] [global::System.Diagnostics.CodeAnalysis.DynamicDependency(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.All, typeof(global::Internal.Generated.WolverineHandlers.GetPickupIcalFeedHandler1817096537))] @@ -158,6 +160,7 @@ public sealed class AotRoots [global::System.Diagnostics.CodeAnalysis.DynamicDependency(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.All, typeof(global::Internal.Generated.WolverineHandlers.ListMyChildrenHandler154133600))] [global::System.Diagnostics.CodeAnalysis.DynamicDependency(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.All, typeof(global::Internal.Generated.WolverineHandlers.ListMyGuardiansHandler2110550175))] [global::System.Diagnostics.CodeAnalysis.DynamicDependency(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.All, typeof(global::Internal.Generated.WolverineHandlers.ListMySiblingsHandler1961259964))] + [global::System.Diagnostics.CodeAnalysis.DynamicDependency(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.All, typeof(global::Internal.Generated.WolverineHandlers.ListNightsHandler1495935728))] [global::System.Diagnostics.CodeAnalysis.DynamicDependency(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.All, typeof(global::Internal.Generated.WolverineHandlers.ListOccurrencesHandler549491886))] [global::System.Diagnostics.CodeAnalysis.DynamicDependency(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.All, typeof(global::Internal.Generated.WolverineHandlers.ListPickupIcalTokensHandler409547967))] [global::System.Diagnostics.CodeAnalysis.DynamicDependency(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.All, typeof(global::Internal.Generated.WolverineHandlers.ListPickupScheduleHandler776491695))] @@ -193,6 +196,7 @@ public sealed class AotRoots [global::System.Diagnostics.CodeAnalysis.DynamicDependency(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.All, typeof(global::Internal.Generated.WolverineHandlers.ReopenFirstThenFirstHandler34832478))] [global::System.Diagnostics.CodeAnalysis.DynamicDependency(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.All, typeof(global::Internal.Generated.WolverineHandlers.ReorderRulesHandler1817725080))] [global::System.Diagnostics.CodeAnalysis.DynamicDependency(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.All, typeof(global::Internal.Generated.WolverineHandlers.ReorderSubtasksHandler1995447908))] + [global::System.Diagnostics.CodeAnalysis.DynamicDependency(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.All, typeof(global::Internal.Generated.WolverineHandlers.ReplaceNightPatternHandler99184939))] [global::System.Diagnostics.CodeAnalysis.DynamicDependency(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.All, typeof(global::Internal.Generated.WolverineHandlers.ReplacePrintTemplateBabysitterColorsHandler1188847461))] [global::System.Diagnostics.CodeAnalysis.DynamicDependency(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.All, typeof(global::Internal.Generated.WolverineHandlers.ReplacePrintTemplateGuardianColorsHandler1758384665))] [global::System.Diagnostics.CodeAnalysis.DynamicDependency(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.All, typeof(global::Internal.Generated.WolverineHandlers.ReplacePrintTemplateRowsHandler929418643))] @@ -228,6 +232,7 @@ public sealed class AotRoots [global::System.Diagnostics.CodeAnalysis.DynamicDependency(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.All, typeof(global::Internal.Generated.WolverineHandlers.SetItemWarningOverrideHandler1415397032))] [global::System.Diagnostics.CodeAnalysis.DynamicDependency(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.All, typeof(global::Internal.Generated.WolverineHandlers.SetMedicineEffectInclusionHandler711723341))] [global::System.Diagnostics.CodeAnalysis.DynamicDependency(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.All, typeof(global::Internal.Generated.WolverineHandlers.SetMemberRoleHandler1174458822))] + [global::System.Diagnostics.CodeAnalysis.DynamicDependency(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.All, typeof(global::Internal.Generated.WolverineHandlers.SetNightOverridesHandler1509958458))] [global::System.Diagnostics.CodeAnalysis.DynamicDependency(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.All, typeof(global::Internal.Generated.WolverineHandlers.SetOccurrenceSkipHandler361992586))] [global::System.Diagnostics.CodeAnalysis.DynamicDependency(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.All, typeof(global::Internal.Generated.WolverineHandlers.SetProviderApiKeyHandler371803204))] [global::System.Diagnostics.CodeAnalysis.DynamicDependency(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.All, typeof(global::Internal.Generated.WolverineHandlers.SetTaskCompletionHandler1651680783))] @@ -446,6 +451,11 @@ public sealed class AotRoots [global::System.Diagnostics.CodeAnalysis.DynamicDependency(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.All, typeof(global::buddy.Features.Medicines.UnshareMedicineFromGroupHandler))] [global::System.Diagnostics.CodeAnalysis.DynamicDependency(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.All, typeof(global::buddy.Features.Medicines.UpdateMedicineDetailsForGroupHandler))] [global::System.Diagnostics.CodeAnalysis.DynamicDependency(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.All, typeof(global::buddy.Features.Medicines.UpdateMedicineDetailsHandler))] + [global::System.Diagnostics.CodeAnalysis.DynamicDependency(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.All, typeof(global::buddy.Features.NightDuty.ClearNightOverridesHandler))] + [global::System.Diagnostics.CodeAnalysis.DynamicDependency(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.All, typeof(global::buddy.Features.NightDuty.GetNightScheduleHandler))] + [global::System.Diagnostics.CodeAnalysis.DynamicDependency(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.All, typeof(global::buddy.Features.NightDuty.ListNightsHandler))] + [global::System.Diagnostics.CodeAnalysis.DynamicDependency(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.All, typeof(global::buddy.Features.NightDuty.ReplaceNightPatternHandler))] + [global::System.Diagnostics.CodeAnalysis.DynamicDependency(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.All, typeof(global::buddy.Features.NightDuty.SetNightOverridesHandler))] [global::System.Diagnostics.CodeAnalysis.DynamicDependency(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.All, typeof(global::buddy.Features.Pickups.AssignPickupHandler))] [global::System.Diagnostics.CodeAnalysis.DynamicDependency(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.All, typeof(global::buddy.Features.Pickups.ClearPickupHandler))] [global::System.Diagnostics.CodeAnalysis.DynamicDependency(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.All, typeof(global::buddy.Features.Pickups.CreatePickupIcalTokenHandler))] @@ -696,6 +706,11 @@ public sealed class AotRoots [global::System.Diagnostics.CodeAnalysis.DynamicDependency(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.All, typeof(global::buddy.Features.Medicines.UnshareMedicineFromGroup))] [global::System.Diagnostics.CodeAnalysis.DynamicDependency(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.All, typeof(global::buddy.Features.Medicines.UpdateMedicineDetails))] [global::System.Diagnostics.CodeAnalysis.DynamicDependency(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.All, typeof(global::buddy.Features.Medicines.UpdateMedicineDetailsForGroup))] + [global::System.Diagnostics.CodeAnalysis.DynamicDependency(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.All, typeof(global::buddy.Features.NightDuty.ClearNightOverrides))] + [global::System.Diagnostics.CodeAnalysis.DynamicDependency(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.All, typeof(global::buddy.Features.NightDuty.GetNightSchedule))] + [global::System.Diagnostics.CodeAnalysis.DynamicDependency(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.All, typeof(global::buddy.Features.NightDuty.ListNights))] + [global::System.Diagnostics.CodeAnalysis.DynamicDependency(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.All, typeof(global::buddy.Features.NightDuty.ReplaceNightPattern))] + [global::System.Diagnostics.CodeAnalysis.DynamicDependency(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.All, typeof(global::buddy.Features.NightDuty.SetNightOverrides))] [global::System.Diagnostics.CodeAnalysis.DynamicDependency(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.All, typeof(global::buddy.Features.Pickups.AssignPickup))] [global::System.Diagnostics.CodeAnalysis.DynamicDependency(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.All, typeof(global::buddy.Features.Pickups.ClearPickup))] [global::System.Diagnostics.CodeAnalysis.DynamicDependency(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.All, typeof(global::buddy.Features.Pickups.CreatePickupIcalToken))] diff --git a/src/backend/buddy/Internal/Generated/WolverineHandlers/GetNightScheduleHandler705851564.cs b/src/backend/buddy/Internal/Generated/WolverineHandlers/GetNightScheduleHandler705851564.cs new file mode 100644 index 00000000..dd7226ec --- /dev/null +++ b/src/backend/buddy/Internal/Generated/WolverineHandlers/GetNightScheduleHandler705851564.cs @@ -0,0 +1,58 @@ +// +#pragma warning disable +using buddy.Features.Babysitters; +using buddy.Features.Guardians; +using buddy.Features.NightDuty; +using buddy.Features.Users; +using System; + +namespace Internal.Generated.WolverineHandlers +{ + // START: GetNightScheduleHandler705851564 + [global::System.CodeDom.Compiler.GeneratedCode("JasperFx", "1.0.0")] + public sealed class GetNightScheduleHandler705851564 : Wolverine.Runtime.Handlers.MessageHandler + { + private readonly System.TimeProvider _timeProvider; + private readonly buddy.Features.Babysitters.IBabysitterListEventStore _babysitterListEventStore; + private readonly buddy.Features.Guardians.IGuardianLinkEventStore _guardianLinkEventStore; + private readonly buddy.Features.NightDuty.INightScheduleEventStore _nightScheduleEventStore; + private readonly buddy.Features.Users.IUserEventStore _userEventStore; + + public GetNightScheduleHandler705851564(System.TimeProvider timeProvider, buddy.Features.Babysitters.IBabysitterListEventStore babysitterListEventStore, buddy.Features.Guardians.IGuardianLinkEventStore guardianLinkEventStore, buddy.Features.NightDuty.INightScheduleEventStore nightScheduleEventStore, buddy.Features.Users.IUserEventStore userEventStore) + { + _timeProvider = timeProvider; + _babysitterListEventStore = babysitterListEventStore; + _guardianLinkEventStore = guardianLinkEventStore; + _nightScheduleEventStore = nightScheduleEventStore; + _userEventStore = userEventStore; + } + + public override async System.Threading.Tasks.Task HandleAsync(Wolverine.Runtime.MessageContext context, System.Threading.CancellationToken cancellation) + { + // The actual message body + var getNightSchedule = (buddy.Features.NightDuty.GetNightSchedule)context.Envelope.Message; + + using var disposable = buddy.Common.Concurrency.StreamVersionScopeMiddleware.Before(); + try + { + Wolverine.Runtime.WolverineTracing.MessageActivity(context.Envelope)?.SetTag("message.handler", "buddy.Features.NightDuty.GetNightScheduleHandler"); + Wolverine.Runtime.WolverineTracing.MessageActivity(context.Envelope)?.SetTag("handler.type", "buddy.Features.NightDuty.GetNightScheduleHandler"); + + // The actual message execution + var outgoing1 = await buddy.Features.NightDuty.GetNightScheduleHandler.Handle(getNightSchedule, _nightScheduleEventStore, _guardianLinkEventStore, _babysitterListEventStore, _userEventStore, _timeProvider, cancellation).ConfigureAwait(false); + + + // Outgoing, cascaded message + await context.EnqueueCascadingAsync(outgoing1).ConfigureAwait(false); + } + finally + { + buddy.Common.Concurrency.StreamVersionScopeMiddleware.Finally(disposable); + } + } + } + + // END: GetNightScheduleHandler705851564 + + +} diff --git a/src/backend/buddy/Internal/Generated/WolverineHandlers/ListNightsHandler1495935728.cs b/src/backend/buddy/Internal/Generated/WolverineHandlers/ListNightsHandler1495935728.cs new file mode 100644 index 00000000..798148f2 --- /dev/null +++ b/src/backend/buddy/Internal/Generated/WolverineHandlers/ListNightsHandler1495935728.cs @@ -0,0 +1,53 @@ +// +#pragma warning disable +using buddy.Features.Babysitters; +using buddy.Features.Guardians; +using buddy.Features.NightDuty; + +namespace Internal.Generated.WolverineHandlers +{ + // START: ListNightsHandler1495935728 + [global::System.CodeDom.Compiler.GeneratedCode("JasperFx", "1.0.0")] + public sealed class ListNightsHandler1495935728 : Wolverine.Runtime.Handlers.MessageHandler + { + private readonly buddy.Features.Babysitters.IBabysitterListEventStore _babysitterListEventStore; + private readonly buddy.Features.Guardians.IGuardianLinkEventStore _guardianLinkEventStore; + private readonly buddy.Features.NightDuty.INightScheduleEventStore _nightScheduleEventStore; + + public ListNightsHandler1495935728(buddy.Features.Babysitters.IBabysitterListEventStore babysitterListEventStore, buddy.Features.Guardians.IGuardianLinkEventStore guardianLinkEventStore, buddy.Features.NightDuty.INightScheduleEventStore nightScheduleEventStore) + { + _babysitterListEventStore = babysitterListEventStore; + _guardianLinkEventStore = guardianLinkEventStore; + _nightScheduleEventStore = nightScheduleEventStore; + } + + public override async System.Threading.Tasks.Task HandleAsync(Wolverine.Runtime.MessageContext context, System.Threading.CancellationToken cancellation) + { + var listNightsValidator = new buddy.Features.NightDuty.ListNightsValidator(); + // The actual message body + var listNights = (buddy.Features.NightDuty.ListNights)context.Envelope.Message; + + using var disposable = buddy.Common.Concurrency.StreamVersionScopeMiddleware.Before(); + try + { + Wolverine.Runtime.WolverineTracing.MessageActivity(context.Envelope)?.SetTag("message.handler", "buddy.Features.NightDuty.ListNightsHandler"); + Wolverine.Runtime.WolverineTracing.MessageActivity(context.Envelope)?.SetTag("handler.type", "buddy.Features.NightDuty.ListNightsHandler"); + + // The actual message execution + var outgoing1 = await buddy.Features.NightDuty.ListNightsHandler.Handle(listNights, listNightsValidator, _nightScheduleEventStore, _guardianLinkEventStore, _babysitterListEventStore, cancellation).ConfigureAwait(false); + + + // Outgoing, cascaded message + await context.EnqueueCascadingAsync(outgoing1).ConfigureAwait(false); + } + finally + { + buddy.Common.Concurrency.StreamVersionScopeMiddleware.Finally(disposable); + } + } + } + + // END: ListNightsHandler1495935728 + + +} diff --git a/src/backend/buddy/Internal/Generated/WolverineHandlers/ReplaceNightPatternHandler99184939.cs b/src/backend/buddy/Internal/Generated/WolverineHandlers/ReplaceNightPatternHandler99184939.cs new file mode 100644 index 00000000..8ae08954 --- /dev/null +++ b/src/backend/buddy/Internal/Generated/WolverineHandlers/ReplaceNightPatternHandler99184939.cs @@ -0,0 +1,59 @@ +// +#pragma warning disable +using buddy.Features.Babysitters; +using buddy.Features.Guardians; +using buddy.Features.NightDuty; +using buddy.Features.Users; +using System; + +namespace Internal.Generated.WolverineHandlers +{ + // START: ReplaceNightPatternHandler99184939 + [global::System.CodeDom.Compiler.GeneratedCode("JasperFx", "1.0.0")] + public sealed class ReplaceNightPatternHandler99184939 : Wolverine.Runtime.Handlers.MessageHandler + { + private readonly System.TimeProvider _timeProvider; + private readonly buddy.Features.Babysitters.IBabysitterListEventStore _babysitterListEventStore; + private readonly buddy.Features.Guardians.IGuardianLinkEventStore _guardianLinkEventStore; + private readonly buddy.Features.NightDuty.INightScheduleEventStore _nightScheduleEventStore; + private readonly buddy.Features.Users.IUserEventStore _userEventStore; + + public ReplaceNightPatternHandler99184939(System.TimeProvider timeProvider, buddy.Features.Babysitters.IBabysitterListEventStore babysitterListEventStore, buddy.Features.Guardians.IGuardianLinkEventStore guardianLinkEventStore, buddy.Features.NightDuty.INightScheduleEventStore nightScheduleEventStore, buddy.Features.Users.IUserEventStore userEventStore) + { + _timeProvider = timeProvider; + _babysitterListEventStore = babysitterListEventStore; + _guardianLinkEventStore = guardianLinkEventStore; + _nightScheduleEventStore = nightScheduleEventStore; + _userEventStore = userEventStore; + } + + public override async System.Threading.Tasks.Task HandleAsync(Wolverine.Runtime.MessageContext context, System.Threading.CancellationToken cancellation) + { + var replaceNightPatternValidator = new buddy.Features.NightDuty.ReplaceNightPatternValidator(); + // The actual message body + var replaceNightPattern = (buddy.Features.NightDuty.ReplaceNightPattern)context.Envelope.Message; + + using var disposable = buddy.Common.Concurrency.StreamVersionScopeMiddleware.Before(); + try + { + Wolverine.Runtime.WolverineTracing.MessageActivity(context.Envelope)?.SetTag("message.handler", "buddy.Features.NightDuty.ReplaceNightPatternHandler"); + Wolverine.Runtime.WolverineTracing.MessageActivity(context.Envelope)?.SetTag("handler.type", "buddy.Features.NightDuty.ReplaceNightPatternHandler"); + + // The actual message execution + var outgoing1 = await buddy.Features.NightDuty.ReplaceNightPatternHandler.Handle(replaceNightPattern, replaceNightPatternValidator, _nightScheduleEventStore, _guardianLinkEventStore, _babysitterListEventStore, _userEventStore, _timeProvider, cancellation).ConfigureAwait(false); + + + // Outgoing, cascaded message + await context.EnqueueCascadingAsync(outgoing1).ConfigureAwait(false); + } + finally + { + buddy.Common.Concurrency.StreamVersionScopeMiddleware.Finally(disposable); + } + } + } + + // END: ReplaceNightPatternHandler99184939 + + +} diff --git a/src/backend/buddy/Internal/Generated/WolverineHandlers/SetNightOverridesHandler1509958458.cs b/src/backend/buddy/Internal/Generated/WolverineHandlers/SetNightOverridesHandler1509958458.cs new file mode 100644 index 00000000..382df575 --- /dev/null +++ b/src/backend/buddy/Internal/Generated/WolverineHandlers/SetNightOverridesHandler1509958458.cs @@ -0,0 +1,56 @@ +// +#pragma warning disable +using buddy.Features.Babysitters; +using buddy.Features.Guardians; +using buddy.Features.NightDuty; +using System; + +namespace Internal.Generated.WolverineHandlers +{ + // START: SetNightOverridesHandler1509958458 + [global::System.CodeDom.Compiler.GeneratedCode("JasperFx", "1.0.0")] + public sealed class SetNightOverridesHandler1509958458 : Wolverine.Runtime.Handlers.MessageHandler + { + private readonly System.TimeProvider _timeProvider; + private readonly buddy.Features.Babysitters.IBabysitterListEventStore _babysitterListEventStore; + private readonly buddy.Features.Guardians.IGuardianLinkEventStore _guardianLinkEventStore; + private readonly buddy.Features.NightDuty.INightScheduleEventStore _nightScheduleEventStore; + + public SetNightOverridesHandler1509958458(System.TimeProvider timeProvider, buddy.Features.Babysitters.IBabysitterListEventStore babysitterListEventStore, buddy.Features.Guardians.IGuardianLinkEventStore guardianLinkEventStore, buddy.Features.NightDuty.INightScheduleEventStore nightScheduleEventStore) + { + _timeProvider = timeProvider; + _babysitterListEventStore = babysitterListEventStore; + _guardianLinkEventStore = guardianLinkEventStore; + _nightScheduleEventStore = nightScheduleEventStore; + } + + public override async System.Threading.Tasks.Task HandleAsync(Wolverine.Runtime.MessageContext context, System.Threading.CancellationToken cancellation) + { + var setNightOverridesValidator = new buddy.Features.NightDuty.SetNightOverridesValidator(); + // The actual message body + var setNightOverrides = (buddy.Features.NightDuty.SetNightOverrides)context.Envelope.Message; + + using var disposable = buddy.Common.Concurrency.StreamVersionScopeMiddleware.Before(); + try + { + Wolverine.Runtime.WolverineTracing.MessageActivity(context.Envelope)?.SetTag("message.handler", "buddy.Features.NightDuty.SetNightOverridesHandler"); + Wolverine.Runtime.WolverineTracing.MessageActivity(context.Envelope)?.SetTag("handler.type", "buddy.Features.NightDuty.SetNightOverridesHandler"); + + // The actual message execution + var outgoing1 = await buddy.Features.NightDuty.SetNightOverridesHandler.Handle(setNightOverrides, setNightOverridesValidator, _nightScheduleEventStore, _guardianLinkEventStore, _babysitterListEventStore, _timeProvider, cancellation).ConfigureAwait(false); + + + // Outgoing, cascaded message + await context.EnqueueCascadingAsync(outgoing1).ConfigureAwait(false); + } + finally + { + buddy.Common.Concurrency.StreamVersionScopeMiddleware.Finally(disposable); + } + } + } + + // END: SetNightOverridesHandler1509958458 + + +} diff --git a/src/backend/buddy/Program.cs b/src/backend/buddy/Program.cs index b65c5684..c7080965 100644 --- a/src/backend/buddy/Program.cs +++ b/src/backend/buddy/Program.cs @@ -21,6 +21,7 @@ using buddy.Features.HouseRules; using buddy.Features.Mealplans; using buddy.Features.Medicines; +using buddy.Features.NightDuty; using buddy.Features.Pickups; using buddy.Features.Privacy; using buddy.Features.PrintTemplates; @@ -136,6 +137,9 @@ builder.Services.AddBabysittersFeature(builder.Configuration); builder.Services.AddPickupsFeature(builder.Configuration); builder.Services.AddWorkLocationsFeature(builder.Configuration); +// After Users, Guardians and Babysitters: reads IGuardianLinkEventStore, IBabysitterListEventStore +// and the child's local clock (IUserEventStore). +builder.Services.AddNightDutyFeature(builder.Configuration); // After Guardians, Groups, Calendars and WorkLocations: its write-time reference checks read their stores. builder.Services.AddPrintTemplatesFeature(builder.Configuration); builder.Services.AddProgressFeature(builder.Configuration); @@ -252,6 +256,11 @@ app.MapWorkLocationsFeature(); } +if (features.NightDuty) +{ + app.MapNightDutyFeature(); +} + if (features.Printing) { app.MapPrintTemplatesFeature(); diff --git a/src/frontend/buddy/e2e/night-duty.spec.ts b/src/frontend/buddy/e2e/night-duty.spec.ts new file mode 100644 index 00000000..b5633eed --- /dev/null +++ b/src/frontend/buddy/e2e/night-duty.spec.ts @@ -0,0 +1,76 @@ +import { DISPOSABLE_GUARDIAN_GIVEN_NAME, expect, test } from './support/auth-fixture'; +import { createChild } from './support/guardian-data'; + +// Night duty end to end against the real API (docs/backend/analysis/night-duty.md): save a fixed +// schedule with a screen night, change a single night, switch two nights (each keeps its note), +// undo a change, and check it all survives a reload. +// +// A disposable guardian whose only child is this test's own, so the schedule never leaks into +// other runs. The week view starts tonight, so its first two tiles are tonight and tomorrow. + +const WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; + +test('guardian plans the nights, changes and switches them, and it persists', async ({ + page, + loginAs, + newGuardian, +}) => { + await loginAs(await newGuardian()); + await createChild(page); + + await page.goto('/guardian/night-duty'); + await expect(page.getByRole('heading', { name: "Who's on duty at night." })).toBeVisible(); + + // Every night of the one-week schedule is the guardian's; tomorrow is screen night. + const pattern = page.locator('app-night-pattern-editor'); + const guardianOption = { label: `👤 ${DISPOSABLE_GUARDIAN_GIVEN_NAME}` }; + for (const day of WEEKDAYS) { + await pattern.getByLabel(`Week A, ${day}: who`).selectOption(guardianOption); + } + const tomorrow = WEEKDAYS[(new Date().getDay() + 1) % 7]!; + await pattern.getByLabel(`Week A, ${tomorrow}: note`).fill('Screen night'); + await pattern.getByRole('button', { name: 'Save schedule' }).click(); + await expect(pattern.getByText('Schedule saved.')).toBeVisible(); + await expect(pattern.getByText(/\(current\)/)).toBeVisible(); + + const week = page.locator('app-night-week'); + const tiles = week.locator('[data-night]'); + const tonight = tiles.nth(0); + const tomorrowTile = tiles.nth(1); + const dayAfter = tiles.nth(2); + + await expect(tonight).toContainText(DISPOSABLE_GUARDIAN_GIVEN_NAME); + await expect(tonight).toContainText('From schedule'); + await expect(tomorrowTile).toContainText('Screen night'); + + // Change one night: nothing planned the day after tomorrow. + await dayAfter.getByRole('button', { name: /^Change/ }).click(); + const dialog = page.getByRole('dialog'); + await dialog.getByLabel('Who is on duty').selectOption({ label: 'Nothing planned' }); + await dialog.getByRole('button', { name: 'Save night' }).click(); + await expect(dialog).toBeHidden(); + await expect(dayAfter).toContainText('Changed'); + await expect(dayAfter).toContainText('Nothing planned'); + + // Switch tonight and the day after: the guardian moves, each night keeps its own note. + await week.getByRole('button', { name: 'Switch two nights' }).click(); + await tonight.getByRole('button').click(); + await dayAfter.getByRole('button').click(); + await week.getByRole('button', { name: /^Switch .* and / }).click(); + await expect(tonight).toContainText('Changed'); + await expect(tonight).toContainText('Nothing planned'); + await expect(dayAfter).toContainText(DISPOSABLE_GUARDIAN_GIVEN_NAME); + + // Undo tonight: back to the schedule. + await tonight.getByRole('button', { name: /^Undo/ }).click(); + await expect(tonight).toContainText('From schedule'); + await expect(tonight).toContainText(DISPOSABLE_GUARDIAN_GIVEN_NAME); + + await page.reload(); + await expect(page.getByRole('heading', { name: "Who's on duty at night." })).toBeVisible(); + const reloaded = page.locator('app-night-week [data-night]'); + await expect(reloaded.nth(0)).toContainText('From schedule'); + await expect(reloaded.nth(1)).toContainText('Screen night'); + await expect(reloaded.nth(2)).toContainText('Changed'); + await expect(reloaded.nth(2)).toContainText(DISPOSABLE_GUARDIAN_GIVEN_NAME); +}); diff --git a/src/frontend/buddy/screenshots/demo-family.ts b/src/frontend/buddy/screenshots/demo-family.ts index 0c3f99d0..6ccf1171 100644 --- a/src/frontend/buddy/screenshots/demo-family.ts +++ b/src/frontend/buddy/screenshots/demo-family.ts @@ -8,10 +8,11 @@ import { } from '../e2e/support/mealie-client'; import type { DemoFamily } from './pages'; -// Seeds the "Holm" demo family that the documentation screenshots show: one guardian (Sara) with -// two children (Emil, Ida), a family group and calendar, tasks, meals, medicine, pickups, work -// locations, progress goals and a print template. Everything goes through the real API as Sara, -// so the screenshots show what a guardian would actually see. +// Seeds the "Holm" demo family that the documentation screenshots show: two guardians (Sara, and +// Jonas as the children's other parent) with two children (Emil, Ida), a family group and +// calendar, tasks, meals, medicine, pickups, night duty, work locations, progress goals and a +// print template. Everything goes through the real API as Sara (Jonas only joins), so the +// screenshots show what a guardian would actually see. // // The demo accounts are fixed usernames (DEMO_USERNAMES) that never collide with the seeded // alice/bob/carol accounts the e2e specs rely on. Every run first removes the previous run's demo @@ -31,6 +32,8 @@ export const DEMO_PASSWORD = 'demo-screenshots-pw'; export const DEMO_USERNAMES = { guardian: 'demo.sara', + // Emil and Ida's other parent, so night duty and pickups show a Mom/Dad rhythm. + coGuardian: 'demo.jonas', child: 'demo.emil', sibling: 'demo.ida', } as const; @@ -143,15 +146,21 @@ async function findKeycloakUserId(username: string): Promise { return users[0]?.id ?? null; } -async function createGuardianIdentity(): Promise { +const CO_GUARDIAN_EMAIL = 'jonas.holm@buddy.test'; + +async function createGuardianIdentity( + username: string = DEMO_USERNAMES.guardian, + email = 'sara.holm@buddy.test', + firstName = 'Sara', +): Promise { const response = await keycloakAdmin('/users', { method: 'POST', body: JSON.stringify({ - username: DEMO_USERNAMES.guardian, - email: 'sara.holm@buddy.test', + username, + email, emailVerified: true, enabled: true, - firstName: 'Sara', + firstName, lastName: 'Holm', // Without it Buddy turns the account away (the buddy-guardian Keycloak group). groups: ['/buddy-guardian'], @@ -160,7 +169,7 @@ async function createGuardianIdentity(): Promise { }); if (!response.ok) { - throw new Error(`Creating Keycloak user ${DEMO_USERNAMES.guardian} failed: ${response.status}`); + throw new Error(`Creating Keycloak user ${username} failed: ${response.status}`); } } @@ -267,9 +276,66 @@ async function deleteMail(to: string): Promise { // ---- Reset ------------------------------------------------------------------------------------- +// Jonas's backend account and his links to the children, best effort. +async function resetCoGuardian(): Promise { + if (!(await findKeycloakUserId(DEMO_USERNAMES.coGuardian))) { + return; + } + + const api = await Api.as(DEMO_USERNAMES.coGuardian, DEMO_PASSWORD).catch(() => null); + + if (api) { + try { + for (const child of await api.get('/users/me/children')) { + await api.delete(`/users/me/children/${child.id}/guardian-link`).catch(warn); + } + + await api.delete('/users/me').catch(warn); + } finally { + await api.dispose(); + } + } +} + +// Joins Jonas as the children's other parent: an invite per child, accepted from the mail. +async function addCoGuardian(api: Api, children: Named[]): Promise { + await createGuardianIdentity(DEMO_USERNAMES.coGuardian, CO_GUARDIAN_EMAIL, 'Jonas'); + const jonas = await Api.as(DEMO_USERNAMES.coGuardian, DEMO_PASSWORD); + + try { + const me = await jonas.get('/users/me'); + await jonas.patch('/users/me/name', { givenName: 'Jonas', familyName: 'Holm' }); + await jonas.patch('/users/me/calling-name', { callingName: 'Dad' }); + await jonas.patch('/users/me/timezone', { timeZoneId: TIME_ZONE }); + await jonas.patch('/users/me/language', { language: 'en' }); + + for (const child of children) { + await deleteMail(CO_GUARDIAN_EMAIL); + await api.post(`/users/me/children/${child.id}/guardian-invites`, { + email: CO_GUARDIAN_EMAIL, + kind: 'Parent', + }); + const token = await inviteTokenFromMail(CO_GUARDIAN_EMAIL, 'guardian-invite'); + + if (!token) { + throw new Error(`No guardian invite mail for ${CO_GUARDIAN_EMAIL}`); + } + + await jonas.post(`/guardian-invites/${token}/accept`); + } + + await deleteMail(CO_GUARDIAN_EMAIL); + return me; + } finally { + await jonas.dispose(); + } +} + // Removes the previous run's demo family: Sara's groups (and with them their calendars), her -// links to the children, her backend account, and all three Keycloak identities. +// and Jonas's links to the children, their backend accounts, and all the Keycloak identities. export async function resetDemoFamily(): Promise { + await resetCoGuardian(); + if (await findKeycloakUserId(DEMO_USERNAMES.guardian)) { const api = await Api.as(DEMO_USERNAMES.guardian, DEMO_PASSWORD).catch(() => null); @@ -332,6 +398,7 @@ export async function seedDemoFamily(): Promise { kind: 'Parent', }); await makeChildLoginUsable(DEMO_USERNAMES.child); + const jonas = await addCoGuardian(api, [emil, ida]); for (const child of [emil, ida]) { await api.patch(`/users/me/children/${child.id}/timezone`, { timeZoneId: TIME_ZONE }); @@ -1213,6 +1280,51 @@ export async function seedDemoFamily(): Promise { // Sara's own calendar subscription to the slots she does, so the card shows an issued link. await api.post('/pickups/me/ical-tokens', {}); + // Night duty: Emil alternates between Mom and Dad over two weeks, with a weekly screen night, + // from today on (a schedule can't start in the past). Then two changed nights: the babysitter + // tomorrow and a sleepover at Grandma's the night after. + const mom = { kind: 0, guardianId: me.id }; + const dad = { kind: 0, guardianId: jonas.id }; + const night = (week: number, day: string, responsible: object | null, note = '') => ({ + week, + day, + entry: { responsible: responsible ?? { kind: 3 }, note }, + }); + await api.put(`/night-duty/children/${emil.id}/pattern`, { + cycleWeeks: 2, + anchorMonday: mondayIso, + days: [ + night(0, 'Monday', mom), + night(0, 'Tuesday', dad), + night(0, 'Wednesday', mom), + night(0, 'Thursday', mom), + night(0, 'Friday', dad, 'Screen night 📱'), + night(0, 'Saturday', dad), + night(0, 'Sunday', mom), + night(1, 'Monday', dad), + night(1, 'Tuesday', mom), + night(1, 'Wednesday', dad), + night(1, 'Thursday', dad), + night(1, 'Friday', mom, 'Screen night 📱'), + night(1, 'Saturday', mom), + ], + }); + await api.put(`/night-duty/children/${emil.id}/overrides`, { + nights: [ + { + date: isoDate(addDays(today, 1)), + entry: { responsible: { kind: 1, guardianId: me.id, babysitterId: maja.id }, note: '' }, + }, + { + date: isoDate(addDays(today, 2)), + entry: { + responsible: { kind: 2, hostName: 'Grandma Lis', location: 'Skovvej 12' }, + note: '', + }, + }, + ], + }); + // Print template ------------------------------------------------------------------------ const row = (fields: Record) => ({ childId: null, @@ -1243,6 +1355,7 @@ export async function seedDemoFamily(): Promise { heightWeight: 2, }), row({ kind: 'Pickup', label: 'Pickup', childId: emil.id }), + row({ kind: 'NightDuty', label: 'Night', childId: emil.id }), row({ kind: 'Meal', label: 'Dinner', childId: emil.id, mealSlot: 'Dinner' }), row({ kind: 'WorkLocation', label: 'Mom works', guardianId: me.id }), row({ @@ -1260,6 +1373,9 @@ export async function seedDemoFamily(): Promise { }), ], }); + await api.put(`/print-templates/${template.id}/colors`, { + colors: [{ guardianId: jonas.id, color: '#f97316' }], + }); await api.put(`/print-templates/${template.id}/babysitter-colors`, { colors: [{ guardianId: me.id, babysitterId: maja.id, color: '#a855f7' }], }); diff --git a/src/frontend/buddy/screenshots/pages.ts b/src/frontend/buddy/screenshots/pages.ts index 8f13cf67..d0b52a55 100644 --- a/src/frontend/buddy/screenshots/pages.ts +++ b/src/frontend/buddy/screenshots/pages.ts @@ -269,6 +269,15 @@ export const SCREENSHOT_PAGES: readonly ScreenshotPage[] = [ as: 'guardian', route: '/guardian/pickup', }, + { + name: 'guardian-night-duty', + title: 'Night duty', + description: + 'Who is on duty each night: the week ahead with changed nights marked, and the fixed schedule they come from.', + as: 'guardian', + route: '/guardian/night-duty', + waitFor: 'Grandma Lis', + }, { name: 'guardian-babysitters', title: 'Babysitters', diff --git a/src/frontend/buddy/src/app/core/api/buddy-api.ts b/src/frontend/buddy/src/app/core/api/buddy-api.ts index dbb70f3f..4172b598 100644 --- a/src/frontend/buddy/src/app/core/api/buddy-api.ts +++ b/src/frontend/buddy/src/app/core/api/buddy-api.ts @@ -2324,6 +2324,70 @@ export interface paths { patch?: never; trace?: never; }; + '/night-duty/children/{childId}/pattern': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations['ReplaceNightPattern']; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/night-duty/children/{childId}/overrides': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations['SetNightOverrides']; + post?: never; + delete: operations['ClearNightOverrides']; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/night-duty/children/{childId}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations['GetNightSchedule']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/night-duty/children/{childId}/nights': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations['ListNights']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/print-templates': { parameters: { query?: never; @@ -3469,6 +3533,19 @@ export interface components { }; /** Format: uuid */ BabysitterId: string; + BabysitterNightResponsibleDto: { + /** Format: uuid */ + guardianId: string; + /** Format: uuid */ + babysitterId: string; + name?: null | string; + /** + * Format: int32 + * @description BabysitterNightResponsibleDto + * @enum {integer} + */ + kind: 1; + }; BabysitterRequest: { name: string; contactInfo?: null | string; @@ -4074,6 +4151,16 @@ export interface components { GuardianKind: 'Parent' | 'Guardian'; /** Format: uuid */ GuardianLinkId: string; + GuardianNightResponsibleDto: { + /** Format: uuid */ + guardianId: string; + /** + * Format: int32 + * @description GuardianNightResponsibleDto + * @enum {integer} + */ + kind: 0; + }; GuardianSummary: { id: components['schemas']['UserId']; name: components['schemas']['Name']; @@ -4124,6 +4211,7 @@ export interface components { pickups: boolean; babysitters: boolean; workLocations: boolean; + nightDuty: boolean; printing: boolean; progress: boolean; taskLibrary: boolean; @@ -4496,6 +4584,92 @@ export interface components { givenName: string; familyName: string; }; + Night: { + /** Format: date */ + date: string; + status: components['schemas']['NightStatus']; + }; + NightCleared: { + scheduled: null | components['schemas']['NightEntryDto']; + /** + * Format: int32 + * @description NightCleared + * @enum {integer} + */ + kind: 1; + }; + NightEntryDto: { + responsible: components['schemas']['NightResponsibleDto']; + note?: null | string; + }; + NightOverrideRequest: { + /** Format: date */ + date: string; + entry?: null | components['schemas']['NightEntryDto']; + }; + NightPatternDayDto: { + /** Format: int32 */ + week: number; + day: components['schemas']['DayOfWeek']; + entry: components['schemas']['NightEntryDto']; + }; + NightPatternDayRequest: { + /** Format: int32 */ + week: number; + day: components['schemas']['DayOfWeek']; + entry: components['schemas']['NightEntryDto']; + }; + NightPatternResponse: { + /** Format: date */ + effectiveFrom: string; + /** Format: int32 */ + cycleWeeks: number; + /** Format: date */ + anchorMonday: string; + days: components['schemas']['NightPatternDayDto'][]; + status: components['schemas']['NightPatternStatus']; + }; + /** @enum {string} */ + NightPatternStatus: 'Past' | 'Current' | 'Upcoming'; + NightPlanned: { + entry: components['schemas']['NightEntryDto']; + source: components['schemas']['NightSource']; + scheduled: null | components['schemas']['NightEntryDto']; + /** + * Format: int32 + * @description NightPlanned + * @enum {integer} + */ + kind: 2; + }; + /** @description One of 4 cases, picked by the numeric "kind": 0 = GuardianNightResponsibleDto, 1 = BabysitterNightResponsibleDto, 2 = SleepoverNightResponsibleDto, 3 = UnassignedNightResponsibleDto. */ + NightResponsibleDto: + | components['schemas']['GuardianNightResponsibleDto'] + | components['schemas']['BabysitterNightResponsibleDto'] + | components['schemas']['SleepoverNightResponsibleDto'] + | components['schemas']['UnassignedNightResponsibleDto']; + NightScheduleResponse: { + /** Format: uuid */ + childId: string; + /** Format: date */ + today: string; + patterns: components['schemas']['NightPatternResponse'][]; + }; + /** @enum {string} */ + NightSource: 'Pattern' | 'Override'; + /** @description One of 3 cases, picked by the numeric "kind": 0 = NightUnplanned, 1 = NightCleared, 2 = NightPlanned. */ + NightStatus: + | components['schemas']['NightUnplanned'] + | components['schemas']['NightCleared'] + | components['schemas']['NightPlanned']; + NightUnplanned: { + /** + * Format: int32 + * @description NightUnplanned + * @enum {integer} + */ + kind: 0; + }; OccurrenceSkipResponse: { itemId: components['schemas']['CalendarItemId']; /** Format: date */ @@ -4605,7 +4779,8 @@ export interface components { | 'CalendarEvents' | 'TaskChecklist' | 'Blank' - | 'CalendarAgenda'; + | 'CalendarAgenda' + | 'NightDuty'; PrintTemplateResponse: { /** Format: uuid */ id: string; @@ -4723,6 +4898,15 @@ export interface components { ReorderSubtasksRequest: { newOrder: string[]; }; + ReplaceNightPatternRequest: { + /** Format: int32 */ + cycleWeeks: number; + /** Format: date */ + anchorMonday: string; + days: components['schemas']['NightPatternDayRequest'][]; + /** Format: date */ + effectiveFrom?: null | string; + }; ReplacePrintTemplateBabysitterColorsRequest: { colors: components['schemas']['BabysitterColorRequest'][]; }; @@ -4906,6 +5090,9 @@ export interface components { SetMemberRoleRequest: { role: components['schemas']['CalendarRole']; }; + SetNightOverridesRequest: { + nights: components['schemas']['NightOverrideRequest'][]; + }; SetOccurrenceSkipRequest: { /** Format: date */ date: string; @@ -5054,6 +5241,17 @@ export interface components { /** Format: int32 */ durationMinutes: number; }; + SleepoverNightResponsibleDto: { + hostName: string; + location?: null | string; + contactInfo?: null | string; + /** + * Format: int32 + * @description SleepoverNightResponsibleDto + * @enum {integer} + */ + kind: 2; + }; StartAiSessionRequest: { /** Format: date */ from: string; @@ -5219,6 +5417,14 @@ export interface components { /** @enum {string} */ TimerSize: 'Small' | 'Medium' | 'Large'; TimeZoneId: string; + UnassignedNightResponsibleDto: { + /** + * Format: int32 + * @description UnassignedNightResponsibleDto + * @enum {integer} + */ + kind: 3; + }; Unplanned: { /** * Format: int32 @@ -16204,6 +16410,307 @@ export interface operations { 503: components['responses']['ServiceUnavailable']; }; }; + ReplaceNightPattern: { + parameters: { + query?: never; + header?: never; + path: { + childId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['ReplaceNightPatternRequest']; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['NightScheduleResponse']; + }; + }; + /** @description Bad Request. ErrorEnvelope code: `validation_error`. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorEnvelope']; + }; + }; + 401: components['responses']['Unauthorized']; + /** @description Forbidden. ErrorEnvelope code: `not_a_buddy_member`, `user_not_provisioned`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorEnvelope']; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Conflict. ErrorEnvelope code: `concurrency_conflict`. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorEnvelope']; + }; + }; + 429: components['responses']['TooManyRequests']; + 500: components['responses']['InternalServerError']; + 503: components['responses']['ServiceUnavailable']; + }; + }; + SetNightOverrides: { + parameters: { + query?: never; + header?: never; + path: { + childId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['SetNightOverridesRequest']; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Night'][]; + }; + }; + /** @description Bad Request. ErrorEnvelope code: `validation_error`. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorEnvelope']; + }; + }; + 401: components['responses']['Unauthorized']; + /** @description Forbidden. ErrorEnvelope code: `not_a_buddy_member`, `user_not_provisioned`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorEnvelope']; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Conflict. ErrorEnvelope code: `concurrency_conflict`. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorEnvelope']; + }; + }; + 429: components['responses']['TooManyRequests']; + 500: components['responses']['InternalServerError']; + 503: components['responses']['ServiceUnavailable']; + }; + }; + ClearNightOverrides: { + parameters: { + query: { + from: string; + to: string; + }; + header?: never; + path: { + childId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description No Content */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Bad Request. ErrorEnvelope code: `validation_error`. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorEnvelope']; + }; + }; + 401: components['responses']['Unauthorized']; + /** @description Forbidden. ErrorEnvelope code: `not_a_buddy_member`, `user_not_provisioned`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorEnvelope']; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Conflict. ErrorEnvelope code: `concurrency_conflict`. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorEnvelope']; + }; + }; + 429: components['responses']['TooManyRequests']; + 500: components['responses']['InternalServerError']; + 503: components['responses']['ServiceUnavailable']; + }; + }; + GetNightSchedule: { + parameters: { + query?: never; + header?: { + /** @description An ETag from an earlier 200; when it still matches, the answer is 304 with no body. */ + 'If-None-Match'?: string; + }; + path: { + childId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + /** @description A strong validator: a hash of this body. */ + ETag?: string; + /** @description private, no-cache (revalidate with If-None-Match before reuse). */ + 'Cache-Control'?: string; + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['NightScheduleResponse']; + }; + }; + 304: components['responses']['NotModified']; + 401: components['responses']['Unauthorized']; + /** @description Forbidden. ErrorEnvelope code: `not_a_buddy_member`, `user_not_provisioned`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorEnvelope']; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 429: components['responses']['TooManyRequests']; + 500: components['responses']['InternalServerError']; + 503: components['responses']['ServiceUnavailable']; + }; + }; + ListNights: { + parameters: { + query: { + from: string; + to: string; + }; + header?: { + /** @description An ETag from an earlier 200; when it still matches, the answer is 304 with no body. */ + 'If-None-Match'?: string; + }; + path: { + childId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + /** @description A strong validator: a hash of this body. */ + ETag?: string; + /** @description private, no-cache (revalidate with If-None-Match before reuse). */ + 'Cache-Control'?: string; + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Night'][]; + }; + }; + 304: components['responses']['NotModified']; + /** @description Bad Request. ErrorEnvelope code: `validation_error`. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorEnvelope']; + }; + }; + 401: components['responses']['Unauthorized']; + /** @description Forbidden. ErrorEnvelope code: `not_a_buddy_member`, `user_not_provisioned`. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorEnvelope']; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 429: components['responses']['TooManyRequests']; + 500: components['responses']['InternalServerError']; + 503: components['responses']['ServiceUnavailable']; + }; + }; ListPrintTemplates: { parameters: { query?: never; diff --git a/src/frontend/buddy/src/app/core/features.service.spec.ts b/src/frontend/buddy/src/app/core/features.service.spec.ts index 349a7cd1..9399c28e 100644 --- a/src/frontend/buddy/src/app/core/features.service.spec.ts +++ b/src/frontend/buddy/src/app/core/features.service.spec.ts @@ -24,6 +24,7 @@ describe('FeaturesService', () => { pickups: true, babysitters: true, workLocations: true, + nightDuty: true, printing: true, progress: true, taskLibrary: true, diff --git a/src/frontend/buddy/src/app/core/features.service.ts b/src/frontend/buddy/src/app/core/features.service.ts index 244d0cef..bdcf067a 100644 --- a/src/frontend/buddy/src/app/core/features.service.ts +++ b/src/frontend/buddy/src/app/core/features.service.ts @@ -20,6 +20,7 @@ const ALL_ON: InstallationFeatures = { pickups: true, babysitters: true, workLocations: true, + nightDuty: true, printing: true, progress: true, taskLibrary: true, diff --git a/src/frontend/buddy/src/app/core/help/help-topics.ts b/src/frontend/buddy/src/app/core/help/help-topics.ts index 4537f4fa..aa0da15e 100644 --- a/src/frontend/buddy/src/app/core/help/help-topics.ts +++ b/src/frontend/buddy/src/app/core/help/help-topics.ts @@ -143,6 +143,17 @@ export const HELP_TOPICS: readonly HelpTopic[] = [ sections: [{ id: 'addBabysitter', steps: 3 }, { id: 'useInPickups' }, { id: 'editOrRemove' }], related: ['pickup', 'print'], }, + { + id: 'nightDuty', + feature: 'nightDuty', + sections: [ + { id: 'fixedSchedule', steps: 4 }, + { id: 'changeANight' }, + { id: 'switchNights', steps: 3 }, + { id: 'whereItShows' }, + ], + related: ['pickup', 'babysitters', 'print'], + }, { id: 'workLocations', feature: 'workLocations', diff --git a/src/frontend/buddy/src/app/core/i18n/translations/da/child.ts b/src/frontend/buddy/src/app/core/i18n/translations/da/child.ts index 4015bb5d..ffd8030c 100644 --- a/src/frontend/buddy/src/app/core/i18n/translations/da/child.ts +++ b/src/frontend/buddy/src/app/core/i18n/translations/da/child.ts @@ -113,6 +113,14 @@ export const child = { babysitter: 'Din babysitter', }, }, + tonight: { + title: 'I nat', + guardian: 'putter dig i seng i aften', + babysitter: 'er hos dig i nat', + sleepover: 'Du sover hos {host}', + aGuardian: 'En voksen', + aBabysitter: 'Din babysitter', + }, }, mealplan: { back: 'Tilbage', diff --git a/src/frontend/buddy/src/app/core/i18n/translations/da/help.ts b/src/frontend/buddy/src/app/core/i18n/translations/da/help.ts index d9f3ecb3..e17c9924 100644 --- a/src/frontend/buddy/src/app/core/i18n/translations/da/help.ts +++ b/src/frontend/buddy/src/app/core/i18n/translations/da/help.ts @@ -435,6 +435,38 @@ export const help = { }, }, }, + nightDuty: { + title: 'Nattevagt', + sections: { + fixedSchedule: { + title: 'Lav den faste plan', + body: 'Den faste plan siger, hvem der har hver nat: hvem der putter barnet, står op i nattens løb, eller hvor barnet sover. En nat går fra aftenen til næste morgen. Hvis jeres uger skifter, fx mellem to hjem, så lad planen gentage sig hver 2. til 4. uge. En note som "Skærmaften" kan stå på enhver nat, med eller uden en person.', + steps: { + s1: 'Vælg barnet øverst, hvis du har flere.', + s2: 'Under "Gentages hver" vælger du, hvor mange uger planen varer, og med "Denne uge er" siger du, hvilken uge det er nu.', + s3: 'Vælg hvem der har hver nat, og skriv eventuelle noter.', + s4: 'Vælg datoen under "Gælder fra", og klik på "Gem plan".', + }, + }, + changeANight: { + title: 'Ændr en enkelt nat', + body: 'Klik på "Ændr" ved en nat for at vælge en anden, en babysitter, en overnatning eller "Intet planlagt". Ændringen går forud for den faste plan, kun for den nat, og er markeret "Ændret". Klik på "Fortryd" eller "Tilbage til den faste plan" for at følge planen igen. En ny fast plan fra en dato ændrer aldrig nætterne før den.', + }, + switchNights: { + title: 'Byt to nætter', + body: 'Når to voksne bytter nætter, så byt dem på én gang. Hver nat beholder sin egen note, så en skærmaften bliver på sin dag.', + steps: { + s1: 'Klik på "Byt to nætter".', + s2: 'Vælg de to nætter.', + s3: 'Klik på byt-knappen for at gemme begge.', + }, + }, + whereItShows: { + title: 'Hvor det vises', + body: 'Dit barn ser aftenens plan og note på et "I nat"-kort i sin app. En "Nattevagt"-række i en printskabelon sætter ugens nætter på den printede ugeplan. Alle barnets voksne kan se og ændre planen.', + }, + }, + }, workLocations: { title: 'Arbejdssteder', sections: { diff --git a/src/frontend/buddy/src/app/core/i18n/translations/da/index.ts b/src/frontend/buddy/src/app/core/i18n/translations/da/index.ts index 490bc859..edb2207c 100644 --- a/src/frontend/buddy/src/app/core/i18n/translations/da/index.ts +++ b/src/frontend/buddy/src/app/core/i18n/translations/da/index.ts @@ -16,6 +16,7 @@ import { mealie } from './mealie'; import { mealplan } from './mealplan'; import { onboarding } from './onboarding'; import { medicine } from './medicine'; +import { nightDuty } from './night-duty'; import { pickup } from './pickup'; import { pictograms } from './pictograms'; import { print } from './print'; @@ -48,6 +49,7 @@ export const da = { mealplan, onboarding, medicine, + nightDuty, pickup, pictograms, print, diff --git a/src/frontend/buddy/src/app/core/i18n/translations/da/night-duty.ts b/src/frontend/buddy/src/app/core/i18n/translations/da/night-duty.ts new file mode 100644 index 00000000..09c752d0 --- /dev/null +++ b/src/frontend/buddy/src/app/core/i18n/translations/da/night-duty.ts @@ -0,0 +1,93 @@ +export const nightDuty = { + backLink: 'Tilbage til oversigt', + eyebrow: 'Nattevagt', + title: 'Hvem har natten.', + intro: + 'Hvem putter {child}, står op i nattens løb, og hvor {child} sover. En nat går fra aftenen til næste morgen, og {child} kan se aftenens plan i sin app.', + introNoChild: + 'Hvem putter dit barn, står op i nattens løb, og hvor barnet sover. En nat går fra aftenen til næste morgen.', + pickupLink: 'Aflevering & afhentning', + babysittersLink: 'Administrér babysittere', + childLabel: 'Barn', + noChildren: 'Opret et barn først for at planlægge nætterne.', + loading: 'Indlæser nattevagt…', + loadError: 'Kunne ikke indlæse nattevagt.', + who: { + guardian: 'En voksen', + babysitter: 'Babysitter', + sleepover: 'Overnatning: {host}', + nobody: 'Ingen (kun note)', + nothingPlanned: 'Intet planlagt', + sleepoverOption: 'Overnatning…', + }, + week: { + title: 'Nætter {from} – {to}', + description: + 'Ændrede nætter går forud for den faste plan, kun for den dato. Vælg en nat for at ændre den.', + previous: 'Forrige uge', + next: 'Næste uge', + toMorning: 'til {day}', + fromSchedule: 'Fra den faste plan', + changed: 'Ændret', + scheduleWas: 'Fast plan: {who}', + scheduleWasNothing: 'Fast plan: intet', + nothingPlanned: 'Intet planlagt', + change: 'Ændr', + undo: 'Fortryd', + undoLabel: 'Fortryd ændringen af {day}', + changeLabel: 'Ændr {day}', + loadError: 'Kunne ikke indlæse nætterne.', + saveError: 'Kunne ikke gemme natten.', + switchButton: 'Byt to nætter', + switchCancel: 'Annullér byt', + switchPrompt: 'Vælg de to nætter, der skal byttes.', + switchPick: 'Vælg {day} til byttet', + switchConfirm: 'Byt {first} og {second}', + switchHint: 'Hver nat beholder sin egen note.', + }, + edit: { + title: '{day} nat', + scheduleSays: 'Fast plan: {who}. En ændring gælder kun denne nat.', + scheduleSaysNothing: 'Den faste plan har intet for denne nat. En ændring gælder kun denne nat.', + whoLabel: 'Hvem har natten', + hostLabel: 'Vært', + locationLabel: 'Hvor (valgfrit)', + noteLabel: 'Note (valgfri)', + notePlaceholder: 'fx Skærmaften 📱', + save: 'Gem natten', + cancel: 'Annullér', + backToSchedule: 'Tilbage til den faste plan', + incomplete: + 'Vælg hvem der har natten, skriv en note, eller begge dele. En overnatning skal have en vært.', + }, + pattern: { + title: 'Fast plan', + description: + 'Vælg hvem der har hver nat. Skiftende uger gentages i rækkefølge. Ændringer gælder fra den dato, du vælger, og tidligere nætter beholder den plan, de havde.', + cycleLabel: 'Gentages hver', + cycleOne: '1 uge', + cycleMany: '{count} uger', + currentWeekLabel: 'Denne uge er', + weekName: 'Uge {name}', + effectiveFromLabel: 'Gælder fra', + whoLabel: '{week}, {day}: hvem', + noteLabel: '{week}, {day}: note', + hostLabel: '{week}, {day}: vært', + notePlaceholder: 'Note', + hostPlaceholder: 'Vært', + none: '—', + save: 'Gem plan', + saved: 'Planen er gemt.', + saveError: 'Kunne ikke gemme planen.', + incomplete: 'Hver overnatning skal have en vært.', + replaceHint: 'Gemte planer, der starter {date} eller senere, erstattes.', + versionsTitle: 'Planens versioner', + noVersions: 'Ingen fast plan endnu.', + version: 'Fra {date}: {cycle}', + status: { + Past: 'tidligere', + Current: 'gældende', + Upcoming: 'kommende', + }, + }, +}; diff --git a/src/frontend/buddy/src/app/core/i18n/translations/da/pickup.ts b/src/frontend/buddy/src/app/core/i18n/translations/da/pickup.ts index a77a189f..18b4eb58 100644 --- a/src/frontend/buddy/src/app/core/i18n/translations/da/pickup.ts +++ b/src/frontend/buddy/src/app/core/i18n/translations/da/pickup.ts @@ -1,4 +1,5 @@ export const pickup = { + nightDutyLink: 'Nattevagt', backLink: 'Tilbage til oversigt', eyebrow: 'Afhentning & aflevering', title: 'Planlæg afhentning og aflevering.', diff --git a/src/frontend/buddy/src/app/core/i18n/translations/da/print.ts b/src/frontend/buddy/src/app/core/i18n/translations/da/print.ts index 2b77901e..8e4685a0 100644 --- a/src/frontend/buddy/src/app/core/i18n/translations/da/print.ts +++ b/src/frontend/buddy/src/app/core/i18n/translations/da/print.ts @@ -26,6 +26,7 @@ export const print = { selfEscort: 'Selv', playdate: 'Leg', babysitter: 'Babysitter', + overnight: 'Overnatning', dropOff: 'Aflevering', pickUp: 'Afhentning', mark: 'Ja', @@ -85,6 +86,7 @@ export const print = { meal: 'Måltid', pickup: 'Aflevere / hente', workLocation: 'Arbejdssted', + nightDuty: 'Nattevagt', calendarMarker: 'Markér dage', calendarEvents: 'Begivenheder', taskChecklist: 'Tjekliste med opgaver', diff --git a/src/frontend/buddy/src/app/core/i18n/translations/da/shell.ts b/src/frontend/buddy/src/app/core/i18n/translations/da/shell.ts index 65d6cdbf..359f48c9 100644 --- a/src/frontend/buddy/src/app/core/i18n/translations/da/shell.ts +++ b/src/frontend/buddy/src/app/core/i18n/translations/da/shell.ts @@ -35,6 +35,7 @@ export const shell = { firstThen: 'Først – Så', progress: 'Mål', babysitters: 'Babysittere', + nightDuty: 'Nattevagt', workLocations: 'Arbejdssteder', print: 'Print ugeplan', }, diff --git a/src/frontend/buddy/src/app/core/i18n/translations/en/child.ts b/src/frontend/buddy/src/app/core/i18n/translations/en/child.ts index c08ff4ca..b967adff 100644 --- a/src/frontend/buddy/src/app/core/i18n/translations/en/child.ts +++ b/src/frontend/buddy/src/app/core/i18n/translations/en/child.ts @@ -113,6 +113,14 @@ export const child = { babysitter: 'Your babysitter', }, }, + tonight: { + title: 'Tonight', + guardian: 'puts you to bed tonight', + babysitter: 'is with you tonight', + sleepover: 'You sleep over at {host}’s', + aGuardian: 'A grown-up', + aBabysitter: 'Your babysitter', + }, }, mealplan: { back: 'Back', diff --git a/src/frontend/buddy/src/app/core/i18n/translations/en/help.ts b/src/frontend/buddy/src/app/core/i18n/translations/en/help.ts index 729a64f1..d0389d16 100644 --- a/src/frontend/buddy/src/app/core/i18n/translations/en/help.ts +++ b/src/frontend/buddy/src/app/core/i18n/translations/en/help.ts @@ -434,6 +434,38 @@ export const help = { }, }, }, + nightDuty: { + title: 'Night duty', + sections: { + fixedSchedule: { + title: 'Set up the fixed schedule', + body: 'The fixed schedule says who is on duty each night: who puts the child to bed, gets up in the night, or where the child sleeps. A night runs from the evening into the next morning. If your weeks alternate, for example between two homes, let it repeat every 2 to 4 weeks. A note such as "Screen night" can go on any night, with or without a person.', + steps: { + s1: 'Pick the child at the top, if you have more than one.', + s2: 'Under "Repeats every", choose how many weeks the schedule lasts, and use "This week is" to say which week the current one is.', + s3: 'Choose who is on duty each night and type any note.', + s4: 'Pick the date it "Applies from" and click "Save schedule".', + }, + }, + changeANight: { + title: 'Change a single night', + body: 'Click "Change" on a night to choose someone else, a babysitter, a sleepover, or "Nothing planned". The change wins over the fixed schedule for that night only and is marked "Changed". Click "Undo" or "Back to schedule" to follow the schedule again. Changing the schedule from a date never changes the nights before it.', + }, + switchNights: { + title: 'Switch two nights', + body: 'When two guardians swap nights, switch them in one go. Each night keeps its own note, so a screen night stays on its day.', + steps: { + s1: 'Click "Switch two nights".', + s2: 'Select the two nights.', + s3: 'Click the switch button to save both.', + }, + }, + whereItShows: { + title: 'Where it shows', + body: 'Your child sees tonight\'s plan and note on a "Tonight" card in their app. A "Night" row in a print template puts the week\'s nights on the printed week plan. Every guardian of the child sees and can change the schedule.', + }, + }, + }, workLocations: { title: 'Work locations', sections: { diff --git a/src/frontend/buddy/src/app/core/i18n/translations/en/index.ts b/src/frontend/buddy/src/app/core/i18n/translations/en/index.ts index d7410c3c..7fd335ba 100644 --- a/src/frontend/buddy/src/app/core/i18n/translations/en/index.ts +++ b/src/frontend/buddy/src/app/core/i18n/translations/en/index.ts @@ -16,6 +16,7 @@ import { mealie } from './mealie'; import { mealplan } from './mealplan'; import { onboarding } from './onboarding'; import { medicine } from './medicine'; +import { nightDuty } from './night-duty'; import { pickup } from './pickup'; import { pictograms } from './pictograms'; import { print } from './print'; @@ -48,6 +49,7 @@ export const en = { mealplan, onboarding, medicine, + nightDuty, pickup, pictograms, print, diff --git a/src/frontend/buddy/src/app/core/i18n/translations/en/night-duty.ts b/src/frontend/buddy/src/app/core/i18n/translations/en/night-duty.ts new file mode 100644 index 00000000..636c5b9a --- /dev/null +++ b/src/frontend/buddy/src/app/core/i18n/translations/en/night-duty.ts @@ -0,0 +1,93 @@ +export const nightDuty = { + backLink: 'Back to dashboard', + eyebrow: 'Night duty', + title: "Who's on duty at night.", + intro: + "Who puts {child} to bed, gets up in the night and where {child} sleeps. A night runs from the evening into the next morning, and {child} sees tonight's plan in their app.", + introNoChild: + 'Who puts your child to bed, gets up in the night and where they sleep. A night runs from the evening into the next morning.', + pickupLink: 'Pickup & drop-off', + babysittersLink: 'Manage babysitters', + childLabel: 'Child', + noChildren: 'Add a child first to plan their nights.', + loading: 'Loading night duty…', + loadError: 'Unable to load night duty.', + who: { + guardian: 'A guardian', + babysitter: 'Babysitter', + sleepover: 'Sleepover: {host}', + nobody: 'Nobody (note only)', + nothingPlanned: 'Nothing planned', + sleepoverOption: 'Sleepover…', + }, + week: { + title: 'Nights {from} – {to}', + description: + 'Changed nights win over the fixed schedule for that date only. Select a night to change it.', + previous: 'Previous week', + next: 'Next week', + toMorning: 'to {day}', + fromSchedule: 'From schedule', + changed: 'Changed', + scheduleWas: 'Schedule: {who}', + scheduleWasNothing: 'Schedule: nothing', + nothingPlanned: 'Nothing planned', + change: 'Change', + undo: 'Undo', + undoLabel: 'Undo the change to {day}', + changeLabel: 'Change {day}', + loadError: 'Unable to load the nights.', + saveError: 'Unable to save the night.', + switchButton: 'Switch two nights', + switchCancel: 'Cancel switch', + switchPrompt: 'Select the two nights to switch.', + switchPick: 'Select {day} for the switch', + switchConfirm: 'Switch {first} and {second}', + switchHint: 'Each night keeps its own note.', + }, + edit: { + title: '{day} night', + scheduleSays: 'Fixed schedule: {who}. A change applies to this night only.', + scheduleSaysNothing: + 'The fixed schedule has nothing for this night. A change applies to this night only.', + whoLabel: 'Who is on duty', + hostLabel: 'Host', + locationLabel: 'Where (optional)', + noteLabel: 'Note (optional)', + notePlaceholder: 'e.g. Screen night 📱', + save: 'Save night', + cancel: 'Cancel', + backToSchedule: 'Back to schedule', + incomplete: 'Choose who is on duty, write a note, or both. A sleepover needs a host.', + }, + pattern: { + title: 'Fixed schedule', + description: + 'Choose who is on duty each night. Alternating weeks repeat in order. Changes apply from the date you pick, and earlier nights keep the schedule they had.', + cycleLabel: 'Repeats every', + cycleOne: '1 week', + cycleMany: '{count} weeks', + currentWeekLabel: 'This week is', + weekName: 'Week {name}', + effectiveFromLabel: 'Applies from', + whoLabel: '{week}, {day}: who', + noteLabel: '{week}, {day}: note', + hostLabel: '{week}, {day}: host', + notePlaceholder: 'Note', + hostPlaceholder: 'Host', + none: '—', + save: 'Save schedule', + saved: 'Schedule saved.', + saveError: 'Unable to save the schedule.', + incomplete: 'Every sleepover needs a host.', + replaceHint: 'Saved schedules that start on or after {date} are replaced.', + versionsTitle: 'Schedule versions', + noVersions: 'No fixed schedule yet.', + version: 'From {date}: {cycle}', + status: { + Past: 'past', + Current: 'current', + Upcoming: 'upcoming', + }, + }, +}; diff --git a/src/frontend/buddy/src/app/core/i18n/translations/en/pickup.ts b/src/frontend/buddy/src/app/core/i18n/translations/en/pickup.ts index 4d1a8889..47d4d405 100644 --- a/src/frontend/buddy/src/app/core/i18n/translations/en/pickup.ts +++ b/src/frontend/buddy/src/app/core/i18n/translations/en/pickup.ts @@ -1,4 +1,5 @@ export const pickup = { + nightDutyLink: 'Night duty', backLink: 'Back to dashboard', eyebrow: 'Pickup & drop-off', title: 'Plan pickups and drop-offs.', diff --git a/src/frontend/buddy/src/app/core/i18n/translations/en/print.ts b/src/frontend/buddy/src/app/core/i18n/translations/en/print.ts index 6250e704..6e446ec9 100644 --- a/src/frontend/buddy/src/app/core/i18n/translations/en/print.ts +++ b/src/frontend/buddy/src/app/core/i18n/translations/en/print.ts @@ -26,6 +26,7 @@ export const print = { selfEscort: 'Alone', playdate: 'Playdate', babysitter: 'Babysitter', + overnight: 'Overnight', dropOff: 'Drop-off', pickUp: 'Pick-up', mark: 'Yes', @@ -85,6 +86,7 @@ export const print = { meal: 'Meal', pickup: 'Drop-off / pick-up', workLocation: 'Work location', + nightDuty: 'Night duty', calendarMarker: 'Mark days', calendarEvents: 'Events', taskChecklist: 'Chores checklist', diff --git a/src/frontend/buddy/src/app/core/i18n/translations/en/shell.ts b/src/frontend/buddy/src/app/core/i18n/translations/en/shell.ts index 60fb949e..489364e8 100644 --- a/src/frontend/buddy/src/app/core/i18n/translations/en/shell.ts +++ b/src/frontend/buddy/src/app/core/i18n/translations/en/shell.ts @@ -35,6 +35,7 @@ export const shell = { firstThen: 'First – Then', progress: 'Progress goals', babysitters: 'Babysitters', + nightDuty: 'Night duty', workLocations: 'Work locations', print: 'Print week plan', }, diff --git a/src/frontend/buddy/src/app/core/night-duty.service.spec.ts b/src/frontend/buddy/src/app/core/night-duty.service.spec.ts new file mode 100644 index 00000000..fe836366 --- /dev/null +++ b/src/frontend/buddy/src/app/core/night-duty.service.spec.ts @@ -0,0 +1,137 @@ +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { TestBed } from '@angular/core/testing'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + Night, + NightDutyService, + NightSchedule, + isNightOverride, + nightEntry, + scheduledEntry, +} from './night-duty.service'; +import { RuntimeConfigService } from './runtime-config.service'; + +describe('NightDutyService', () => { + let service: NightDutyService; + let httpMock: HttpTestingController; + + const apiBaseUrl = 'https://api.buddy.test'; + const base = `${apiBaseUrl}/night-duty/children/c-1`; + const mom = { responsible: { kind: 0 as const, guardianId: 'g-1' }, note: '' }; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + { + provide: RuntimeConfigService, + useValue: { apiBaseUrl } as Partial, + }, + ], + }); + + service = TestBed.inject(NightDutyService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + it('GETs a child schedule', async () => { + const schedule: NightSchedule = { childId: 'c-1', today: '2026-11-09', patterns: [] }; + const promise = service.getSchedule('c-1'); + + const req = httpMock.expectOne(base); + expect(req.request.method).toBe('GET'); + req.flush(schedule); + + expect(await promise).toEqual(schedule); + }); + + it('GETs the resolved nights of a range', async () => { + const nights: Night[] = [{ date: '2026-11-09', status: { kind: 0 } }]; + const promise = service.listNights('c-1', '2026-11-09', '2026-11-15'); + + const req = httpMock.expectOne(`${base}/nights?from=2026-11-09&to=2026-11-15`); + expect(req.request.method).toBe('GET'); + req.flush(nights); + + expect(await promise).toEqual(nights); + }); + + it('PUTs a fixed-schedule version', async () => { + const request = { + effectiveFrom: '2026-11-16', + cycleWeeks: 2, + anchorMonday: '2026-11-16', + days: [{ week: 0, day: 'Monday' as const, entry: mom }], + }; + const promise = service.replacePattern('c-1', request); + + const req = httpMock.expectOne(`${base}/pattern`); + expect(req.request.method).toBe('PUT'); + expect(req.request.body).toEqual(request); + req.flush({ childId: 'c-1', today: '2026-11-09', patterns: [] }); + + expect((await promise).childId).toBe('c-1'); + }); + + it('PUTs several overrides in one request', async () => { + const promise = service.setOverrides('c-1', [ + { date: '2026-11-10', entry: mom }, + { date: '2026-11-12', entry: null }, + ]); + + const req = httpMock.expectOne(`${base}/overrides`); + expect(req.request.method).toBe('PUT'); + expect(req.request.body).toEqual({ + nights: [ + { date: '2026-11-10', entry: mom }, + { date: '2026-11-12', entry: null }, + ], + }); + req.flush([]); + + expect(await promise).toEqual([]); + }); + + it('DELETEs the overrides of a range', async () => { + const promise = service.clearOverrides('c-1', '2026-11-10', '2026-11-10'); + + const req = httpMock.expectOne(`${base}/overrides?from=2026-11-10&to=2026-11-10`); + expect(req.request.method).toBe('DELETE'); + req.flush(null); + + await promise; + }); + + describe('night helpers', () => { + const planned = (source: 'Pattern' | 'Override'): Night => ({ + date: '2026-11-10', + status: { kind: 2, entry: mom, source, scheduled: source === 'Override' ? mom : null }, + }); + + it('reads the entry of a planned night only', () => { + expect(nightEntry(planned('Pattern'))).toEqual(mom); + expect(nightEntry({ date: 'x', status: { kind: 1, scheduled: mom } })).toBeNull(); + expect(nightEntry({ date: 'x', status: { kind: 0 } })).toBeNull(); + }); + + it('reads what the schedule said for cleared and planned nights', () => { + expect(scheduledEntry(planned('Override'))).toEqual(mom); + expect(scheduledEntry({ date: 'x', status: { kind: 1, scheduled: mom } })).toEqual(mom); + expect(scheduledEntry({ date: 'x', status: { kind: 0 } })).toBeNull(); + }); + + it('marks cleared nights and override entries as changes', () => { + expect(isNightOverride(planned('Override'))).toBe(true); + expect(isNightOverride({ date: 'x', status: { kind: 1, scheduled: null } })).toBe(true); + expect(isNightOverride(planned('Pattern'))).toBe(false); + expect(isNightOverride({ date: 'x', status: { kind: 0 } })).toBe(false); + }); + }); +}); diff --git a/src/frontend/buddy/src/app/core/night-duty.service.ts b/src/frontend/buddy/src/app/core/night-duty.service.ts new file mode 100644 index 00000000..a0d1c4d8 --- /dev/null +++ b/src/frontend/buddy/src/app/core/night-duty.service.ts @@ -0,0 +1,97 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { firstValueFrom } from 'rxjs'; + +import type { Schemas } from './api/schemas'; +import { RuntimeConfigService } from './runtime-config.service'; + +// Who is on duty at night for a child (docs/backend/analysis/night-duty.md). + +// responsible.kind: 0 guardian, 1 babysitter (name resolved, output only), 2 sleepover, +// 3 unassigned (a note on its own). +export type NightResponsible = Schemas['NightResponsibleDto']; +export type NightResponsibleKind = NightResponsible['kind']; + +export type NightEntry = Schemas['NightEntryDto']; + +// kind 0 = unplanned, 1 = cleared by an override, 2 = planned (from the schedule or an override). +export type NightStatus = Schemas['NightStatus']; +export type NightSource = Schemas['NightSource']; +export type Night = Schemas['Night']; + +export type NightPatternDay = Schemas['NightPatternDayDto']; +export type NightPattern = Schemas['NightPatternResponse']; +export type NightPatternStatus = Schemas['NightPatternStatus']; +export type NightSchedule = Schemas['NightScheduleResponse']; + +export type ReplaceNightPatternRequest = Schemas['ReplaceNightPatternRequest']; + +// entry null = "nothing planned this night" (a cleared night). To follow the fixed schedule +// again, clear the override instead. +export type NightOverride = Schemas['NightOverrideRequest']; + +export const NIGHT_RESPONSIBLE = { + guardian: 0, + babysitter: 1, + sleepover: 2, + unassigned: 3, +} as const satisfies Record; + +export const NIGHT_STATUS = { + unplanned: 0, + cleared: 1, + planned: 2, +} as const satisfies Record; + +// The entry a night resolves to, or null when nothing is planned. +export function nightEntry(night: Night): NightEntry | null { + return night.status.kind === NIGHT_STATUS.planned ? night.status.entry : null; +} + +// What the fixed schedule says for an overridden night, or null. +export function scheduledEntry(night: Night): NightEntry | null { + return night.status.kind === NIGHT_STATUS.unplanned ? null : night.status.scheduled; +} + +// Whether the night is a per-date change: cleared, or planned by an override. +export function isNightOverride(night: Night): boolean { + return ( + night.status.kind === NIGHT_STATUS.cleared || + (night.status.kind === NIGHT_STATUS.planned && night.status.source === 'Override') + ); +} + +@Injectable({ providedIn: 'root' }) +export class NightDutyService { + private readonly http = inject(HttpClient); + private readonly runtimeConfig = inject(RuntimeConfigService); + + private url(childId: string, path = ''): string { + return `${this.runtimeConfig.apiBaseUrl}/night-duty/children/${childId}${path}`; + } + + getSchedule(childId: string): Promise { + return firstValueFrom(this.http.get(this.url(childId))); + } + + listNights(childId: string, from: string, to: string): Promise { + return firstValueFrom( + this.http.get(this.url(childId, '/nights'), { params: { from, to } }), + ); + } + + replacePattern(childId: string, request: ReplaceNightPatternRequest): Promise { + return firstValueFrom(this.http.put(this.url(childId, '/pattern'), request)); + } + + // One request for several nights -- a switch is two -- saved in one append. + setOverrides(childId: string, nights: NightOverride[]): Promise { + return firstValueFrom(this.http.put(this.url(childId, '/overrides'), { nights })); + } + + clearOverrides(childId: string, from: string, to: string): Promise { + return firstValueFrom( + this.http.delete(this.url(childId, '/overrides'), { params: { from, to } }), + ); + } +} diff --git a/src/frontend/buddy/src/app/core/print-templates.service.ts b/src/frontend/buddy/src/app/core/print-templates.service.ts index 2e92394d..1e4cec06 100644 --- a/src/frontend/buddy/src/app/core/print-templates.service.ts +++ b/src/frontend/buddy/src/app/core/print-templates.service.ts @@ -19,6 +19,7 @@ export const PRINT_ROW_KIND = { taskChecklist: 'TaskChecklist', blank: 'Blank', calendarAgenda: 'CalendarAgenda', + nightDuty: 'NightDuty', } as const satisfies Record; export type PrintRowKind = Schemas['PrintRowKind']; diff --git a/src/frontend/buddy/src/app/features/child/home/home.html b/src/frontend/buddy/src/app/features/child/home/home.html index 08ba6d14..419885e1 100644 --- a/src/frontend/buddy/src/app/features/child/home/home.html +++ b/src/frontend/buddy/src/app/features/child/home/home.html @@ -672,4 +672,7 @@

} + + + diff --git a/src/frontend/buddy/src/app/features/child/home/home.ts b/src/frontend/buddy/src/app/features/child/home/home.ts index 243bb9a4..33b572b1 100644 --- a/src/frontend/buddy/src/app/features/child/home/home.ts +++ b/src/frontend/buddy/src/app/features/child/home/home.ts @@ -65,6 +65,7 @@ import { ChildMenu } from './child-menu/child-menu'; import { ChildSuggestions } from './child-suggestions/child-suggestions'; import { FeelingsCheckIn } from './feelings-check-in/feelings-check-in'; import { FirstThenCard } from './first-then-card/first-then-card'; +import { TonightCard } from './tonight-card/tonight-card'; import { Icon } from '../../../shared/icon/icon'; const EVENT_KIND: CalendarItemKind = 'Event'; @@ -163,6 +164,7 @@ const NOW_REFRESH_INTERVAL_MS = 60_000; ChildSuggestions, FeelingsCheckIn, FirstThenCard, + TonightCard, ], templateUrl: './home.html', // Any tap lets the page make sound later (browsers block audio until the user interacts). diff --git a/src/frontend/buddy/src/app/features/child/home/tonight-card/tonight-card.html b/src/frontend/buddy/src/app/features/child/home/tonight-card/tonight-card.html new file mode 100644 index 00000000..80b11e0a --- /dev/null +++ b/src/frontend/buddy/src/app/features/child/home/tonight-card/tonight-card.html @@ -0,0 +1,39 @@ +@if (view(); as tonight) { +
+

+ {{ 'child.home.tonight.title' | translate }} +

+ + @if (tonight.name || tonight.nameKey) { +
+ +
+

+ {{ tonight.name ?? (tonight.nameKey ?? '' | translate) }} +

+ @if (tonight.lineKey; as lineKey) { +

+ {{ lineKey | translate: { host: tonight.host } }} +

+ } +
+
+ } + + @if (tonight.note) { +

+ {{ tonight.note }} +

+ } +
+} diff --git a/src/frontend/buddy/src/app/features/child/home/tonight-card/tonight-card.spec.ts b/src/frontend/buddy/src/app/features/child/home/tonight-card/tonight-card.spec.ts new file mode 100644 index 00000000..074bbca5 --- /dev/null +++ b/src/frontend/buddy/src/app/features/child/home/tonight-card/tonight-card.spec.ts @@ -0,0 +1,128 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { describe, expect, it, vi } from 'vitest'; + +import { ChildClockService } from '../../../../core/child-clock.service'; +import { FeaturesService } from '../../../../core/features.service'; +import { GuardiansService } from '../../../../core/guardians.service'; +import { Night, NightDutyService } from '../../../../core/night-duty.service'; +import { UsersService } from '../../../../core/users.service'; +import { TonightCard } from './tonight-card'; + +describe('TonightCard', () => { + const dad = { + id: 'dad', + name: { givenName: 'Jonas', familyName: 'Holm' }, + callingName: 'Dad', + kind: 'Parent', + }; + + async function settle(fixture: ComponentFixture): Promise { + for (let i = 0; i < 4; i++) { + fixture.detectChanges(); + await new Promise((resolve) => setTimeout(resolve, 0)); + } + fixture.detectChanges(); + } + + function planned(responsible: object, note = ''): Night[] { + return [ + { + date: '2026-11-13', + status: { kind: 2, entry: { responsible, note }, source: 'Pattern', scheduled: null }, + } as Night, + ]; + } + + async function setup( + nights: () => Promise, + options: { enabled?: boolean; guardians?: () => Promise } = {}, + ) { + const listNights = vi.fn(nights); + await TestBed.configureTestingModule({ + imports: [TonightCard], + providers: [ + { provide: NightDutyService, useValue: { listNights } }, + { + provide: GuardiansService, + useValue: { listMyGuardians: vi.fn(options.guardians ?? (async () => [dad])) }, + }, + { + provide: UsersService, + useValue: { ensureCurrentUser: vi.fn(async () => ({ id: 'emil' })) }, + }, + { provide: ChildClockService, useValue: { today: () => '2026-11-13' } }, + { provide: FeaturesService, useValue: { enabled: () => options.enabled ?? true } }, + ], + }).compileComponents(); + + const fixture = TestBed.createComponent(TonightCard); + await settle(fixture); + return { fixture, compiled: fixture.nativeElement as HTMLElement, listNights }; + } + + it("shows tonight's guardian by the name the child uses, and the note", async () => { + const { compiled, listNights } = await setup(async () => + planned({ kind: 0, guardianId: 'dad' }, 'Screen night 📱'), + ); + + expect(listNights).toHaveBeenCalledWith('emil', '2026-11-13', '2026-11-13'); + const card = compiled.querySelector('[data-testid="tonight-card"]')!; + expect(card.textContent).toContain('Tonight'); + expect(card.textContent).toContain('Dad'); + expect(card.textContent).toContain('puts you to bed tonight'); + expect(card.textContent).toContain('Screen night 📱'); + }); + + it('falls back to a generic word when the guardian is unknown', async () => { + const { compiled } = await setup(async () => planned({ kind: 0, guardianId: 'gone' }), { + guardians: async () => { + throw new Error('500'); + }, + }); + + expect(compiled.textContent).toContain('A grown-up'); + }); + + it('shows a babysitter and a sleepover', async () => { + const babysitter = await setup(async () => + planned({ kind: 1, guardianId: 'mom', babysitterId: 'b', name: 'Maja' }), + ); + expect(babysitter.compiled.textContent).toContain('Maja'); + expect(babysitter.compiled.textContent).toContain('is with you tonight'); + TestBed.resetTestingModule(); + + const unnamed = await setup(async () => + planned({ kind: 1, guardianId: 'mom', babysitterId: 'b', name: '' }), + ); + expect(unnamed.compiled.textContent).toContain('Your babysitter'); + TestBed.resetTestingModule(); + + const sleepover = await setup(async () => + planned({ kind: 2, hostName: 'Grandma Lis', location: '', contactInfo: '' }), + ); + expect(sleepover.compiled.textContent).toContain('You sleep over at Grandma Lis’s'); + }); + + it('shows a note on its own without a person', async () => { + const { compiled } = await setup(async () => planned({ kind: 3 }, 'Movie night')); + + expect(compiled.textContent).toContain('Movie night'); + expect(compiled.textContent).not.toContain('puts you to bed'); + }); + + it('renders nothing for an empty night, a failed load or a turned-off feature', async () => { + const empty = await setup(async () => [{ date: '2026-11-13', status: { kind: 0 } }]); + expect(empty.compiled.querySelector('[data-testid="tonight-card"]')).toBeNull(); + TestBed.resetTestingModule(); + + const failed = await setup(async () => { + throw new Error('500'); + }); + expect(failed.compiled.querySelector('[data-testid="tonight-card"]')).toBeNull(); + TestBed.resetTestingModule(); + + const off = await setup(async () => planned({ kind: 3 }, 'x'), { enabled: false }); + expect(off.listNights).not.toHaveBeenCalled(); + expect(off.compiled.querySelector('[data-testid="tonight-card"]')).toBeNull(); + }); +}); diff --git a/src/frontend/buddy/src/app/features/child/home/tonight-card/tonight-card.ts b/src/frontend/buddy/src/app/features/child/home/tonight-card/tonight-card.ts new file mode 100644 index 00000000..5cffcbf4 --- /dev/null +++ b/src/frontend/buddy/src/app/features/child/home/tonight-card/tonight-card.ts @@ -0,0 +1,114 @@ +import { Component, OnInit, computed, inject, signal } from '@angular/core'; + +import { ChildClockService } from '../../../../core/child-clock.service'; +import { FeaturesService } from '../../../../core/features.service'; +import { + GuardianSummary, + GuardiansService, + childFacingName, +} from '../../../../core/guardians.service'; +import { TranslatePipe } from '../../../../core/i18n/translate.pipe'; +import { + NIGHT_RESPONSIBLE, + NightDutyService, + NightEntry, + nightEntry, +} from '../../../../core/night-duty.service'; +import { UsersService } from '../../../../core/users.service'; + +interface TonightView { + icon: string; + // The big name; null for a note on its own. + name: string | null; + // i18n key of the line under the name. + lineKey: string | null; + host: string; + // A name that couldn't be resolved falls back to a generic word. + nameKey: string | null; + note: string; +} + +// "Tonight" on the child's day dashboard (docs/backend/analysis/night-duty.md, Question 7): who is +// on duty tonight, large, and the night's note ("Screen night"). Renders nothing for an empty +// night, and nothing when the load fails -- the rest of the dashboard has its own error. +@Component({ + selector: 'app-tonight-card', + imports: [TranslatePipe], + templateUrl: './tonight-card.html', + host: { class: 'contents' }, +}) +export class TonightCard implements OnInit { + private readonly nightDuty = inject(NightDutyService); + private readonly guardians = inject(GuardiansService); + private readonly users = inject(UsersService); + private readonly clock = inject(ChildClockService); + private readonly features = inject(FeaturesService); + + private readonly entry = signal(null); + private readonly guardianList = signal([]); + + protected readonly view = computed((): TonightView | null => { + const entry = this.entry(); + return entry ? this.toView(entry) : null; + }); + + ngOnInit(): void { + void this.load(); + } + + private async load(): Promise { + if (!this.features.enabled('nightDuty')) { + return; + } + + try { + const me = await this.users.ensureCurrentUser(); + const today = this.clock.today(); + const [nights, guardians] = await Promise.all([ + this.nightDuty.listNights(me.id, today, today), + this.guardians.listMyGuardians().catch((): GuardianSummary[] => []), + ]); + this.guardianList.set(guardians); + this.entry.set(nights[0] ? nightEntry(nights[0]) : null); + } catch { + // No card is the safe fallback. + } + } + + private toView(entry: NightEntry): TonightView { + const responsible = entry.responsible; + const note = entry.note ?? ''; + const base = { host: '', nameKey: null, note }; + + switch (responsible.kind) { + case NIGHT_RESPONSIBLE.guardian: { + const guardian = this.guardianList().find((g) => g.id === responsible.guardianId); + return { + ...base, + icon: '🌙', + name: guardian ? childFacingName(guardian) : null, + nameKey: guardian ? null : 'child.home.tonight.aGuardian', + lineKey: 'child.home.tonight.guardian', + }; + } + case NIGHT_RESPONSIBLE.babysitter: + return { + ...base, + icon: '🧑‍🍼', + name: (responsible.name ?? '') || null, + nameKey: responsible.name ? null : 'child.home.tonight.aBabysitter', + lineKey: 'child.home.tonight.babysitter', + }; + case NIGHT_RESPONSIBLE.sleepover: + return { + ...base, + icon: '🛏️', + name: responsible.hostName, + host: responsible.hostName, + lineKey: 'child.home.tonight.sleepover', + }; + default: + return { ...base, icon: '🌙', name: null, lineKey: null }; + } + } +} diff --git a/src/frontend/buddy/src/app/features/guardian/guardian.routes.ts b/src/frontend/buddy/src/app/features/guardian/guardian.routes.ts index 40eb57cb..b05a2d67 100644 --- a/src/frontend/buddy/src/app/features/guardian/guardian.routes.ts +++ b/src/frontend/buddy/src/app/features/guardian/guardian.routes.ts @@ -30,6 +30,7 @@ import { GuardianVisitSummaries } from './visit-summaries/visit-summaries'; import { VisitSummaryPrintPage } from './visit-summaries/print/visit-summary-print-page'; import { VisitSummaryPage } from './visit-summaries/summary/visit-summary-page'; import { GuardianWorkLocations } from './work-locations/work-locations'; +import { GuardianNightDuty } from './night-duty/night-duty'; export const GUARDIAN_ROUTES: Routes = [ // Outside GuardianShell on purpose: no navigation, header or padding may end up on paper. Listed @@ -168,6 +169,12 @@ export const GUARDIAN_ROUTES: Routes = [ component: GuardianWorkLocations, data: { helpTopic: 'workLocations' }, }, + { + path: 'night-duty', + canActivate: [featureGuard('nightDuty')], + component: GuardianNightDuty, + data: { helpTopic: 'nightDuty' }, + }, { path: 'print', canActivate: [featureGuard('printing')], diff --git a/src/frontend/buddy/src/app/features/guardian/house-rules/rule-editor/rule-editor.ts b/src/frontend/buddy/src/app/features/guardian/house-rules/rule-editor/rule-editor.ts index d2af360f..7ce65efb 100644 --- a/src/frontend/buddy/src/app/features/guardian/house-rules/rule-editor/rule-editor.ts +++ b/src/frontend/buddy/src/app/features/guardian/house-rules/rule-editor/rule-editor.ts @@ -17,6 +17,7 @@ import { Rule, RuleContent, workingContent } from '../../../../core/house-rules. import { TranslatePipe } from '../../../../core/i18n/translate.pipe'; import { MarkdownEditor } from '../../../../shared/markdown-editor/markdown-editor'; import { Toggle } from '../../../../shared/toggle/toggle'; +import { keepTabInside } from '../../../../shared/focus-trap/focus-trap'; export const MAX_TITLE_LENGTH = 100; export const MAX_BODY_LENGTH = 4000; @@ -109,25 +110,6 @@ export class RuleEditor implements OnInit { @HostListener('document:keydown.tab', ['$event']) @HostListener('document:keydown.shift.tab', ['$event']) protected onTab(event: Event): void { - const panel = this.panel()?.nativeElement; - - if (!panel || !(event instanceof KeyboardEvent)) { - return; - } - - const focusable: HTMLElement[] = Array.from( - panel.querySelectorAll( - 'input:not(:disabled), textarea:not(:disabled), button:not(:disabled)', - ), - ); - const first = focusable.at(0) ?? panel; - const last = focusable.at(-1) ?? panel; - const active = document.activeElement as HTMLElement | null; - const inside = active !== null && focusable.includes(active); - - if (!inside || active === (event.shiftKey ? first : last)) { - event.preventDefault(); - (event.shiftKey ? last : first).focus(); - } + keepTabInside(this.panel()?.nativeElement, event); } } diff --git a/src/frontend/buddy/src/app/features/guardian/night-duty/night-duty.html b/src/frontend/buddy/src/app/features/guardian/night-duty/night-duty.html new file mode 100644 index 00000000..223d677b --- /dev/null +++ b/src/frontend/buddy/src/app/features/guardian/night-duty/night-duty.html @@ -0,0 +1,87 @@ + +
+
+

+ {{ 'nightDuty.eyebrow' | translate }} +

+

+ {{ 'nightDuty.title' | translate }} +

+

+ @if (selectedChild(); as child) { + {{ 'nightDuty.intro' | translate: { child: child.name.givenName } }} + } @else { + {{ 'nightDuty.introNoChild' | translate }} + } +

+

+ @if (features.enabled('pickups')) { + {{ 'nightDuty.pickupLink' | translate }} + } + @if (features.enabled('babysitters')) { + {{ 'nightDuty.babysittersLink' | translate }} + } +

+
+ + @if (childList().length > 1) { +
+ + +
+ } +
+ + @if (children.hasValue() && childList().length === 0) { +

+ {{ 'nightDuty.noChildren' | translate }} +

+ } @else if (data.hasValue() && selectedChildId(); as childId) { + @let loaded = data.value(); +
+ + +
+ } @else if (data.error() || children.error()) { + + } @else { + + } +
diff --git a/src/frontend/buddy/src/app/features/guardian/night-duty/night-duty.spec.ts b/src/frontend/buddy/src/app/features/guardian/night-duty/night-duty.spec.ts new file mode 100644 index 00000000..f760d322 --- /dev/null +++ b/src/frontend/buddy/src/app/features/guardian/night-duty/night-duty.spec.ts @@ -0,0 +1,123 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { describe, expect, it, vi } from 'vitest'; + +import { BabysittersService } from '../../../core/babysitters.service'; +import { FeaturesService } from '../../../core/features.service'; +import { GuardiansService } from '../../../core/guardians.service'; +import { NightDutyService } from '../../../core/night-duty.service'; +import { GuardianNightDuty } from './night-duty'; + +describe('GuardianNightDuty', () => { + const emil = { id: 'emil', name: { givenName: 'Emil', familyName: 'Holm' } }; + const ida = { id: 'ida', name: { givenName: 'Ida', familyName: 'Holm' } }; + + async function settle(fixture: ComponentFixture): Promise { + for (let i = 0; i < 5; i++) { + fixture.detectChanges(); + await new Promise((resolve) => setTimeout(resolve, 0)); + } + fixture.detectChanges(); + } + + async function setup( + options: { + children?: unknown[]; + enabled?: (name: string) => boolean; + getSchedule?: () => Promise; + } = {}, + ) { + const guardians = { + listMyChildren: vi.fn(async () => options.children ?? [emil, ida]), + listChildGuardians: vi.fn(async () => []), + }; + const babysitters = { + listForChild: vi.fn(async () => { + throw new Error('500'); + }), + }; + const nightDuty = { + getSchedule: vi.fn( + options.getSchedule ?? + (async () => ({ childId: 'emil', today: '2026-11-09', patterns: [] })), + ), + listNights: vi.fn(async () => []), + }; + + await TestBed.configureTestingModule({ + imports: [GuardianNightDuty], + providers: [ + provideRouter([]), + { provide: GuardiansService, useValue: guardians }, + { provide: BabysittersService, useValue: babysitters }, + { provide: NightDutyService, useValue: nightDuty }, + { provide: FeaturesService, useValue: { enabled: options.enabled ?? (() => true) } }, + ], + }).compileComponents(); + + const fixture = TestBed.createComponent(GuardianNightDuty); + await settle(fixture); + return { + fixture, + compiled: fixture.nativeElement as HTMLElement, + guardians, + babysitters, + nightDuty, + }; + } + + it("loads the first child's schedule, links to pickups and babysitters, and survives a babysitter failure", async () => { + const { compiled, nightDuty, guardians } = await setup(); + + expect(compiled.textContent).toContain("Who's on duty at night."); + expect(compiled.textContent).toContain('Who puts Emil to bed'); + expect(guardians.listChildGuardians).toHaveBeenCalledWith('emil'); + expect(nightDuty.getSchedule).toHaveBeenCalledWith('emil'); + expect(compiled.querySelector('app-night-week')).toBeTruthy(); + expect(compiled.querySelector('app-night-pattern-editor')).toBeTruthy(); + expect(compiled.querySelector('a[href="/guardian/pickup"]')?.textContent).toContain( + 'Pickup & drop-off', + ); + expect(compiled.querySelector('a[href="/guardian/babysitters"]')).toBeTruthy(); + }); + + it('switches child', async () => { + const { fixture, compiled, nightDuty } = await setup(); + + const select = compiled.querySelector('#night-duty-child')!; + select.value = select.options[1]!.value; + select.dispatchEvent(new Event('change')); + await settle(fixture); + + expect(nightDuty.getSchedule).toHaveBeenLastCalledWith('ida'); + expect(compiled.textContent).toContain('Who puts Ida to bed'); + }); + + it('hides the child picker and the links of turned-off features', async () => { + const { compiled, babysitters } = await setup({ + children: [emil], + enabled: (name) => name !== 'babysitters' && name !== 'pickups', + }); + + expect(compiled.querySelector('#night-duty-child')).toBeNull(); + expect(compiled.querySelector('a[href="/guardian/pickup"]')).toBeNull(); + expect(compiled.querySelector('a[href="/guardian/babysitters"]')).toBeNull(); + expect(babysitters.listForChild).not.toHaveBeenCalled(); + }); + + it('asks for a child first', async () => { + const { compiled } = await setup({ children: [] }); + + expect(compiled.textContent).toContain('Add a child first to plan their nights.'); + }); + + it('shows a load error', async () => { + const { compiled } = await setup({ + getSchedule: async () => { + throw new Error('500'); + }, + }); + + expect(compiled.textContent).toContain('Unable to load night duty.'); + }); +}); diff --git a/src/frontend/buddy/src/app/features/guardian/night-duty/night-duty.ts b/src/frontend/buddy/src/app/features/guardian/night-duty/night-duty.ts new file mode 100644 index 00000000..21e4e8e0 --- /dev/null +++ b/src/frontend/buddy/src/app/features/guardian/night-duty/night-duty.ts @@ -0,0 +1,72 @@ +import { Component, computed, inject, linkedSignal, resource, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { RouterLink } from '@angular/router'; + +import { BabysittersService, ChildBabysitter } from '../../../core/babysitters.service'; +import { FeaturesService } from '../../../core/features.service'; +import { ChildSummary, GuardiansService } from '../../../core/guardians.service'; +import { TranslatePipe } from '../../../core/i18n/translate.pipe'; +import { NightDutyService } from '../../../core/night-duty.service'; +import { LoadingSpinner } from '../../../shared/loading-spinner/loading-spinner'; +import { Page } from '../../../shared/page/page'; +import { NightPatternEditor } from './night-pattern-editor/night-pattern-editor'; +import { NightPeople } from './night-people'; +import { NightWeek } from './night-week/night-week'; + +// Who is on duty at night for each child (docs/backend/analysis/night-duty.md): this week's +// resolved nights, where single nights are changed or switched, and the fixed schedule they come +// from. Saving the schedule bumps `revision`, so the week reloads against the new version. +@Component({ + selector: 'app-guardian-night-duty', + imports: [ + FormsModule, + RouterLink, + TranslatePipe, + LoadingSpinner, + Page, + NightWeek, + NightPatternEditor, + ], + templateUrl: './night-duty.html', +}) +export class GuardianNightDuty { + protected readonly features = inject(FeaturesService); + private readonly guardians = inject(GuardiansService); + private readonly babysitters = inject(BabysittersService); + private readonly nightDuty = inject(NightDutyService); + + protected readonly children = resource({ loader: () => this.guardians.listMyChildren() }); + protected readonly childList = computed((): ChildSummary[] => + this.children.hasValue() ? this.children.value() : [], + ); + // The first child until the guardian picks another. + protected readonly selectedChildId = linkedSignal(() => this.childList()[0]?.id); + protected readonly selectedChild = computed(() => + this.childList().find((child) => child.id === this.selectedChildId()), + ); + + protected readonly revision = signal(0); + + // The child's guardians and their babysitters, and the fixed schedule, for the selected child. + protected readonly data = resource({ + params: () => this.selectedChildId(), + loader: async ({ params: childId }) => { + const [guardians, babysitters, schedule] = await Promise.all([ + this.guardians.listChildGuardians(childId), + // Best effort: without babysitters only the babysitter choices are missing. With the + // feature off the route isn't mapped, so there is nothing to ask for. + this.features.enabled('babysitters') + ? this.babysitters.listForChild(childId).catch((): ChildBabysitter[] => []) + : Promise.resolve([]), + this.nightDuty.getSchedule(childId), + ]); + const people: NightPeople = { guardians, babysitters }; + return { people, schedule }; + }, + }); + + protected scheduleSaved(): void { + this.data.reload(); + this.revision.update((value) => value + 1); + } +} diff --git a/src/frontend/buddy/src/app/features/guardian/night-duty/night-edit-dialog/night-edit-dialog.html b/src/frontend/buddy/src/app/features/guardian/night-duty/night-edit-dialog/night-edit-dialog.html new file mode 100644 index 00000000..b38b4745 --- /dev/null +++ b/src/frontend/buddy/src/app/features/guardian/night-duty/night-edit-dialog/night-edit-dialog.html @@ -0,0 +1,154 @@ +
+ + + + +
diff --git a/src/frontend/buddy/src/app/features/guardian/night-duty/night-edit-dialog/night-edit-dialog.spec.ts b/src/frontend/buddy/src/app/features/guardian/night-duty/night-edit-dialog/night-edit-dialog.spec.ts new file mode 100644 index 00000000..ce61481b --- /dev/null +++ b/src/frontend/buddy/src/app/features/guardian/night-duty/night-edit-dialog/night-edit-dialog.spec.ts @@ -0,0 +1,209 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { describe, expect, it, vi } from 'vitest'; + +import { Night, NightEntry } from '../../../../core/night-duty.service'; +import { NightPeople } from '../night-people'; +import { NightEditDialog } from './night-edit-dialog'; + +describe('NightEditDialog', () => { + const people: NightPeople = { + guardians: [ + { + id: 'mom', + name: { givenName: 'Sara', familyName: 'Holm' }, + callingName: 'Mom', + kind: 'Parent', + }, + { + id: 'dad', + name: { givenName: 'Jonas', familyName: 'Holm' }, + callingName: 'Dad', + kind: 'Parent', + }, + ] as NightPeople['guardians'], + babysitters: [{ guardianId: 'mom', id: 'maja', name: 'Maja', contactInfo: '' }], + }; + + const dad: NightEntry = { responsible: { kind: 0, guardianId: 'dad' }, note: 'Screen night' }; + + async function settle(fixture: ComponentFixture): Promise { + for (let i = 0; i < 3; i++) { + fixture.detectChanges(); + await new Promise((resolve) => setTimeout(resolve, 0)); + } + fixture.detectChanges(); + } + + function choose(select: HTMLSelectElement, value: string): void { + select.value = value; + select.dispatchEvent(new Event('change')); + } + + function type(input: HTMLInputElement, value: string): void { + input.value = value; + input.dispatchEvent(new Event('input')); + } + + function button(root: HTMLElement, text: string): HTMLButtonElement | undefined { + return Array.from(root.querySelectorAll('button')).find((b) => b.textContent?.trim() === text); + } + + async function setup(night: Night, inputs: { busy?: boolean; error?: string | null } = {}) { + await TestBed.configureTestingModule({ imports: [NightEditDialog] }).compileComponents(); + const fixture = TestBed.createComponent(NightEditDialog); + fixture.componentRef.setInput('night', night); + fixture.componentRef.setInput('dayLabel', 'Fri, Nov 13'); + fixture.componentRef.setInput('people', people); + fixture.componentRef.setInput('busy', inputs.busy ?? false); + fixture.componentRef.setInput('error', inputs.error ?? null); + const save = vi.fn(); + const backToSchedule = vi.fn(); + const closed = vi.fn(); + fixture.componentInstance.save.subscribe(save); + fixture.componentInstance.backToSchedule.subscribe(backToSchedule); + fixture.componentInstance.closed.subscribe(closed); + await settle(fixture); + const compiled = fixture.nativeElement as HTMLElement; + return { fixture, compiled, save, backToSchedule, closed }; + } + + const fromSchedule: Night = { + date: '2026-11-13', + status: { kind: 2, entry: dad, source: 'Pattern', scheduled: null }, + }; + + it('starts from the night and names what the schedule says', async () => { + const { compiled } = await setup(fromSchedule); + + expect(compiled.querySelector('[role="dialog"]')?.getAttribute('aria-modal')).toBe('true'); + expect(compiled.textContent).toContain('Fri, Nov 13 night'); + expect(compiled.textContent).toContain('Fixed schedule: Dad · Screen night.'); + expect(compiled.querySelector('#night-edit-who')?.value).toBe('g:dad'); + expect(compiled.querySelector('#night-edit-note')?.value).toBe( + 'Screen night', + ); + // Not overridden, so there is nothing to go back to. + expect(button(compiled, 'Back to schedule')).toBeUndefined(); + }); + + it('keeps the schedule note when only the person changes', async () => { + const { fixture, compiled, save } = await setup(fromSchedule); + + choose(compiled.querySelector('#night-edit-who')!, 'g:mom'); + await settle(fixture); + button(compiled, 'Save night')!.click(); + + expect(save).toHaveBeenCalledWith({ + responsible: { kind: 0, guardianId: 'mom' }, + note: 'Screen night', + }); + }); + + it('prefills an unplanned night with the schedule note and saves a sleepover', async () => { + const { fixture, compiled, save } = await setup({ + date: '2026-11-14', + status: { kind: 0 }, + }); + + expect(compiled.textContent).toContain('The fixed schedule has nothing for this night.'); + choose(compiled.querySelector('#night-edit-who')!, 's'); + await settle(fixture); + type(compiled.querySelector('#night-edit-host')!, ' Grandma Lis '); + type(compiled.querySelector('#night-edit-location')!, 'Skovvej 12'); + await settle(fixture); + button(compiled, 'Save night')!.click(); + + expect(save).toHaveBeenCalledWith({ + responsible: { + kind: 2, + hostName: 'Grandma Lis', + location: 'Skovvej 12', + contactInfo: '', + }, + note: '', + }); + }); + + it('keeps a stored sleepover contact it does not show', async () => { + const { compiled, save } = await setup({ + date: '2026-11-14', + status: { + kind: 2, + entry: { + responsible: { kind: 2, hostName: 'Lis', location: 'Skovvej', contactInfo: '20 12' }, + note: '', + }, + source: 'Override', + scheduled: null, + }, + }); + + button(compiled, 'Save night')!.click(); + + expect(save).toHaveBeenCalledWith({ + responsible: { kind: 2, hostName: 'Lis', location: 'Skovvej', contactInfo: '20 12' }, + note: '', + }); + }); + + it('asks for a host, a person or a note before saving', async () => { + const { fixture, compiled, save } = await setup({ date: '2026-11-14', status: { kind: 0 } }); + + choose(compiled.querySelector('#night-edit-who')!, ''); + await settle(fixture); + button(compiled, 'Save night')!.click(); + await settle(fixture); + + expect(save).not.toHaveBeenCalled(); + expect(compiled.textContent).toContain('Choose who is on duty, write a note, or both.'); + }); + + it('saves "nothing planned" as a cleared night and hides the note', async () => { + const { fixture, compiled, save } = await setup(fromSchedule); + + choose(compiled.querySelector('#night-edit-who')!, 'cleared'); + await settle(fixture); + + expect(compiled.querySelector('#night-edit-note')).toBeNull(); + button(compiled, 'Save night')!.click(); + expect(save).toHaveBeenCalledWith(null); + }); + + it('starts a cleared night on "nothing planned" and offers going back to the schedule', async () => { + const { compiled, backToSchedule } = await setup({ + date: '2026-11-13', + status: { kind: 1, scheduled: dad }, + }); + + expect(compiled.querySelector('#night-edit-who')?.value).toBe('cleared'); + button(compiled, 'Back to schedule')!.click(); + expect(backToSchedule).toHaveBeenCalledOnce(); + }); + + it('offers babysitters and closes on Cancel or Escape', async () => { + const { compiled, closed } = await setup(fromSchedule); + + const options = Array.from( + compiled.querySelectorAll('#night-edit-who option'), + ).map((o) => o.textContent?.trim()); + expect(options).toContain('🧑‍🍼 Maja'); + + button(compiled, 'Cancel')!.click(); + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + expect(closed).toHaveBeenCalledTimes(2); + }); + + it('shows the parent error and blocks while busy', async () => { + const { compiled, save, closed } = await setup(fromSchedule, { + busy: true, + error: 'nightDuty.week.saveError', + }); + + expect(compiled.textContent).toContain('Unable to save the night.'); + expect(button(compiled, 'Save night')!.disabled).toBe(true); + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + (compiled.querySelector('form') as HTMLFormElement).dispatchEvent(new Event('submit')); + expect(save).not.toHaveBeenCalled(); + expect(closed).not.toHaveBeenCalled(); + }); +}); diff --git a/src/frontend/buddy/src/app/features/guardian/night-duty/night-edit-dialog/night-edit-dialog.ts b/src/frontend/buddy/src/app/features/guardian/night-duty/night-edit-dialog/night-edit-dialog.ts new file mode 100644 index 00000000..4880725a --- /dev/null +++ b/src/frontend/buddy/src/app/features/guardian/night-duty/night-edit-dialog/night-edit-dialog.ts @@ -0,0 +1,169 @@ +import { + Component, + ElementRef, + HostListener, + Injector, + OnInit, + afterNextRender, + computed, + inject, + input, + output, + signal, + viewChild, +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; + +import { TranslatePipe } from '../../../../core/i18n/translate.pipe'; +import { TranslationService } from '../../../../core/i18n/translation.service'; +import { Night, NightEntry, nightEntry, scheduledEntry } from '../../../../core/night-duty.service'; +import { + NOBODY_CHOICE, + NightPeople, + SLEEPOVER_CHOICE, + babysitterChoice, + choiceOf, + isCompleteEntry, + responsibleFromChoice, + responsibleText, + sleepoverOf, +} from '../night-people'; +import { keepTabInside } from '../../../../shared/focus-trap/focus-trap'; + +// The + + + +
+ @for (week of weeks(); track week) { +
+

+ {{ weekLabel(week) }} +

+
    + @for (day of weekdays; track day; let i = $index) { + @let draft = cell(week, day); +
  • + {{ + dayNames()[i] + }} + + @if (draft.choice === sleepoverChoice) { + + } + +
  • + } +
+
+ } +
+ +
+ + @if (justSaved()) { +

+ {{ 'nightDuty.pattern.saved' | translate }} +

+ } @else if (saving.state(); as state) { + @if (state.status === 'error') { + + } @else { +

+ {{ 'nightDuty.pattern.replaceHint' | translate: { date: effectiveFromLabel() } }} +

+ } + } +
+ +
+

{{ 'nightDuty.pattern.versionsTitle' | translate }}

+ @if (versions().length === 0) { +

+ {{ 'nightDuty.pattern.noVersions' | translate }} +

+ } @else { +
    + @for (version of versions(); track version.key) { +
  • + {{ + 'nightDuty.pattern.version' | translate: { date: version.date, cycle: version.cycle } + }} + ({{ 'nightDuty.pattern.status.' + version.status | translate }}) +
  • + } +
+ } +
+ diff --git a/src/frontend/buddy/src/app/features/guardian/night-duty/night-pattern-editor/night-pattern-editor.spec.ts b/src/frontend/buddy/src/app/features/guardian/night-duty/night-pattern-editor/night-pattern-editor.spec.ts new file mode 100644 index 00000000..94757e90 --- /dev/null +++ b/src/frontend/buddy/src/app/features/guardian/night-duty/night-pattern-editor/night-pattern-editor.spec.ts @@ -0,0 +1,302 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { describe, expect, it, vi } from 'vitest'; + +import { + NightDutyService, + NightPattern, + NightSchedule, + ReplaceNightPatternRequest, +} from '../../../../core/night-duty.service'; +import { NightPeople } from '../night-people'; +import { NightPatternEditor } from './night-pattern-editor'; + +describe('NightPatternEditor', () => { + // Thursday 2026-11-12, in the week that starts Monday 2026-11-09. + const today = '2026-11-12'; + + const people: NightPeople = { + guardians: [ + { + id: 'mom', + name: { givenName: 'Sara', familyName: 'Holm' }, + callingName: 'Mom', + kind: 'Parent', + }, + { + id: 'dad', + name: { givenName: 'Jonas', familyName: 'Holm' }, + callingName: '', + kind: 'Parent', + }, + ] as NightPeople['guardians'], + babysitters: [{ guardianId: 'mom', id: 'maja', name: 'Maja', contactInfo: '' }], + }; + + function version(overrides: Partial = {}): NightPattern { + return { + effectiveFrom: '2026-08-03', + cycleWeeks: 1, + anchorMonday: '2026-08-03', + days: [], + status: 'Current', + ...overrides, + }; + } + + function schedule(patterns: NightPattern[] = []): NightSchedule { + return { childId: 'emil', today, patterns }; + } + + async function settle(fixture: ComponentFixture): Promise { + for (let i = 0; i < 3; i++) { + fixture.detectChanges(); + await new Promise((resolve) => setTimeout(resolve, 0)); + } + fixture.detectChanges(); + } + + function byLabel(root: HTMLElement, label: string): T { + const element = root.querySelector(`[aria-label="${label}"]`); + expect(element, label).toBeTruthy(); + return element!; + } + + function choose(select: HTMLSelectElement, value: string): void { + select.value = value; + select.dispatchEvent(new Event('change')); + } + + function type(input: HTMLInputElement, value: string): void { + input.value = value; + input.dispatchEvent(new Event('input')); + } + + function radio(root: HTMLElement, label: string): HTMLButtonElement { + const button = Array.from(root.querySelectorAll('[role="radio"]')).find( + (b) => b.textContent?.trim() === label, + ); + expect(button, label).toBeTruthy(); + return button!; + } + + function saveButton(root: HTMLElement): HTMLButtonElement { + return Array.from(root.querySelectorAll('button')).find( + (b) => b.textContent?.trim() === 'Save schedule', + )!; + } + + async function setup( + initial: NightSchedule, + replacePattern = vi.fn< + (childId: string, request: ReplaceNightPatternRequest) => Promise + >(async () => schedule()), + ) { + await TestBed.configureTestingModule({ + imports: [NightPatternEditor], + providers: [{ provide: NightDutyService, useValue: { replacePattern } }], + }).compileComponents(); + + const fixture = TestBed.createComponent(NightPatternEditor); + fixture.componentRef.setInput('childId', 'emil'); + fixture.componentRef.setInput('schedule', initial); + fixture.componentRef.setInput('people', people); + const saved = vi.fn(); + fixture.componentInstance.saved.subscribe(saved); + await settle(fixture); + + return { fixture, replacePattern, saved, compiled: fixture.nativeElement as HTMLElement }; + } + + it('starts from the current version and lists every version', async () => { + const { compiled } = await setup( + schedule([ + version({ + days: [ + { + week: 0, + day: 'Friday', + entry: { responsible: { kind: 0, guardianId: 'dad' }, note: 'Screen night' }, + }, + ], + }), + ]), + ); + + expect(byLabel(compiled, 'Week A, Friday: who').value).toBe('g:dad'); + expect(byLabel(compiled, 'Week A, Friday: note').value).toBe('Screen night'); + expect(byLabel(compiled, 'Week A, Monday: who').value).toBe(''); + expect(compiled.textContent).toContain('From Aug 3, 2026: 1 week (current)'); + expect(compiled.querySelector('#night-pattern-effective-from')?.value).toBe( + today, + ); + }); + + it('says when there is no schedule yet', async () => { + const { compiled } = await setup(schedule()); + + expect(compiled.textContent).toContain('No fixed schedule yet.'); + }); + + it('prefers the upcoming version and keeps its date', async () => { + const { compiled } = await setup( + schedule([ + version(), + version({ effectiveFrom: '2026-11-16', anchorMonday: '2026-11-16', status: 'Upcoming' }), + ]), + ); + + expect(compiled.querySelector('#night-pattern-effective-from')?.value).toBe( + '2026-11-16', + ); + expect(compiled.textContent).toContain('(upcoming)'); + }); + + it('saves a two-week schedule with notes, a babysitter and a sleepover', async () => { + const { fixture, compiled, replacePattern, saved } = await setup(schedule()); + + radio(compiled, '2 weeks').click(); + await settle(fixture); + radio(compiled, 'Week B').click(); + await settle(fixture); + choose(byLabel(compiled, 'Week A, Monday: who'), 'g:mom'); + choose(byLabel(compiled, 'Week A, Tuesday: who'), 'b:mom:maja'); + type(byLabel(compiled, 'Week A, Friday: note'), ' Screen night 📱 '); + choose(byLabel(compiled, 'Week B, Saturday: who'), 's'); + await settle(fixture); + type(byLabel(compiled, 'Week B, Saturday: host'), 'Grandma Lis'); + await settle(fixture); + saveButton(compiled).click(); + await settle(fixture); + + expect(replacePattern).toHaveBeenCalledWith('emil', { + effectiveFrom: today, + cycleWeeks: 2, + // This week (from 2026-11-09) is week B, so week A started the Monday before. + anchorMonday: '2026-11-02', + days: [ + { + week: 0, + day: 'Monday', + entry: { responsible: { kind: 0, guardianId: 'mom' }, note: '' }, + }, + { + week: 0, + day: 'Tuesday', + entry: { responsible: { kind: 1, guardianId: 'mom', babysitterId: 'maja' }, note: '' }, + }, + { week: 0, day: 'Friday', entry: { responsible: { kind: 3 }, note: 'Screen night 📱' } }, + { + week: 1, + day: 'Saturday', + entry: { + responsible: { kind: 2, hostName: 'Grandma Lis', location: '', contactInfo: '' }, + note: '', + }, + }, + ], + }); + expect(saved).toHaveBeenCalledOnce(); + expect(compiled.textContent).toContain('Schedule saved.'); + }); + + it('keeps the stored anchor when it already makes this week the chosen week', async () => { + const { fixture, compiled, replacePattern } = await setup( + schedule([version({ cycleWeeks: 2, anchorMonday: '2026-08-03' })]), + ); + + // 2026-08-03 + 14 weeks = 2026-11-09, so this week is week A. + expect(radio(compiled, 'Week A').getAttribute('aria-checked')).toBe('true'); + saveButton(compiled).click(); + await settle(fixture); + + expect(replacePattern).toHaveBeenCalledWith( + 'emil', + expect.objectContaining({ cycleWeeks: 2, anchorMonday: '2026-08-03' }), + ); + }); + + it('never applies a schedule from before today', async () => { + const { fixture, compiled, replacePattern } = await setup(schedule()); + const input = compiled.querySelector('#night-pattern-effective-from')!; + + type(input, '2026-11-01'); + await settle(fixture); + saveButton(compiled).click(); + await settle(fixture); + + expect(input.min).toBe(today); + expect(replacePattern).toHaveBeenCalledWith( + 'emil', + expect.objectContaining({ effectiveFrom: today }), + ); + }); + + it('applies from a later date the guardian picks and says what it replaces', async () => { + const { fixture, compiled, replacePattern } = await setup(schedule()); + + type(compiled.querySelector('#night-pattern-effective-from')!, '2026-11-30'); + await settle(fixture); + + expect(compiled.textContent).toContain('Saved schedules that start on or after Nov 30, 2026'); + saveButton(compiled).click(); + await settle(fixture); + + expect(replacePattern).toHaveBeenCalledWith( + 'emil', + expect.objectContaining({ effectiveFrom: '2026-11-30' }), + ); + }); + + it('keeps a sleepover’s stored location and contact when saving an unrelated change', async () => { + const sleepover = { + responsible: { + kind: 2 as const, + hostName: 'Grandma Lis', + location: 'Skovvej 12', + contactInfo: '20 12 34 56', + }, + note: '', + }; + const { fixture, compiled, replacePattern } = await setup( + schedule([version({ days: [{ week: 0, day: 'Saturday', entry: sleepover }] })]), + ); + + type(byLabel(compiled, 'Week A, Monday: note'), 'Movie'); + await settle(fixture); + saveButton(compiled).click(); + await settle(fixture); + + expect(replacePattern.mock.calls[0]![1].days).toContainEqual({ + week: 0, + day: 'Saturday', + entry: sleepover, + }); + }); + + it('refuses a sleepover without a host', async () => { + const { fixture, compiled, replacePattern } = await setup(schedule()); + + choose(byLabel(compiled, 'Week A, Monday: who'), 's'); + await settle(fixture); + saveButton(compiled).click(); + await settle(fixture); + + expect(replacePattern).not.toHaveBeenCalled(); + expect(compiled.textContent).toContain('Every sleepover needs a host.'); + }); + + it('shows the save error', async () => { + const { fixture, compiled, saved } = await setup( + schedule(), + vi.fn(async () => { + throw new Error('400'); + }), + ); + + saveButton(compiled).click(); + await settle(fixture); + + expect(compiled.textContent).toContain('Unable to save the schedule.'); + expect(saved).not.toHaveBeenCalled(); + }); +}); diff --git a/src/frontend/buddy/src/app/features/guardian/night-duty/night-pattern-editor/night-pattern-editor.ts b/src/frontend/buddy/src/app/features/guardian/night-duty/night-pattern-editor/night-pattern-editor.ts new file mode 100644 index 00000000..2edda863 --- /dev/null +++ b/src/frontend/buddy/src/app/features/guardian/night-duty/night-pattern-editor/night-pattern-editor.ts @@ -0,0 +1,268 @@ +import { Component, computed, inject, input, linkedSignal, output, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; + +import { parseIsoDate } from '../../../../core/date-utils'; +import { TranslatePipe } from '../../../../core/i18n/translate.pipe'; +import { TranslationService } from '../../../../core/i18n/translation.service'; +import { + NightDutyService, + NightPattern, + NightPatternDay, + NightSchedule, +} from '../../../../core/night-duty.service'; +import { createAction } from '../../../../shared/action-state/action-state'; +import { Card } from '../../../../shared/card/card'; +import { + SegmentedControl, + SegmentedControlOption, +} from '../../../../shared/segmented-control/segmented-control'; +import { + MAX_CYCLE_WEEKS, + WEEKDAYS_MONDAY_FIRST, + anchorForCurrentWeek, + cycleWeekOf, + patternKey, + weekName, + weekdayName, +} from '../../work-locations/work-pattern'; +import { + NOBODY_CHOICE, + NightPeople, + SLEEPOVER_CHOICE, + babysitterChoice, + choiceOf, + isCompleteEntry, + sleepoverOf, + responsibleFromChoice, +} from '../night-people'; +import { Weekday } from '../../../../core/work-locations.service'; + +// location and contactInfo aren't edited here but are carried through, so a save keeps them. +interface CellDraft { + choice: string; + hostName: string; + location: string; + contactInfo: string; + note: string; +} + +const EMPTY_CELL: CellDraft = { + choice: NOBODY_CHOICE, + hostName: '', + location: '', + contactInfo: '', + note: '', +}; + +// The version the editor starts from: the upcoming one if there is one (the guardian is still +// preparing it), else the current one, else an empty one-week schedule. +function editedVersion(schedule: NightSchedule): NightPattern | undefined { + return ( + schedule.patterns.find((p) => p.status === 'Upcoming') ?? + schedule.patterns.find((p) => p.status === 'Current') + ); +} + +// Draft of one fixed-schedule version: cycle length, "which week is this week", the date it applies +// from (never before the child's today) and a week x weekday grid of who and a note. Nothing is +// sent until Save. Earlier nights keep the version they had (docs/backend/analysis/night-duty.md, +// Question 4). +@Component({ + selector: 'app-night-pattern-editor', + imports: [FormsModule, TranslatePipe, SegmentedControl, Card], + templateUrl: './night-pattern-editor.html', +}) +export class NightPatternEditor { + private readonly nightDuty = inject(NightDutyService); + private readonly translation = inject(TranslationService); + + readonly childId = input.required(); + readonly schedule = input.required(); + readonly people = input.required(); + readonly saved = output(); + + protected readonly weekdays = WEEKDAYS_MONDAY_FIRST; + protected readonly sleepoverChoice = SLEEPOVER_CHOICE; + protected readonly nobody = NOBODY_CHOICE; + protected readonly babysitterChoice = babysitterChoice; + + private readonly today = computed(() => this.schedule().today); + private readonly base = computed(() => editedVersion(this.schedule())); + + protected readonly cycleWeeks = linkedSignal(() => this.base()?.cycleWeeks ?? 1); + protected readonly currentWeek = linkedSignal(() => { + const base = this.base(); + return base ? cycleWeekOf(base.anchorMonday, base.cycleWeeks, this.today()) : 0; + }); + protected readonly effectiveFrom = linkedSignal(() => { + const base = this.base(); + return base?.status === 'Upcoming' ? base.effectiveFrom : this.today(); + }); + + protected readonly cells = linkedSignal(() => { + const cells: Record = {}; + for (const day of this.base()?.days ?? []) { + const responsible = day.entry.responsible; + cells[patternKey(day.week, day.day)] = { + choice: choiceOf(responsible), + ...sleepoverOf(responsible), + note: day.entry.note ?? '', + }; + } + return cells; + }); + + protected readonly saving = createAction(); + protected readonly justSaved = signal(false); + + protected readonly weeks = computed(() => + Array.from({ length: this.cycleWeeks() }, (_, week) => week), + ); + + protected readonly cycleOptions = computed((): SegmentedControlOption[] => { + this.translation.language(); + return Array.from({ length: MAX_CYCLE_WEEKS }, (_, i) => i + 1).map((count) => ({ + value: count, + label: + count === 1 + ? this.translation.translate('nightDuty.pattern.cycleOne') + : this.translation.translate('nightDuty.pattern.cycleMany', { count }), + })); + }); + + protected readonly currentWeekOptions = computed((): SegmentedControlOption[] => + this.weeks().map((week) => ({ value: week, label: this.weekLabel(week) })), + ); + + protected readonly dayNames = computed(() => { + const locale = this.translation.language(); + return WEEKDAYS_MONDAY_FIRST.map((day) => weekdayName(day, locale, 'long')); + }); + + protected readonly versions = computed(() => { + const locale = this.translation.language(); + return this.schedule().patterns.map((pattern) => ({ + key: pattern.effectiveFrom, + date: this.formatDate(pattern.effectiveFrom, locale), + cycle: + pattern.cycleWeeks === 1 + ? this.translation.translate('nightDuty.pattern.cycleOne') + : this.translation.translate('nightDuty.pattern.cycleMany', { + count: pattern.cycleWeeks, + }), + status: pattern.status, + })); + }); + + protected readonly effectiveFromLabel = computed(() => + this.formatDate(this.effectiveFrom(), this.translation.language()), + ); + + protected weekLabel(week: number): string { + return this.translation.translate('nightDuty.pattern.weekName', { name: weekName(week) }); + } + + protected cellLabel(key: string, week: number, dayIndex: number): string { + return this.translation.translate(`nightDuty.pattern.${key}`, { + week: this.weekLabel(week), + day: this.dayNames()[dayIndex] ?? '', + }); + } + + protected cell(week: number, day: Weekday): CellDraft { + return this.cells()[patternKey(week, day)] ?? EMPTY_CELL; + } + + protected setCell(week: number, day: Weekday, change: Partial): void { + const key = patternKey(week, day); + this.cells.update((cells) => ({ + ...cells, + [key]: { ...(cells[key] ?? EMPTY_CELL), ...change }, + })); + this.edited(); + } + + protected setCycleWeeks(count: number): void { + this.cycleWeeks.set(count); + this.currentWeek.update((week) => Math.min(week, count - 1)); + this.edited(); + } + + protected setCurrentWeek(week: number): void { + this.currentWeek.set(week); + this.edited(); + } + + protected setEffectiveFrom(date: string): void { + // A cleared or earlier date falls back to today: the past is never rewritten. + this.effectiveFrom.set(date && date >= this.today() ? date : this.today()); + this.edited(); + } + + protected minDate(): string { + return this.today(); + } + + private edited(): void { + this.justSaved.set(false); + this.saving.clearError(); + } + + protected async save(): Promise { + const cycleWeeks = this.cycleWeeks(); + const days: NightPatternDay[] = []; + + for (let week = 0; week < cycleWeeks; week++) { + for (const day of WEEKDAYS_MONDAY_FIRST) { + const cell = this.cell(week, day); + const entry = { + responsible: responsibleFromChoice(cell.choice, cell), + note: cell.note.trim(), + }; + if (cell.choice === NOBODY_CHOICE && !entry.note) { + continue; + } + if (!isCompleteEntry(entry)) { + this.saving.fail(true, 'nightDuty.pattern.incomplete'); + return; + } + days.push({ week, day, entry }); + } + } + + // Keep the edited version's anchor when it already makes this week the chosen cycle week, so + // saving an unchanged version doesn't rewrite it to an equivalent Monday. + const base = this.base(); + const anchorMonday = + base?.cycleWeeks === cycleWeeks && + cycleWeekOf(base.anchorMonday, cycleWeeks, this.today()) === this.currentWeek() + ? base.anchorMonday + : anchorForCurrentWeek(this.today(), this.currentWeek()); + + this.justSaved.set(false); + const childId = this.childId(); + + await this.saving.run( + true, + async () => { + await this.nightDuty.replacePattern(childId, { + effectiveFrom: this.effectiveFrom(), + cycleWeeks, + anchorMonday, + days, + }); + this.justSaved.set(true); + this.saved.emit(); + }, + 'nightDuty.pattern.saveError', + ); + } + + private formatDate(isoDate: string, locale: string): string { + return parseIsoDate(isoDate).toLocaleDateString(locale, { + month: 'short', + day: 'numeric', + year: 'numeric', + }); + } +} diff --git a/src/frontend/buddy/src/app/features/guardian/night-duty/night-people.spec.ts b/src/frontend/buddy/src/app/features/guardian/night-duty/night-people.spec.ts new file mode 100644 index 00000000..fe9c8299 --- /dev/null +++ b/src/frontend/buddy/src/app/features/guardian/night-duty/night-people.spec.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest'; + +import { + NOBODY_CHOICE, + NightPeople, + SLEEPOVER_CHOICE, + babysitterChoice, + choiceOf, + isCompleteEntry, + responsibleFromChoice, + responsibleText, + sleepoverOf, +} from './night-people'; + +describe('night-people', () => { + const people: NightPeople = { + guardians: [ + { + id: 'mom', + name: { givenName: 'Sara', familyName: 'Holm' }, + callingName: 'Mom', + kind: 'Parent', + }, + { + id: 'dad', + name: { givenName: 'Jonas', familyName: 'Holm' }, + callingName: '', + kind: 'Parent', + }, + ] as NightPeople['guardians'], + babysitters: [{ guardianId: 'mom', id: 'maja', name: 'Maja', contactInfo: '' }], + }; + const labels = { + guardian: 'A guardian', + babysitter: 'Babysitter', + sleepover: (host: string) => `Sleepover: ${host}`, + }; + const noSleepover = { hostName: '', location: '', contactInfo: '' }; + + it('round-trips every responsible person through a select value', () => { + expect(choiceOf({ kind: 0, guardianId: 'mom' })).toBe('g:mom'); + expect(choiceOf({ kind: 1, guardianId: 'mom', babysitterId: 'maja' })).toBe('b:mom:maja'); + expect(choiceOf({ kind: 2, hostName: 'Lis' })).toBe(SLEEPOVER_CHOICE); + expect(choiceOf({ kind: 3 })).toBe(NOBODY_CHOICE); + expect(babysitterChoice(people.babysitters[0]!)).toBe('b:mom:maja'); + + expect(responsibleFromChoice('g:mom', noSleepover)).toEqual({ kind: 0, guardianId: 'mom' }); + expect(responsibleFromChoice('b:mom:maja', noSleepover)).toEqual({ + kind: 1, + guardianId: 'mom', + babysitterId: 'maja', + }); + expect( + responsibleFromChoice('s', { hostName: ' Lis ', location: ' Skovvej ', contactInfo: ' 20 ' }), + ).toEqual({ kind: 2, hostName: 'Lis', location: 'Skovvej', contactInfo: '20' }); + expect(responsibleFromChoice('', noSleepover)).toEqual({ kind: 3 }); + }); + + it('reads a sleepover draft back', () => { + expect( + sleepoverOf({ kind: 2, hostName: 'Lis', location: 'Skovvej', contactInfo: '20' }), + ).toEqual({ hostName: 'Lis', location: 'Skovvej', contactInfo: '20' }); + expect(sleepoverOf({ kind: 2, hostName: 'Lis' })).toEqual({ + hostName: 'Lis', + location: '', + contactInfo: '', + }); + expect(sleepoverOf({ kind: 3 })).toEqual(noSleepover); + expect(sleepoverOf(null)).toEqual(noSleepover); + }); + + it('accepts a person, a note or both, and a sleepover only with a host', () => { + expect(isCompleteEntry({ responsible: { kind: 0, guardianId: 'mom' }, note: '' })).toBe(true); + expect(isCompleteEntry({ responsible: { kind: 3 }, note: ' Screen ' })).toBe(true); + expect(isCompleteEntry({ responsible: { kind: 3 }, note: ' ' })).toBe(false); + expect(isCompleteEntry({ responsible: { kind: 3 } })).toBe(false); + expect(isCompleteEntry({ responsible: { kind: 2, hostName: ' ' }, note: 'x' })).toBe(false); + expect(isCompleteEntry({ responsible: { kind: 2, hostName: 'Lis' } })).toBe(true); + }); + + it('names who is on duty', () => { + expect(responsibleText({ kind: 0, guardianId: 'mom' }, people, labels)).toEqual({ + icon: '👤', + text: 'Mom', + }); + expect(responsibleText({ kind: 0, guardianId: 'dad' }, people, labels)?.text).toBe('Jonas'); + expect(responsibleText({ kind: 0, guardianId: 'gone' }, people, labels)?.text).toBe( + 'A guardian', + ); + expect( + responsibleText( + { kind: 1, guardianId: 'mom', babysitterId: 'm', name: 'Maja' }, + people, + labels, + ), + ).toEqual({ icon: '🧑‍🍼', text: 'Maja' }); + expect( + responsibleText({ kind: 1, guardianId: 'mom', babysitterId: 'm', name: '' }, people, labels) + ?.text, + ).toBe('Babysitter'); + expect(responsibleText({ kind: 2, hostName: 'Lis' }, people, labels)).toEqual({ + icon: '🛏️', + text: 'Sleepover: Lis', + }); + expect(responsibleText({ kind: 3 }, people, labels)).toBeNull(); + }); +}); diff --git a/src/frontend/buddy/src/app/features/guardian/night-duty/night-people.ts b/src/frontend/buddy/src/app/features/guardian/night-duty/night-people.ts new file mode 100644 index 00000000..0b6cd52a --- /dev/null +++ b/src/frontend/buddy/src/app/features/guardian/night-duty/night-people.ts @@ -0,0 +1,117 @@ +import { ChildBabysitter } from '../../../core/babysitters.service'; +import { GuardianSummary, childFacingName } from '../../../core/guardians.service'; +import { NIGHT_RESPONSIBLE, NightEntry, NightResponsible } from '../../../core/night-duty.service'; + +// Who can be on duty for the selected child: its guardians and their babysitters. +export interface NightPeople { + guardians: GuardianSummary[]; + babysitters: ChildBabysitter[]; +} + +export const NO_PEOPLE: NightPeople = { guardians: [], babysitters: [] }; + +// A responsible person travels through the + + @for (child of children(); track child.id) { + + } + + + } @case (kind.workLocation) {