Skip to content

Fix steps trends: bucket daily aggregate by local day and surface today's live total#231

Merged
d3mocide merged 3 commits into
mainfrom
claude/steps-calculation-discrepancy-byd1s4
Jun 17, 2026
Merged

Fix steps trends: bucket daily aggregate by local day and surface today's live total#231
d3mocide merged 3 commits into
mainfrom
claude/steps-calculation-discrepancy-byd1s4

Conversation

@d3mocide

Copy link
Copy Markdown
Owner

Problem

Reported symptoms:

  • Apple Health shows ~5k steps today, Trends shows ~3k.
  • The ~3k value actually matches yesterday's steps — the chart surfaces yesterday's data as the latest point instead of today's current total.
  • "System" average ~8k vs Apple Health monthly average ~5k.

Root cause

The biometrics_daily TimescaleDB continuous aggregate bucketed samples with time_bucket('1 day', ts). On a timestamptz column with no timezone argument, buckets align to UTC midnight regardless of session timezone — but everywhere else in the app (/today, streaks, the Trends "live today" supplement) a calendar day is defined by SERVER_TIMEZONE (PR #219).

For a user in a negative-offset zone, this splits each local day's step samples across two UTC buckets:

  • Daily totals and the multi-day average diverge from Apple Health's clean local-day numbers (the 8k vs 5k gap).
  • The right-most ("today") bucket represents a UTC day straddling the user's yesterday/today boundary, so the headline reflects a partial/mixed day — and the live-today supplement only appended when the last bucket's date didn't equal today, so a stale/partial aggregate bucket could shadow the correct value.

Changes

  • 0030_biometrics_daily_local_tz.py — recreate the continuous aggregate with the timezone-aware time_bucket('1 day', ts, SERVER_TIMEZONE) overload so buckets align to local midnight, and fully refresh so existing history re-buckets correctly. (The timezone is embedded as a literal because a continuous aggregate definition can't reference runtime params; it's read from validated config.)
  • trends.py — extract the bucket date in SERVER_TIMEZONE (day AT TIME ZONE :tz), and always recompute today from raw biometrics, replacing a stale/partial aggregate bucket (not just appending) so the latest point and headline always show today's live local-day total.

⚠️ Required deployment config

This fix is only fully correct when SERVER_TIMEZONE is set to the user's actual zone (e.g. America/Denver). It currently defaults to UTC in config.py. If the deployment is still on UTC while the user is on US time, the historical buckets and today's boundary will still be off — please confirm/set SERVER_TIMEZONE in the environment.

Testing notes

No DB/deps available in this session, so the aggregate refresh and endpoint couldn't be exercised here. Recommend verifying after deploy: run alembic upgrade head, then confirm /trends/steps returns today's local-day total as the last series point and that historical daily sums match Apple Health.

🤖 Generated with Claude Code


Generated by Claude Code

claude added 3 commits June 17, 2026 00:59
…ay live

The biometrics_daily continuous aggregate bucketed samples with
time_bucket('1 day', ts), which aligns to UTC midnight regardless of session
timezone, while the rest of the app defines a calendar day by SERVER_TIMEZONE.
For users in a negative-offset zone this split a day's steps across two UTC
buckets, so daily totals and the multi-day average diverged from Apple
Health's local-day numbers, and the right-most chart point reflected a UTC
day straddling the user's yesterday/today boundary.

- Add migration 0030 recreating biometrics_daily with the timezone-aware
  time_bucket('1 day', ts, SERVER_TIMEZONE) so buckets align to local midnight,
  and fully refresh so existing history re-buckets correctly.
- In the trends endpoint, extract the bucket date in SERVER_TIMEZONE and always
  recompute today from raw biometrics, replacing a stale/partial aggregate
  bucket so the latest point and headline show today's live local-day total.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0133D6GfA6XvnH3oqXUBBBtP
…egate helper

Two remaining timezone gaps surfaced by the day-boundary audit:

- ldl_simulate / protein_simulate counted distinct days for their 7-day
  averaging divisor via e.ts.date() on UTC-aware meal timestamps, so meals
  near local midnight landed in the wrong day and could skew the divisor by
  ±1. Convert to SERVER_TIMEZONE before taking the date, matching the rest of
  the day-boundary contract.

- Remove the unused create_continuous_aggregates() helper in db/timescale.py.
  It defined biometrics_daily with a UTC time_bucket('1 day', ts) and was
  never called (migrations use inline SQL); leaving it risked silently
  reintroducing the UTC-bucketing bug fixed in migration 0030.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0133D6GfA6XvnH3oqXUBBBtP
Importing luma.config in the migration instantiated Settings() at Alembic
load time, which requires app secrets (jwt_secret) absent in the migrations
CI job, so `alembic upgrade head` failed with a pydantic ValidationError.
Read the SERVER_TIMEZONE env var directly (the same value pydantic reads,
defaulting to UTC) and validate it via ZoneInfo, matching how env.py reads
DATABASE_URL. Also resolves the ruff I001 import-order failure.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0133D6GfA6XvnH3oqXUBBBtP
@d3mocide d3mocide marked this pull request as ready for review June 17, 2026 01:17
@d3mocide d3mocide merged commit 537c21c into main Jun 17, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants