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_timeandtimezone - 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
- last_feeding: Most recent feeding by timestamp (any type, any caregiver).
- last_diaper: Most recent diaper change by timestamp (any type, any caregiver).
- last_sleep: Most recent COMPLETED sleep session by start_time (end_time is NOT null). Any caregiver.
- 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.
- "Last" events are NOT limited to today - they are the most recent overall.
- Returns null if no events exist for that category.
Suggested Side
- suggested_side: Opposite of the last breast feeding's
started_side. If last breast feeding started on "left", suggested_side = "right" (and vice versa). - Returns null if no breast feedings exist yet.
- Looks at the most recent breast feeding only (ignores bottle feedings).
- Used by iOS for the breastfeeding-first quick button (primary button shows suggested side).
"Today" Calculation
- "Today" is defined by the authenticated user's
day_start_timein theirtimezone. - Default: day starts at 07:00 in user's timezone.
- 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).
- Events at 2:00 AM count toward the PREVIOUS day (before 07:00 boundary).
- PostgreSQL
AT TIME ZONEused for correct timezone conversion. - DST transitions handled correctly by PostgreSQL.
"Today" Count Rules
- 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.
- today.diapers_pee: Count where type="pee" OR type="both" and timestamp is within today.
- today.diapers_poop: Count where type="poop" OR type="both" and timestamp is within today.
- today.sleep_minutes: Sum of duration_minutes for COMPLETED sleep sessions where start_time falls within today.
- 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.
- Sleep sessions that START today count, even if they end tomorrow.
- Sleep sessions that started yesterday but end today do NOT count.
- Active sleep sessions (no end_time) are NOT included in sleep_minutes (no duration yet).
- today.notes: Count of note events where timestamp falls within today's boundary.
- All caregivers' data included in counts (no filtering by caregiver).
"Last 24h" Rolling Window
- last_24h is a rolling 24-hour window ending at request time:
NOW() - INTERVAL '24 hours'toNOW(). - No timezone math — purely UTC arithmetic. No dependency on user's
day_start_timeortimezone. - last_24h.feedings: Count of feeding events where timestamp >= NOW()-24h AND timestamp < NOW().
- last_24h.diapers_pee: Count where type="pee" OR type="both" within the 24h window.
- last_24h.diapers_poop: Count where type="poop" OR type="both" within the 24h window.
- last_24h.sleep_minutes: Sum of duration_minutes for COMPLETED sleep sessions where start_time is within the 24h window.
- last_24h.notes: Count of note events within the 24h window.
- 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.
- Active sleep sessions (no end_time) are NOT included in last_24h.sleep_minutes.
- All caregivers' data included (no filtering by caregiver).
Access Control
- User must have a ChildAccess record (owner or caregiver role) for the child.
- No access → 403 FORBIDDEN.
- Child not found → 404 NOT_FOUND.
- Invalid child UUID → 400 VALIDATION_ERROR.
- Missing/expired token → 401 UNAUTHORIZED.
Performance Requirements
- Query must complete in <100ms target, <200ms acceptable.
- Use database indexes on (child_id, timestamp) and (child_id, start_time).
- Single database round trip preferred (use CTEs or subqueries).
- 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
- All data from the same transaction/query (no race conditions between fields).
- 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
- All data exists: Child has feedings, diapers, completed sleep → 200, all fields populated
- Last feeding is breast: type=breast, started_side=left, both_sides=false → returned correctly
- Last feeding is bottle: type=bottle → started_side=null, both_sides=false
- Last feeding from yesterday: Most recent feeding was yesterday → still returned (not limited to today)
- Multiple feedings today: Log 6 feedings within today boundary → today.feedings = 6
No Data Cases
- No data at all: No events logged → last_* all null, active_sleep null, suggested_side null, today counts all 0
- No feedings: Has diapers and sleep but no feedings → last_feeding = null, suggested_side = null
- No diapers: Has feedings and sleep but no diapers → last_diaper = null, today.diapers_pee = 0
- No sleep: Has feedings and diapers but no sleep → last_sleep = null, active_sleep = null, today.sleep_minutes = 0
Active Sleep
- Baby is sleeping: Start timer, GET dashboard → active_sleep = {id, start_time}, last_sleep = last completed session
- Baby is awake: No active timer → active_sleep = null
- Active sleep with no prior completed sleep: First-ever sleep started → active_sleep has data, last_sleep = null
- Active sleep not in sleep_minutes: Start timer today → today.sleep_minutes does NOT include active session
Suggested Side
- Last breast was left: Last breast feeding started_side=left → suggested_side = "right"
- Last breast was right: Last breast feeding started_side=right → suggested_side = "left"
- Last feeding was bottle, before that breast left: Most recent = bottle, second most recent = breast started_side=left → suggested_side = "right" (looks past bottles)
- No breast feedings: Only bottle feedings exist → suggested_side = null
- No feedings at all: → suggested_side = null
- 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
- Single "both" diaper: type=both → today.diapers_pee = 1, today.diapers_poop = 1
- Mix of types: 2 pee + 1 poop + 1 both → today.diapers_pee = 3, today.diapers_poop = 2
- Only "both" diapers: 3 "both" → today.diapers_pee = 3, today.diapers_poop = 3
Day Boundary (day_start_time) Tests
- Event before day_start_time: day_start_time=07:00, feeding at 06:59 AM → counts in PREVIOUS day
- Event at day_start_time: day_start_time=07:00, feeding at 07:00 AM → counts in today
- Event after day_start_time: day_start_time=07:00, feeding at 07:01 AM → counts in today
- 2 AM event is "yesterday": day_start_time=07:00, feeding at 2:00 AM → counts in previous day's total
- Custom day_start_time: User sets day_start_time=06:00, feeding at 06:00 AM → counts in today
Timezone Tests
- User in America/New_York: Verify boundaries use EST/EDT correctly
- User in America/Los_Angeles: PST boundaries correct
- User in UTC: No offset applied, day_start_time in UTC
- User in Asia/Tokyo: UTC+9 boundaries correct
- DST transition day: Verify correct behavior during spring forward / fall back
Sleep Duration
- Sleep started today, ended today: 8:00 AM - 10:00 AM (120 min) → included in sleep_minutes
- Sleep started yesterday, ended today: Does NOT count in today (start_time is in previous day)
- Sleep started today, ends tomorrow: Counts in today (start_time is today)
- Multiple completed sessions: 120 + 60 + 150 min → today.sleep_minutes = 330
Multiple Caregivers
- Two caregivers log data: User A logs feeding, User B logs diaper → both appear in dashboard
- Caregiver data counted: Both users' feedings counted in today.feedings
- Last event by caregiver: User B logged most recent feeding → last_feeding shows their data
Access Control
- Owner access: → 200
- Caregiver access: → 200
- No access: → 403
- Child not found: → 404
- Invalid UUID: → 400
- Missing auth: → 401
Performance
- Large dataset: 1000 feedings, 1000 diapers, 500 sleep sessions → <200ms
- Index usage: Verify query plan uses (child_id, timestamp) and (child_id, start_time) indexes
Last 24h Rolling Window
- Shape present: Empty child → last_24h present with all zeros, correct keys
- Events within window: Feeding 1h ago, diaper 2h ago → last_24h.feedings = 1, diapers counted
- Events outside window: Feeding 25h ago → last_24h.feedings = 0
- Empty state: No events exist → all last_24h counts are 0
- Sleep minutes in window: Sleep started 5h ago (90 min) → last_24h.sleep_minutes = 90; sleep started 30h ago excluded
Edge Cases
- Feeding at exact day_start_time boundary: Counts in new day
- Very old last event: Last feeding from 1 year ago → still returned
- 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)