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):
+
+
+
+### 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):
+
+
+
+## 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):
+
+
+
+A mockup of changing one night and of switching two nights (demo data, not a screenshot of built
+code):
+
+
+
+- **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
+## Night duty
+
+Who is on duty each night: the week ahead with changed nights marked, and the fixed schedule they come from.
+
+
+
+
+
+
+
## 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
}
+
+
+
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) {
+
+