Skip to main content

Feature: Dashboard Data API

Version: 1.5.0 Last Reviewed: 2026-03-14 Status: Approved

User Story

As an iOS developer, I can fetch all dashboard data (last events, active sleep, today's counts, and suggested breast side) in a single API call so the dashboard loads instantly without multiple round trips.

MVP Scope

  • Single optimized endpoint returning all dashboard data
  • Last feeding event (timestamp, type, started_side, both_sides) - supports "since last" counter
  • Last diaper change (timestamp, type) - supports "since last" counter
  • Independent last_pee_timestamp and last_poop_timestamp fields
  • Last completed sleep session (start_time, end_time, duration_minutes) - supports "since last sleep" counter
  • Active sleep session (id, start_time) or null - supports "Sleeping since Xh Ym" display
  • Today's counts: total feedings, pee diapers, poop diapers, total sleep minutes, notes (day_start_time + timezone boundary)
  • Rolling 24h counts: same shape as today, computed over NOW()-24h to NOW() (pure UTC arithmetic, no timezone math)
  • Suggested breast side for next feeding (server-computed from last breast feeding)
  • "Today" calculated using authenticated user's day_start_time and timezone
  • PostgreSQL AT TIME ZONE for correct day boundaries
  • Performance target: <100ms ideal, <200ms acceptable
  • Child access verification via ChildAccess table
  • Read-only endpoint (GET only)

NOT in MVP

  • Historical data beyond "last", "today", and "last_24h" (use existing list endpoints)
  • Week/month aggregations (Post-MVP analytics feature)
  • Chart data or trends (Post-MVP dashboard v2)
  • Caching layer (add if performance target not met)
  • Real-time updates via WebSocket (use polling for MVP)
  • Multiple children in one request (make separate calls per child)
  • Filtering by caregiver (all caregivers' data included)

API Contract

Get Dashboard Data

GET /api/v1/children/:childId/dashboard
Authorization: Bearer <token>

Response 200:
{
"last_feeding": {
"timestamp": "2026-02-10T14:30:00.000Z",
"type": "breast",
"started_side": "left",
"both_sides": false
},
"last_diaper": {
"timestamp": "2026-02-10T13:00:00.000Z",
"type": "poop"
},
"last_pee_timestamp": "2026-02-10T12:00:00.000Z",
"last_poop_timestamp": "2026-02-10T13:00:00.000Z",
"last_sleep": {
"start_time": "2026-02-10T08:00:00.000Z",
"end_time": "2026-02-10T10:15:00.000Z",
"duration_minutes": 135
},
"active_sleep": null,
"suggested_side": "right",
"today": {
"feedings": 6,
"breast_count": 3,
"bottle_count": 2,
"pump_count": 1,
"diapers_pee": 4,
"diapers_poop": 2,
"ec_attempts": 2,
"ec_catches": 1,
"sleep_minutes": 480,
"notes": 3
},
"last_24h": {
"feedings": 8,
"breast_count": 4,
"bottle_count": 3,
"pump_count": 1,
"diapers_pee": 5,
"diapers_poop": 3,
"ec_attempts": 3,
"ec_catches": 2,
"sleep_minutes": 600,
"notes": 4
}
}

Response 200 (no data exists):
{
"last_feeding": null,
"last_diaper": null,
"last_pee_timestamp": null,
"last_poop_timestamp": null,
"last_sleep": null,
"active_sleep": null,
"suggested_side": null,
"today": {
"feedings": 0,
"breast_count": 0,
"bottle_count": 0,
"pump_count": 0,
"diapers_pee": 0,
"diapers_poop": 0,
"ec_attempts": 0,
"ec_catches": 0,
"sleep_minutes": 0,
"notes": 0
},
"last_24h": {
"feedings": 0,
"breast_count": 0,
"bottle_count": 0,
"pump_count": 0,
"diapers_pee": 0,
"diapers_poop": 0,
"ec_attempts": 0,
"ec_catches": 0,
"sleep_minutes": 0,
"notes": 0
}
}

Response 200 (baby is currently sleeping):
{
"last_feeding": {
"timestamp": "2026-02-10T19:30:00.000Z",
"type": "bottle",
"started_side": null,
"both_sides": false
},
"last_diaper": {
"timestamp": "2026-02-10T20:00:00.000Z",
"type": "pee"
},
"last_pee_timestamp": "2026-02-10T20:00:00.000Z",
"last_poop_timestamp": "2026-02-10T18:00:00.000Z",
"last_sleep": {
"start_time": "2026-02-10T08:00:00.000Z",
"end_time": "2026-02-10T10:15:00.000Z",
"duration_minutes": 135
},
"active_sleep": {
"id": "uuid",
"start_time": "2026-02-10T20:30:00.000Z"
},
"suggested_side": "left",
"today": {
"feedings": 6,
"breast_count": 3,
"bottle_count": 3,
"pump_count": 0,
"diapers_pee": 4,
"diapers_poop": 2,
"ec_attempts": 1,
"ec_catches": 0,
"sleep_minutes": 135,
"notes": 2
},
"last_24h": {
"feedings": 6,
"breast_count": 3,
"bottle_count": 3,
"pump_count": 0,
"diapers_pee": 4,
"diapers_poop": 2,
"ec_attempts": 1,
"ec_catches": 0,
"sleep_minutes": 135,
"notes": 2
}
}

Error Responses

Standard error format. Error codes: 400 VALIDATION_ERROR, 401 UNAUTHORIZED, 403 FORBIDDEN, 404 NOT_FOUND

Business Rules

"Last" Event Selection

  1. last_feeding: Most recent feeding by timestamp (any type, any caregiver).
  2. last_diaper: Most recent diaper change by timestamp (any type, any caregiver).
  3. last_sleep: Most recent COMPLETED sleep session by start_time (end_time is NOT null). Any caregiver.
  4. active_sleep: The currently in-progress sleep session (end_time IS null) for this child. Only one can exist per child (enforced by sleep spec). Null if baby is awake.
  5. "Last" events are NOT limited to today - they are the most recent overall.
  6. Returns null if no events exist for that category.

Suggested Side

  1. suggested_side: Opposite of the last breast feeding's started_side. If last breast feeding started on "left", suggested_side = "right" (and vice versa).
  2. Returns null if no breast feedings exist yet.
  3. Looks at the most recent breast feeding only (ignores bottle feedings).
  4. Used by iOS for the breastfeeding-first quick button (primary button shows suggested side).

"Today" Calculation

  1. "Today" is defined by the authenticated user's day_start_time in their timezone.
  2. Default: day starts at 07:00 in user's timezone.
  3. Example: User in "America/New_York" with day_start_time 07:00. Current time is 2026-02-10T15:00:00Z (10:00 AM EST). Today = 2026-02-10T12:00:00Z (7:00 AM EST) to 2026-02-11T11:59:59Z (6:59:59 AM EST next day).
  4. Events at 2:00 AM count toward the PREVIOUS day (before 07:00 boundary).
  5. PostgreSQL AT TIME ZONE used for correct timezone conversion.
  6. DST transitions handled correctly by PostgreSQL.

"Today" Count Rules

  1. today.feedings: Count of ALL feeding events where timestamp falls within today's boundary (breast + bottle + pump). 17a. today.breast_count: Count where type="breast" and timestamp is within today. 17b. today.bottle_count: Count where type="bottle" and timestamp is within today. 17c. today.pump_count: Count where type="pump" and timestamp is within today.
  2. today.diapers_pee: Count where type="pee" OR type="both" and timestamp is within today.
  3. today.diapers_poop: Count where type="poop" OR type="both" and timestamp is within today.
  4. today.sleep_minutes: Sum of duration_minutes for COMPLETED sleep sessions where start_time falls within today.
  5. A "both" diaper counts toward BOTH diapers_pee AND diapers_poop. 21a. today.ec_attempts: Count of diapers where ec_attempt=true and timestamp is within today. 21b. today.ec_catches: Count of diapers where ec_attempt=true AND ec_success=true and timestamp is within today.
  6. Sleep sessions that START today count, even if they end tomorrow.
  7. Sleep sessions that started yesterday but end today do NOT count.
  8. Active sleep sessions (no end_time) are NOT included in sleep_minutes (no duration yet).
  9. today.notes: Count of note events where timestamp falls within today's boundary.
  10. All caregivers' data included in counts (no filtering by caregiver).

"Last 24h" Rolling Window

  1. last_24h is a rolling 24-hour window ending at request time: NOW() - INTERVAL '24 hours' to NOW().
  2. No timezone math — purely UTC arithmetic. No dependency on user's day_start_time or timezone.
  3. last_24h.feedings: Count of feeding events where timestamp >= NOW()-24h AND timestamp < NOW().
  4. last_24h.diapers_pee: Count where type="pee" OR type="both" within the 24h window.
  5. last_24h.diapers_poop: Count where type="poop" OR type="both" within the 24h window.
  6. last_24h.sleep_minutes: Sum of duration_minutes for COMPLETED sleep sessions where start_time is within the 24h window.
  7. last_24h.notes: Count of note events within the 24h window.
  8. Same "both" diaper double-counting rule applies: counts in both diapers_pee and diapers_poop. 34a. last_24h.ec_attempts: Count of diapers where ec_attempt=true within the 24h window. 34b. last_24h.ec_catches: Count of diapers where ec_attempt=true AND ec_success=true within the 24h window.
  9. Active sleep sessions (no end_time) are NOT included in last_24h.sleep_minutes.
  10. All caregivers' data included (no filtering by caregiver).

Access Control

  1. User must have a ChildAccess record (owner or caregiver role) for the child.
  2. No access → 403 FORBIDDEN.
  3. Child not found → 404 NOT_FOUND.
  4. Invalid child UUID → 400 VALIDATION_ERROR.
  5. Missing/expired token → 401 UNAUTHORIZED.

Performance Requirements

  1. Query must complete in <100ms target, <200ms acceptable.
  2. Use database indexes on (child_id, timestamp) and (child_id, start_time).
  3. Single database round trip preferred (use CTEs or subqueries).
  4. No N+1 queries.

Implementation note: During development, evaluate a single raw SQL CTE (combining all last-event lookups + today's counts + active sleep in one query) versus multiple Prisma queries. CTE gives transactional consistency and a single round trip. Multiple Prisma queries are simpler to write/maintain. Profile both approaches against the <100ms target with realistic data before committing to one.

Data Consistency

  1. All data from the same transaction/query (no race conditions between fields).
  2. Response is a point-in-time snapshot at query execution.

Acceptance Criteria

  • Endpoint returns all dashboard data in single response
  • last_feeding includes timestamp, type, started_side, both_sides
  • last_feeding returns most recent feeding overall (not just today)
  • last_diaper includes timestamp and type
  • last_diaper returns most recent diaper overall (not just today)
  • last_sleep includes start_time, end_time, duration_minutes (completed sessions only)
  • last_sleep returns most recent completed sleep (not just today)
  • active_sleep returns {id, start_time} when baby is sleeping, null when awake
  • suggested_side returns opposite of last breast feeding's started_side
  • suggested_side returns null when no breast feedings exist
  • today.feedings counts all feedings within day_start_time boundary
  • today.diapers_pee counts "pee" + "both" diapers within today boundary
  • today.diapers_poop counts "poop" + "both" diapers within today boundary
  • today.sleep_minutes sums completed sleep sessions starting within today boundary
  • today.notes counts notes within today boundary
  • "both" diapers count in BOTH pee and poop totals
  • today.ec_attempts counts diapers with ec_attempt=true within today boundary
  • today.ec_catches counts diapers with ec_attempt=true AND ec_success=true within today boundary
  • last_24h.ec_attempts and last_24h.ec_catches count within rolling 24h window
  • EC counts default to 0 when no EC diapers exist
  • Active sleep NOT included in sleep_minutes
  • Day boundary uses user's day_start_time and timezone (not midnight)
  • Returns null for last_* and active_sleep when no data exists
  • Returns zeros for today counts when no data exists
  • Query completes in <200ms with 1000+ events per category
  • User without ChildAccess receives 403
  • Caregiver can access dashboard data
  • last_24h present in response with correct shape (feedings, diapers_pee, diapers_poop, sleep_minutes, notes)
  • last_24h counts events within rolling 24h window (NOW()-24h to NOW())
  • last_24h excludes events older than 24 hours
  • last_24h returns zeros when no events in window
  • last_24h.sleep_minutes only counts completed sleeps whose start_time is within the window
  • last_24h uses pure UTC arithmetic (no timezone or day_start_time logic)

Test Cases

Happy Path

  1. All data exists: Child has feedings, diapers, completed sleep → 200, all fields populated
  2. Last feeding is breast: type=breast, started_side=left, both_sides=false → returned correctly
  3. Last feeding is bottle: type=bottle → started_side=null, both_sides=false
  4. Last feeding from yesterday: Most recent feeding was yesterday → still returned (not limited to today)
  5. Multiple feedings today: Log 6 feedings within today boundary → today.feedings = 6

No Data Cases

  1. No data at all: No events logged → last_* all null, active_sleep null, suggested_side null, today counts all 0
  2. No feedings: Has diapers and sleep but no feedings → last_feeding = null, suggested_side = null
  3. No diapers: Has feedings and sleep but no diapers → last_diaper = null, today.diapers_pee = 0
  4. No sleep: Has feedings and diapers but no sleep → last_sleep = null, active_sleep = null, today.sleep_minutes = 0

Active Sleep

  1. Baby is sleeping: Start timer, GET dashboard → active_sleep = {id, start_time}, last_sleep = last completed session
  2. Baby is awake: No active timer → active_sleep = null
  3. Active sleep with no prior completed sleep: First-ever sleep started → active_sleep has data, last_sleep = null
  4. Active sleep not in sleep_minutes: Start timer today → today.sleep_minutes does NOT include active session

Suggested Side

  1. Last breast was left: Last breast feeding started_side=left → suggested_side = "right"
  2. Last breast was right: Last breast feeding started_side=right → suggested_side = "left"
  3. Last feeding was bottle, before that breast left: Most recent = bottle, second most recent = breast started_side=left → suggested_side = "right" (looks past bottles)
  4. No breast feedings: Only bottle feedings exist → suggested_side = null
  5. No feedings at all: → suggested_side = null
  6. Both sides feeding: Last breast started_side=left, both_sides=true → suggested_side = "right" (opposite of started_side regardless of both_sides)

"Both" Diaper Counting

  1. Single "both" diaper: type=both → today.diapers_pee = 1, today.diapers_poop = 1
  2. Mix of types: 2 pee + 1 poop + 1 both → today.diapers_pee = 3, today.diapers_poop = 2
  3. Only "both" diapers: 3 "both" → today.diapers_pee = 3, today.diapers_poop = 3

Day Boundary (day_start_time) Tests

  1. Event before day_start_time: day_start_time=07:00, feeding at 06:59 AM → counts in PREVIOUS day
  2. Event at day_start_time: day_start_time=07:00, feeding at 07:00 AM → counts in today
  3. Event after day_start_time: day_start_time=07:00, feeding at 07:01 AM → counts in today
  4. 2 AM event is "yesterday": day_start_time=07:00, feeding at 2:00 AM → counts in previous day's total
  5. Custom day_start_time: User sets day_start_time=06:00, feeding at 06:00 AM → counts in today

Timezone Tests

  1. User in America/New_York: Verify boundaries use EST/EDT correctly
  2. User in America/Los_Angeles: PST boundaries correct
  3. User in UTC: No offset applied, day_start_time in UTC
  4. User in Asia/Tokyo: UTC+9 boundaries correct
  5. DST transition day: Verify correct behavior during spring forward / fall back

Sleep Duration

  1. Sleep started today, ended today: 8:00 AM - 10:00 AM (120 min) → included in sleep_minutes
  2. Sleep started yesterday, ended today: Does NOT count in today (start_time is in previous day)
  3. Sleep started today, ends tomorrow: Counts in today (start_time is today)
  4. Multiple completed sessions: 120 + 60 + 150 min → today.sleep_minutes = 330

Multiple Caregivers

  1. Two caregivers log data: User A logs feeding, User B logs diaper → both appear in dashboard
  2. Caregiver data counted: Both users' feedings counted in today.feedings
  3. Last event by caregiver: User B logged most recent feeding → last_feeding shows their data

Access Control

  1. Owner access: → 200
  2. Caregiver access: → 200
  3. No access: → 403
  4. Child not found: → 404
  5. Invalid UUID: → 400
  6. Missing auth: → 401

Performance

  1. Large dataset: 1000 feedings, 1000 diapers, 500 sleep sessions → <200ms
  2. Index usage: Verify query plan uses (child_id, timestamp) and (child_id, start_time) indexes

Last 24h Rolling Window

  1. Shape present: Empty child → last_24h present with all zeros, correct keys
  2. Events within window: Feeding 1h ago, diaper 2h ago → last_24h.feedings = 1, diapers counted
  3. Events outside window: Feeding 25h ago → last_24h.feedings = 0
  4. Empty state: No events exist → all last_24h counts are 0
  5. Sleep minutes in window: Sleep started 5h ago (90 min) → last_24h.sleep_minutes = 90; sleep started 30h ago excluded

Edge Cases

  1. Feeding at exact day_start_time boundary: Counts in new day
  2. Very old last event: Last feeding from 1 year ago → still returned
  3. Partial data today: Feedings today but no diapers → feedings counted, diaper counts = 0

Boundaries

Performance Constraints

  • Single database round trip preferred, multiple acceptable if needed
  • No pagination (single summary object)
  • No caching in MVP (add if performance target not met)

Scope Constraints

  • No historical trends (use list endpoints + client aggregation)
  • Rolling 24h window (last_24h) is in scope for MVP
  • Week/month aggregations remain Post-MVP analytics
  • No caregiver filtering (all data included)
  • No real-time updates (client polls or refetches after mutations)
  • No multiple children per response

Data Boundaries

  • "Last" events are truly the most recent, no matter how old
  • Active sleep (no end_time) not included in sleep_minutes
  • Deleted events do not appear
  • No audit logging (read-only endpoint)

Client Responsibilities

  • Calculate "since last" elapsed time from timestamps
  • Display "Sleeping since Xh Ym" when active_sleep is not null
  • Display "Since last sleep: Xh Ym ago" using last_sleep.end_time when active_sleep is null
  • Use suggested_side for breastfeeding-first quick button primary action
  • Handle null values gracefully
  • Poll this endpoint to refresh (after mutations, on foreground, periodic)

Timezone Assumptions

  • User.timezone is always a valid IANA timezone string
  • User.day_start_time defines the day boundary (default 07:00)
  • PostgreSQL AT TIME ZONE handles DST correctly
  • All timestamps stored as UTC (TIMESTAMPTZ)