Feature: Sleep Log
Version: 1.5.0 Last Reviewed: 2026-02-18 Status: Approved
User Story
As a caregiver, I can log when baby fell asleep and woke up so we know total sleep per day. I can start a sleep timer when baby falls asleep, then stop it when they wake up. Or I can manually log a past sleep session with both times.
MVP Scope
- Timer flow (primary): Tap "Sleep" to start timer (start_time=now, end_time=null). Tap "Wake" to stop (end_time=now, duration calculated).
- Manual flow (fallback): Enter both start and end times for retroactive logging.
- Duration auto-calculated from start/end when end_time is set.
- Edit start/end times after logging.
- Active sleep indicator on dashboard ("Sleeping since Xh Ym ago").
NOT in MVP
- Live Activity on lock screen (post-MVP, high priority)
- Sleep quality rating
- Sleep location (crib, bassinet, etc.)
- Nap vs night auto-classification
- Multiple concurrent sleep sessions (only one active timer per child)
New in v1.5.0
- Notes: optional free-text field per sleep session (max 1000 chars)
API Contract
Start Sleep Timer
POST /api/v1/children/:childId/sleeps
Authorization: Bearer <token>
Request (timer mode - end_time omitted):
{
"start_time": "2026-02-10T20:30:00.000Z"
}
Response 201:
{
"sleep": {
"id": "uuid",
"child_id": "uuid",
"start_time": "2026-02-10T20:30:00.000Z",
"end_time": null,
"duration_minutes": null,
"notes": null,
"created_by": "uuid",
"created_at": "2026-02-10T20:30:05.000Z",
"updated_by": "uuid",
"updated_at": "2026-02-10T20:30:05.000Z"
}
}
Log Complete Sleep Session (Manual)
POST /api/v1/children/:childId/sleeps
Authorization: Bearer <token>
Request (manual mode - both times provided):
{
"start_time": "2026-02-10T14:00:00.000Z",
"end_time": "2026-02-10T16:15:00.000Z",
"notes": "Napped in stroller"
}
Response 201:
{
"sleep": {
"id": "uuid",
"child_id": "uuid",
"start_time": "2026-02-10T14:00:00.000Z",
"end_time": "2026-02-10T16:15:00.000Z",
"duration_minutes": 135,
"notes": "Napped in stroller",
"created_by": "uuid",
"created_at": "...",
"updated_by": "uuid",
"updated_at": "..."
}
}
Get Sleep by ID
GET /api/v1/children/:childId/sleeps/:id
Authorization: Bearer <token>
Response 200:
{
"sleep": { ... }
}
Stop Sleep Timer (Update)
PUT /api/v1/children/:childId/sleeps/:id
Authorization: Bearer <token>
Request:
{
"end_time": "2026-02-10T22:45:00.000Z"
}
Response 200:
{
"sleep": {
"id": "uuid",
"child_id": "uuid",
"start_time": "2026-02-10T20:30:00.000Z",
"end_time": "2026-02-10T22:45:00.000Z",
"duration_minutes": 135,
"created_by": "uuid",
"created_at": "...",
"updated_by": "uuid",
"updated_at": "..."
}
}
List Sleep Sessions
GET /api/v1/children/:childId/sleeps?since=ISO8601
Authorization: Bearer <token>
Query Parameters (all optional):
- since: ISO8601 timestamp - return sleep sessions with start_time after this (exclusive)
- limit: integer - max results (default 100, max 500)
Response 200:
{
"sleeps": [
{ ...sleep with end_time and duration },
{ ...active sleep with end_time: null, duration_minutes: null }
],
"count": 2
}
Note: Active sleep sessions (end_time=null) are included in results.
Sorted by start_time descending.
Delete Sleep Session
DELETE /api/v1/children/:childId/sleeps/:id
Authorization: Bearer <token>
Response 204: (No Content)
Error Responses
400 - end_time before start_time:
{ "error": { "code": "VALIDATION_ERROR", "message": "end_time must be after start_time", "details": [{ "field": "end_time", "message": "Must be after start_time", "code": "invalid_range" }] } }
409 - active sleep already exists:
{ "error": { "code": "CONFLICT", "message": "An active sleep session already exists for this child. Stop it before starting a new one.", "details": [] } }
403 - no access:
{ "error": { "code": "FORBIDDEN", "message": "No access to this child", "details": [] } }
404 - not found:
{ "error": { "code": "NOT_FOUND", "message": "Sleep session not found", "details": [] } }
iOS UX
Sleep Sheet (opened from Dashboard sleep card or Today + button)
When no active sleep — timer-first, manual as fallback:
Sheet: "Log Sleep" [X close]
─────────────────────────────────────────────────
┌─────────────────────────────────────────────┐
│ Start Sleep │ ← BBPrimaryButton, 60pt
└─────────────────────────────────────────────┘
Or log a past sleep:
Start: [time picker, defaults to now]
End: [time picker, defaults to now]
┌─────────────────────────────────────────────┐
│ Save │ ← BBSecondaryButton
└─────────────────────────────────────────────┘
Cancel ← borderless
- "Start Sleep" taps: POST with start_time=now, end_time omitted → sheet dismisses → success banner "Started sleep" + Edit button
- "Save" (manual): POST with both times → sheet dismisses → success banner "Logged Xh Ym sleep" + Edit button
- Sheet detent:
.medium, .large— starts half, manual section may need space
When active sleep exists — wake-up focused:
Sheet: "Sleep Active" [X close]
─────────────────────────────────────────────────
moon.zzz.fill (pulsing)
Sleeping since 8:30 PM
1h 20m elapsed ← live counter
┌─────────────────────────────────────────────┐
│ Wake Up │ ← BBPrimaryButton, 60pt
└─────────────────────────────────────────────┘
Cancel Sleep ← borderless, destructive
- "Wake Up" taps: PUT with end_time=now → sheet dismisses → success banner "Ended sleep (1h 20m)" + Edit button
- "Cancel Sleep" taps: confirmation dialog ("Cancel this sleep session?") → DELETE → sheet dismisses
- Elapsed time: calculated from
active_sleep.start_time, updates every second - Pulsing moon icon: same animation as dashboard card (see ios-design-system.md)
- Sheet detent:
.medium— simple, fits in half
Dashboard Sleep Card States
- No active sleep: Shows "Xh Ym ago" (since last completed sleep's end_time). Tap → opens "Log Sleep" sheet.
- Active sleep: Shows pulsing moon icon + "Sleeping: Xh Ym" (elapsed). Tap → opens "Sleep Active" sheet.
- No sleep data at all: Shows "--". Tap → opens "Log Sleep" sheet.
Edit Sheet (from timeline tap)
Pre-populated form with start/end time pickers + Save button. Same layout as manual entry section but pre-filled.
Business Rules
- start_time: Optional, defaults to server's current time if not provided.
- end_time is optional on create. If omitted, this is a "timer start" (active sleep). If provided, this is a complete manual entry.
- Only one active sleep per child. Creating a new sleep with end_time=null when one already exists returns 409 CONFLICT. This prevents accidental double-starts. Enforced at application level (check-then-insert). PostgreSQL partial unique indexes (
UNIQUE WHERE end_time IS NULL) are possible via raw SQL in migration but not natively supported by Prisma — acceptable to rely on application logic for MVP since concurrent timer starts for the same child are extremely unlikely. - end_time must be after start_time when provided. Returns 400 otherwise.
- duration_minutes is server-calculated:
Math.round((end - start) / 60000). NULL when end_time is NULL. - duration_minutes recalculates on update if either time changes.
- Partial updates: On update, if only one time is provided, use existing value for the other. This is how "stop timer" works (just send end_time).
- Stopping a timer: Setting end_time on an active sleep (end_time was null) calculates and stores duration_minutes.
- Canceling a timer: Delete the active sleep session.
- Access control: Child access verified via ChildAccess table.
- Audit logging: AuditLog on all CUD operations.
- Sort order: start_time descending (most recent first).
- "since" filter: Filters by start_time > since value (exclusive).
- Day boundary for totals: Sleep counts toward the day (per user's
day_start_time) that contains itsstart_time. Never split a session across days. - Dashboard "since last sleep": Shows time since the most recent end_time (completed sleep). If a sleep is active, shows "Sleeping since Xh Ym".
Acceptance Criteria
- Can start a sleep timer (POST with start_time only, end_time=null)
- Can stop a sleep timer (PUT with end_time, duration auto-calculated)
- Can log a complete sleep manually (POST with both times)
- Cannot start a second timer while one is active (400 error)
- Can cancel an active timer (DELETE the active sleep)
- Duration displays correctly (e.g., "2h 15m" = 135 minutes)
- Can edit start/end times after logging
- Can delete a sleep session
- "Since last sleep" counter updates on dashboard
- Active sleep shows "Sleeping since..." on dashboard with pulsing moon icon
- Tapping sleep card during active sleep opens "Sleep Active" sheet with Wake Up button
- Tapping sleep card with no active sleep opens "Log Sleep" sheet with Start Sleep as primary
- "Start Sleep" is a single-tap quick action (dismiss + undo banner)
- "Wake Up" is a single-tap quick action (dismiss + undo banner with duration)
- "Cancel Sleep" requires confirmation dialog before DELETE
- Can log a sleep session retroactively via manual entry (both times in the past)
- Sleep spanning midnight stays as one record
- Active sleep sessions appear in list (with null end_time)
- Only authorized users can access child's sleep data
- All CUD operations create audit log entries
Test Cases
Timer Flow
- Start timer: POST with only start_time → 201, end_time=null, duration_minutes=null
- Start timer defaults to now: POST with empty body → 201, start_time≈now, end_time=null
- Stop timer: PUT with end_time on active sleep → 200, duration calculated correctly
- Double start blocked: Start timer, try to start another → 409 "active sleep already exists"
- Cancel timer: Start timer, DELETE → 204, no sleep record remains
- Stop sets duration: Start at 14:00, stop at 16:15 → duration_minutes=135
Manual Entry
- Full manual entry: POST with both times → 201, duration calculated
- Retroactive entry: Both times in the past → 201 success
- Overnight entry: start 22:00, end 06:30 next day → duration_minutes=510
Validation
- end_time before start_time: → 400
- Equal start and end: → 400 (zero duration not allowed)
- Missing start_time defaults to now: POST with only end_time → should this be an error? (Yes - end_time without start_time makes no sense in timer mode. Return 400.)
- Invalid ISO8601: → 400
Updates
- Update only end_time: start unchanged, duration recalculated
- Update only start_time: end unchanged, duration recalculated
- Update both times: both change, duration recalculated
- Update that would violate ordering: new start_time after existing end_time → 400
Access Control
- No access: POST to child without access → 403
- No access update: PUT on sleep for inaccessible child → 403
- No access delete: DELETE on sleep for inaccessible child → 403
- Not found: PUT/DELETE on non-existent ID → 404
List
- List includes active sleep: Start timer, list → includes entry with null end_time
- List with since filter: Only returns sessions with start_time > since (exclusive)
- List sorted: Multiple sessions returned newest-first
- Empty list: Child with no sleep sessions →
{ sleeps: [], count: 0 }
Edge Cases
- Very short sleep (5 min): → duration_minutes=5
- Very long sleep (14 hours): → duration_minutes=840
- Duration rounding: 37.5 minutes → rounds to 38
- Multiple completed sleeps, one active: All returned, active has null end/duration
Audit
- Create logged: AuditLog entry on create
- Update logged: AuditLog entry on update (including timer stop)
- Delete logged: AuditLog entry on delete
Boundaries
- Only one active sleep timer per child at a time
- No minimum sleep duration (but end must be after start)
- No maximum sleep duration
- No nap vs night classification in MVP
- No overlapping sleep detection in MVP (user can accidentally log overlapping sessions)
- Active sleep timer is server-side only (no client-side timer in MVP - iOS polls or calculates elapsed from start_time)
- Post-MVP: Live Activity on iOS lock screen showing active sleep timer