Skip to main content

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

  1. start_time: Optional, defaults to server's current time if not provided.
  2. end_time is optional on create. If omitted, this is a "timer start" (active sleep). If provided, this is a complete manual entry.
  3. 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.
  4. end_time must be after start_time when provided. Returns 400 otherwise.
  5. duration_minutes is server-calculated: Math.round((end - start) / 60000). NULL when end_time is NULL.
  6. duration_minutes recalculates on update if either time changes.
  7. 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).
  8. Stopping a timer: Setting end_time on an active sleep (end_time was null) calculates and stores duration_minutes.
  9. Canceling a timer: Delete the active sleep session.
  10. Access control: Child access verified via ChildAccess table.
  11. Audit logging: AuditLog on all CUD operations.
  12. Sort order: start_time descending (most recent first).
  13. "since" filter: Filters by start_time > since value (exclusive).
  14. Day boundary for totals: Sleep counts toward the day (per user's day_start_time) that contains its start_time. Never split a session across days.
  15. 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

  1. Start timer: POST with only start_time → 201, end_time=null, duration_minutes=null
  2. Start timer defaults to now: POST with empty body → 201, start_time≈now, end_time=null
  3. Stop timer: PUT with end_time on active sleep → 200, duration calculated correctly
  4. Double start blocked: Start timer, try to start another → 409 "active sleep already exists"
  5. Cancel timer: Start timer, DELETE → 204, no sleep record remains
  6. Stop sets duration: Start at 14:00, stop at 16:15 → duration_minutes=135

Manual Entry

  1. Full manual entry: POST with both times → 201, duration calculated
  2. Retroactive entry: Both times in the past → 201 success
  3. Overnight entry: start 22:00, end 06:30 next day → duration_minutes=510

Validation

  1. end_time before start_time: → 400
  2. Equal start and end: → 400 (zero duration not allowed)
  3. 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.)
  4. Invalid ISO8601: → 400

Updates

  1. Update only end_time: start unchanged, duration recalculated
  2. Update only start_time: end unchanged, duration recalculated
  3. Update both times: both change, duration recalculated
  4. Update that would violate ordering: new start_time after existing end_time → 400

Access Control

  1. No access: POST to child without access → 403
  2. No access update: PUT on sleep for inaccessible child → 403
  3. No access delete: DELETE on sleep for inaccessible child → 403
  4. Not found: PUT/DELETE on non-existent ID → 404

List

  1. List includes active sleep: Start timer, list → includes entry with null end_time
  2. List with since filter: Only returns sessions with start_time > since (exclusive)
  3. List sorted: Multiple sessions returned newest-first
  4. Empty list: Child with no sleep sessions → { sleeps: [], count: 0 }

Edge Cases

  1. Very short sleep (5 min): → duration_minutes=5
  2. Very long sleep (14 hours): → duration_minutes=840
  3. Duration rounding: 37.5 minutes → rounds to 38
  4. Multiple completed sleeps, one active: All returned, active has null end/duration

Audit

  1. Create logged: AuditLog entry on create
  2. Update logged: AuditLog entry on update (including timer stop)
  3. 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