Output:
Reading additional input from stdin...
OpenAI Codex v0.128.0 (research preview)
--------
workdir: /home/ubuntu/health-bridge
model: gpt-5.5
provider: openai
approval: never
sandbox: danger-full-access
reasoning effort: low
reasoning summaries: none
session id: 019e4269-c141-72f2-8ca9-9a0a3eb179d8
--------
user
READ-ONLY MICRO REVIEW. Be very economical. Review only the post-signoff compatibility fix in server.py/test_server.py: endpoints now SELECT explicit SUMMARY_COLUMNS instead of SELECT * to handle migrated SQLite column order, plus regression test. Local tests pass: 7 passed. Output SIGN OFF FOR DEPLOY if no blockers, otherwise DO NOT DEPLOY with blocker.
codex
I’ll inspect the relevant diff only in `server.py` and `test_server.py`, then check for deployment blockers around the explicit column selection and regression coverage.
exec
/bin/bash -lc 'git diff -- server.py test_server.py' in /home/ubuntu/health-bridge
exec
/bin/bash -lc 'git status --short' in /home/ubuntu/health-bridge
 succeeded in 0ms:
A  .gitignore
A  android-app/.gitignore
A  android-app/app/build.gradle.kts
A  android-app/app/src/main/AndroidManifest.xml
AM android-app/app/src/main/java/com/healthbridge/app/HealthConnectSync.kt
AM android-app/app/src/main/java/com/healthbridge/app/MainActivity.kt
A  android-app/app/src/main/java/com/healthbridge/app/PermissionsRationaleActivity.kt
A  android-app/app/src/main/res/layout/activity_main.xml
A  android-app/app/src/main/res/values/strings.xml
A  android-app/build.gradle.kts
A  android-app/gradle.properties
A  android-app/gradle/wrapper/gradle-wrapper.jar
A  android-app/gradle/wrapper/gradle-wrapper.properties
A  android-app/gradlew
A  android-app/gradlew.bat
A  android-app/settings.gradle.kts
A  health-bridge.service
AM server.py
?? test_server.py

 succeeded in 0ms:
diff --git a/server.py b/server.py
index 2ec3284..72e7006 100644
--- a/server.py
+++ b/server.py
@@ -1,24 +1,49 @@
-"""Health Bridge — endpoint receptor de datos de Health Connect.
+"""Health Bridge — private Health Connect ingestion API for Pipo.

-Recibe POST del Pixel 9a, almacena en SQLite, expone API para Pipo.
-Solo escucha en Tailscale (no público).
+Receives Pixel Health Connect batches over Tailscale, stores raw records
+idempotently, and rebuilds local-day summaries per subject.
+
+Design goals:
+- raw_data preserves source records with provenance
+- daily_summary is derived, idempotent, dashboard-friendly
+- /debug/day explains every number by source/classification
 """
+import hashlib
 import json
-import sqlite3
 import os
-from datetime import datetime, timezone, timedelta
+import sqlite3
 from contextlib import asynccontextmanager
+from datetime import datetime, timezone
+from zoneinfo import ZoneInfo

-from fastapi import FastAPI, Request, HTTPException
+from fastapi import FastAPI, HTTPException, Request
 from fastapi.middleware.cors import CORSMiddleware
 from fastapi.responses import FileResponse
 import uvicorn
-import os

 DB_PATH = os.path.expanduser("~/health-bridge/health.db")
 AUTH_TOKEN=os.env...EN", "changeme")
+DEFAULT_TIMEZONE = os.environ.get("HEALTH_BRIDGE_TIMEZONE", "America/Argentina/Buenos_Aires")
+DEFAULT_SUBJECT = os.environ.get("HEALTH_BRIDGE_DEFAULT_SUBJECT", "chicho")
+
+SUMMARY_COLUMNS = [
+    "subject", "date", "steps", "distance_m", "floors", "active_minutes",
+    "exercise_minutes", "workout_minutes", "auto_activity_minutes",
+    "calories", "active_kcal", "total_kcal", "nutrition_kcal",
+    "heart_rate_avg", "heart_rate_min", "heart_rate_max", "sleep_minutes",
+    "workouts", "workout_count", "auto_activity_count", "weight_kg",
+    "body_fat_percentage", "height_m", "protein_g", "carbs_g", "fat_g",
+    "hydration_ml", "updated_at",
+]
+
+SOURCE_RULES = {
+    "com.hevy": {"class": "explicit_workout", "label": "Hevy", "trust": "high"},
+    "com.google.android.apps.fitness": {"class": "auto_activity", "label": "Google Fit", "trust": "medium"},
+    "com.fitbit.FitbitMobile": {"class": "wearable_activity", "label": "Fitbit", "trust": "high"},
+    "health_connect": {"class": "legacy_unknown", "label": "Health Connect legacy", "trust": "low"},
+}
+EXERCISE_TYPE_STRENGTH = {"70"}

-# ── DB ────────────────────────────────────────────────────────────

 def init_db():
     conn = sqlite3.connect(DB_PATH)
@@ -27,38 +52,205 @@ def init_db():
         CREATE TABLE IF NOT EXISTS raw_data (
             id INTEGER PRIMARY KEY AUTOINCREMENT,
             received_at TEXT NOT NULL DEFAULT (datetime('now')),
+            subject TEXT NOT NULL DEFAULT 'chicho',
             data_type TEXT NOT NULL,
+            record_key TEXT,
+            local_date TEXT,
+            source TEXT,
             data_json TEXT NOT NULL
         );
         CREATE INDEX IF NOT EXISTS idx_raw_data_type ON raw_data(data_type);
         CREATE INDEX IF NOT EXISTS idx_raw_data_received ON raw_data(received_at);

         CREATE TABLE IF NOT EXISTS daily_summary (
-            date TEXT PRIMARY KEY,
+            subject TEXT NOT NULL DEFAULT 'chicho',
+            date TEXT NOT NULL,
             steps INTEGER DEFAULT 0,
+            distance_m REAL DEFAULT 0,
+            floors REAL DEFAULT 0,
             active_minutes INTEGER DEFAULT 0,
+            exercise_minutes INTEGER DEFAULT 0,
+            workout_minutes INTEGER DEFAULT 0,
+            auto_activity_minutes INTEGER DEFAULT 0,
             calories REAL DEFAULT 0,
+            active_kcal REAL DEFAULT 0,
+            total_kcal REAL DEFAULT 0,
+            nutrition_kcal REAL DEFAULT 0,
             heart_rate_avg REAL,
             heart_rate_min REAL,
             heart_rate_max REAL,
             sleep_minutes INTEGER DEFAULT 0,
+            workouts INTEGER DEFAULT 0,
+            workout_count INTEGER DEFAULT 0,
+            auto_activity_count INTEGER DEFAULT 0,
             weight_kg REAL,
-            updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+            body_fat_percentage REAL,
+            height_m REAL,
+            protein_g REAL DEFAULT 0,
+            carbs_g REAL DEFAULT 0,
+            fat_g REAL DEFAULT 0,
+            hydration_ml REAL DEFAULT 0,
+            updated_at TEXT NOT NULL DEFAULT (datetime('now')),
+            PRIMARY KEY (subject, date)
         );
     """)
+    _migrate_schema(conn)
     conn.commit()
     return conn

+
+def _table_columns(conn: sqlite3.Connection, table: str) -> dict:
+    return {r[1]: r for r in conn.execute(f"PRAGMA table_info({table})").fetchall()}
+
+
+def _migrate_schema(conn: sqlite3.Connection):
+    raw_cols = _table_columns(conn, "raw_data")
+    for col, ddl in {
+        "subject": "ALTER TABLE raw_data ADD COLUMN subject TEXT NOT NULL DEFAULT 'chicho'",
+        "record_key": "ALTER TABLE raw_data ADD COLUMN record_key TEXT",
+        "local_date": "ALTER TABLE raw_data ADD COLUMN local_date TEXT",
+        "source": "ALTER TABLE raw_data ADD COLUMN source TEXT",
+    }.items():
+        if col not in raw_cols:
+            conn.execute(ddl)
+
+    summary_cols = _table_columns(conn, "daily_summary")
+    if "subject" not in summary_cols or not _daily_summary_has_subject_pk(conn):
+        conn.execute("ALTER TABLE daily_summary RENAME TO daily_summary_legacy")
+        _create_daily_summary_table(conn)
+        legacy_cols = _table_columns(conn, "daily_summary_legacy")
+        select_exprs = []
+        for col in SUMMARY_COLUMNS:
+            if col in legacy_cols:
+                select_exprs.append(col)
+            elif col == "subject":
+                select_exprs.append("'chicho'")
+            elif col == "updated_at":
+                select_exprs.append("datetime('now')")
+            else:
+                select_exprs.append("0")
+        conn.execute(
+            f"INSERT OR REPLACE INTO daily_summary ({', '.join(SUMMARY_COLUMNS)}) "
+            f"SELECT {', '.join(select_exprs)} FROM daily_summary_legacy"
+        )
+        conn.execute("DROP TABLE daily_summary_legacy")
+    else:
+        summary_cols = _table_columns(conn, "daily_summary")
+        for col, ddl in {
+            "distance_m": "ALTER TABLE daily_summary ADD COLUMN distance_m REAL DEFAULT 0",
+            "floors": "ALTER TABLE daily_summary ADD COLUMN floors REAL DEFAULT 0",
+            "exercise_minutes": "ALTER TABLE daily_summary ADD COLUMN exercise_minutes INTEGER DEFAULT 0",
+            "workout_minutes": "ALTER TABLE daily_summary ADD COLUMN workout_minutes INTEGER DEFAULT 0",
+            "auto_activity_minutes": "ALTER TABLE daily_summary ADD COLUMN auto_activity_minutes INTEGER DEFAULT 0",
+            "active_kcal": "ALTER TABLE daily_summary ADD COLUMN active_kcal REAL DEFAULT 0",
+            "total_kcal": "ALTER TABLE daily_summary ADD COLUMN total_kcal REAL DEFAULT 0",
+            "nutrition_kcal": "ALTER TABLE daily_summary ADD COLUMN nutrition_kcal REAL DEFAULT 0",
+            "workouts": "ALTER TABLE daily_summary ADD COLUMN workouts INTEGER DEFAULT 0",
+            "workout_count": "ALTER TABLE daily_summary ADD COLUMN workout_count INTEGER DEFAULT 0",
+            "auto_activity_count": "ALTER TABLE daily_summary ADD COLUMN auto_activity_count INTEGER DEFAULT 0",
+            "body_fat_percentage": "ALTER TABLE daily_summary ADD COLUMN body_fat_percentage REAL",
+            "height_m": "ALTER TABLE daily_summary ADD COLUMN height_m REAL",
+            "protein_g": "ALTER TABLE daily_summary ADD COLUMN protein_g REAL DEFAULT 0",
+            "carbs_g": "ALTER TABLE daily_summary ADD COLUMN carbs_g REAL DEFAULT 0",
+            "fat_g": "ALTER TABLE daily_summary ADD COLUMN fat_g REAL DEFAULT 0",
+            "hydration_ml": "ALTER TABLE daily_summary ADD COLUMN hydration_ml REAL DEFAULT 0",
+        }.items():
+            if col not in summary_cols:
+                conn.execute(ddl)
+
+    index_sql = conn.execute(
+        "SELECT sql FROM sqlite_master WHERE type = 'index' AND name = 'idx_raw_data_record_key'"
+    ).fetchone()
+    if index_sql and "WHERE" in (index_sql[0] or "").upper():
+        conn.execute("DROP INDEX idx_raw_data_record_key")
+
+    _backfill_legacy_raw_metadata(conn)
+    conn.execute("CREATE INDEX IF NOT EXISTS idx_raw_data_subject_date ON raw_data(subject, local_date)")
+    conn.execute("CREATE INDEX IF NOT EXISTS idx_daily_summary_subject_date ON daily_summary(subject, date)")
+    conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_raw_data_record_key ON raw_data(record_key)")
+
+
+def _create_daily_summary_table(conn: sqlite3.Connection):
+    conn.executescript("""
+        CREATE TABLE daily_summary (
+            subject TEXT NOT NULL DEFAULT 'chicho',
+            date TEXT NOT NULL,
+            steps INTEGER DEFAULT 0,
+            distance_m REAL DEFAULT 0,
+            floors REAL DEFAULT 0,
+            active_minutes INTEGER DEFAULT 0,
+            exercise_minutes INTEGER DEFAULT 0,
+            workout_minutes INTEGER DEFAULT 0,
+            auto_activity_minutes INTEGER DEFAULT 0,
+            calories REAL DEFAULT 0,
+            active_kcal REAL DEFAULT 0,
+            total_kcal REAL DEFAULT 0,
+            nutrition_kcal REAL DEFAULT 0,
+            heart_rate_avg REAL,
+            heart_rate_min REAL,
+            heart_rate_max REAL,
+            sleep_minutes INTEGER DEFAULT 0,
+            workouts INTEGER DEFAULT 0,
+            workout_count INTEGER DEFAULT 0,
+            auto_activity_count INTEGER DEFAULT 0,
+            weight_kg REAL,
+            body_fat_percentage REAL,
+            height_m REAL,
+            protein_g REAL DEFAULT 0,
+            carbs_g REAL DEFAULT 0,
+            fat_g REAL DEFAULT 0,
+            hydration_ml REAL DEFAULT 0,
+            updated_at TEXT NOT NULL DEFAULT (datetime('now')),
+            PRIMARY KEY (subject, date)
+        );
+    """)
+
+
+def _daily_summary_has_subject_pk(conn: sqlite3.Connection) -> bool:
+    cols = conn.execute("PRAGMA table_info(daily_summary)").fetchall()
+    pk_cols = [r[1] for r in sorted([r for r in cols if r[5]], key=lambda r: r[5])]
+    return pk_cols == ["subject", "date"]
+
+
+def _backfill_legacy_raw_metadata(conn: sqlite3.Connection):
+    rows = conn.execute(
+        "SELECT id, data_type, data_json FROM raw_data WHERE record_key IS NULL OR local_date IS NULL OR source IS NULL OR subject IS NULL"
+    ).fetchall()
+    for row_id, data_type, data_json in rows:
+        try:
+            record = json.loads(data_json)
+        except Exception:
+            continue
+        normalized = _normalize_record(record, DEFAULT_TIMEZONE, DEFAULT_SUBJECT)
+        record_key = normalized["record_key"]
+        exists = conn.execute("SELECT id FROM raw_data WHERE record_key = ? AND id != ?", (record_key, row_id)).fetchone()
+        if exists:
+            record_key = f"legacy:{row_id}:{record_key}"
+            normalized["record"]["record_key"] = record_key
+        conn.execute(
+            """
+            UPDATE raw_data
+            SET subject = ?, data_type = ?, record_key = ?, local_date = ?, source = ?, data_json = ?
+            WHERE id = ?
+            """,
+            (
+                normalized["subject"], normalized["data_type"] or data_type, record_key,
+                normalized["local_date"], normalized["source"],
+                json.dumps(normalized["record"], separators=(",", ":"), sort_keys=True), row_id,
+            ),
+        )
+
+
 @asynccontextmanager
 async def lifespan(app: FastAPI):
     app.state.db = init_db()
     yield
     app.state.db.close()

+
 app = FastAPI(title="Health Bridge", lifespan=lifespan)
 app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])

-# ── Auth ───────────────────────────────────────────────────────────

 async def check_token(request: Request):
     token = request.headers.get("Authorization", "").replace("Bearer ", "")
@@ -66,16 +258,17 @@ async def check_token(request: Request):
         raise HTTPException(status_code=401, detail="Invalid token")
     return token

-# ── Endpoints ──────────────────────────────────────────────────────

 @app.get("/health")
 async def health():
     return {"status": "ok", "time": datetime.now(timezone.utc).isoformat()}

+
 @app.get("/")
 async def root():
     return {"status": "ok", "service": "health-bridge"}

+
 @app.get("/download")
 async def download_apk():
     apk_path = os.path.expanduser("~/health-bridge/static/health-bridge.apk")
@@ -83,144 +276,512 @@ async def download_apk():
         raise HTTPException(status_code=404, detail="APK not found. Build it first.")
     return FileResponse(apk_path, media_type="application/vnd.android.package-archive", filename="health-bridge.apk")

+
 @app.post("/ingest")
-async def ingest(request: Request, _token: str = None):
-    """Recibe un batch de datos de Health Connect."""
+async def ingest(request: Request):
     await check_token(request)
     body = await request.json()
     records = body.get("records", [])
-
+    client_tz = body.get("timezone") or DEFAULT_TIMEZONE
+    subject = body.get("subject") or DEFAULT_SUBJECT
     if not records:
         raise HTTPException(status_code=400, detail="No records provided")
-
+
     db: sqlite3.Connection = app.state.db
+    affected = set()
     count = 0
-
     for record in records:
-        data_type = record.get("type", "unknown")
+        normalized = _normalize_record(record, client_tz, subject)
         db.execute(
-            "INSERT INTO raw_data (data_type, data_json) VALUES (?, ?)",
-            (data_type, json.dumps(record))
+            """
+            INSERT INTO raw_data (subject, data_type, record_key, local_date, source, data_json, received_at)
+            VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
+            ON CONFLICT(record_key) DO UPDATE SET
+                subject = excluded.subject,
+                data_type = excluded.data_type,
+                local_date = excluded.local_date,
+                source = excluded.source,
+                data_json = excluded.data_json,
+                received_at = datetime('now')
+            """,
+            (
+                normalized["subject"], normalized["data_type"], normalized["record_key"],
+                normalized["local_date"], normalized["source"],
+                json.dumps(normalized["record"], separators=(",", ":"), sort_keys=True),
+            ),
         )
+        if normalized["local_date"]:
+            affected.add((normalized["subject"], normalized["local_date"]))
         count += 1
-
     db.commit()
-
-    # Actualizar daily_summary (upsert simple)
-    _update_daily_summary(db, records)
-
-    return {"status": "ok", "records": count}
+
+    rebuilt = []
+    for subj, local_date in sorted(affected):
+        _rebuild_daily_summary(db, subj, local_date)
+        rebuilt.append({"subject": subj, "date": local_date})
+    return {"status": "ok", "records": count, "dates_rebuilt": rebuilt}
+

 @app.get("/data")
-async def get_data(request: Request, data_type: str = None, limit: int = 100):
-    """Consulta datos almacenados."""
+async def get_data(request: Request, data_type: str = None, subject: str = None, limit: int = 100):
     await check_token(request)
     db: sqlite3.Connection = app.state.db
-
+    subject = subject or DEFAULT_SUBJECT
     if data_type:
         rows = db.execute(
-            "SELECT data_json, received_at FROM raw_data WHERE data_type = ? ORDER BY id DESC LIMIT ?",
-            (data_type, limit)
+            "SELECT data_json, received_at FROM raw_data WHERE subject = ? AND data_type = ? ORDER BY id DESC LIMIT ?",
+            (subject, data_type, limit),
         ).fetchall()
     else:
         rows = db.execute(
-            "SELECT data_json, received_at FROM raw_data ORDER BY id DESC LIMIT ?",
-            (limit,)
+            "SELECT data_json, received_at FROM raw_data WHERE subject = ? ORDER BY id DESC LIMIT ?",
+            (subject, limit),
         ).fetchall()
-
+    return {"subject": subject, "count": len(rows), "data": [{"record": json.loads(r[0]), "received_at": r[1]} for r in rows]}
+
+
+@app.get("/summary")
+async def get_summary(request: Request, days: int = 7, subject: str = None):
+    await check_token(request)
+    db: sqlite3.Connection = app.state.db
+    subject = subject or DEFAULT_SUBJECT
+    rows = db.execute(
+        f"SELECT {', '.join(SUMMARY_COLUMNS)} FROM daily_summary WHERE subject = ? ORDER BY date DESC LIMIT ?",
+        (subject, days),
+    ).fetchall()
+    return {"subject": subject, "days": [_summary_dict(r) for r in rows]}
+
+
+@app.get("/overview")
+async def get_overview(request: Request, days: int = 7, subject: str = None):
+    await check_token(request)
+    db: sqlite3.Connection = app.state.db
+    subject = subject or DEFAULT_SUBJECT
+    rows = db.execute(
+        f"SELECT {', '.join(SUMMARY_COLUMNS)} FROM daily_summary WHERE subject = ? ORDER BY date DESC LIMIT ?",
+        (subject, days),
+    ).fetchall()
+    summaries = [_summary_dict(r) for r in rows]
+    freshness = db.execute(
+        """
+        SELECT MAX(received_at), COUNT(*), COUNT(DISTINCT local_date), COUNT(DISTINCT source)
+        FROM raw_data WHERE subject = ?
+        """,
+        (subject,),
+    ).fetchone()
     return {
-        "count": len(rows),
-        "data": [{"record": json.loads(r[0]), "received_at": r[1]} for r in rows]
+        "subject": subject,
+        "timezone": DEFAULT_TIMEZONE,
+        "freshness": {
+            "last_sync_at": freshness[0],
+            "raw_records": freshness[1],
+            "covered_days": freshness[2],
+            "sources": freshness[3],
+        },
+        "today": summaries[0] if summaries else None,
+        "days": summaries,
+        "legend": {
+            "workout_minutes": "explicit workout sessions, primarily Hevy/strength",
+            "auto_activity_minutes": "auto-detected activity, usually Google Fit walk

... [OUTPUT TRUNCATED - 24323 chars omitted out of 74323 total] ...

onical_fingerprint(base))
    return base

def test_classifies_hevy_vs_google_fit_exercise():
    hevy = rec(type="exercise", source="com.hevy", exercise_type="70", title="Min-Max", minutes=117, start="a", end="b")
    fit = rec(type="exercise", source="com.google.android.apps.fitness", exercise_type="79", minutes=41, start="c", end="d")
    assert server.classify_record(hevy)["class"] == "explicit_workout"
    assert server.classify_record(fit)["class"] == "auto_activity"

def test_summary_splits_workout_and_auto_activity_minutes():
    records = [
        rec(type="daily_aggregate", source="health_connect", steps=11540, distance_m=11122, active_kcal=0, total_kcal=1544),
        rec(type="exercise", source="com.hevy", exercise_type="70", title="Min-Max", minutes=117, start="2026-05-19T18:23:32Z", end="2026-05-19T20:20:51Z"),
        rec(type="exercise", source="com.google.android.apps.fitness", exercise_type="79", minutes=41, start="2026-05-19T15:24:07Z", end="2026-05-19T16:06:00Z"),
    ]
    summary, provenance = server.compute_daily_summary(records)
    assert summary["steps"] == 11540
    assert summary["workout_minutes"] == 117
    assert summary["auto_activity_minutes"] == 41
    assert summary["exercise_minutes"] == 158
    assert summary["workout_count"] == 1
    assert summary["auto_activity_count"] == 1
    assert "workout_minutes" in provenance["by_metric"]
    assert "auto_activity_minutes" in provenance["by_metric"]

def test_dedup_prefers_named_source_over_legacy_health_connect():
    legacy = rec(type="exercise", source="health_connect", exercise_type="79", minutes=20, start="2026-05-19T12:00:00Z", end="2026-05-19T12:20:00Z")
    google = rec(type="exercise", source="com.google.android.apps.fitness", exercise_type="79", minutes=20, start="2026-05-19T12:00:00Z", end="2026-05-19T12:20:00Z")
    # Force same semantic fingerprint even with different source.
    fp = server.canonical_fingerprint(google)
    legacy["fingerprint"] = fp
    google["fingerprint"] = fp
    summary, provenance = server.compute_daily_summary([legacy, google])
    assert summary["auto_activity_minutes"] == 20
    assert len(provenance["ignored"]) == 1
    assert provenance["ignored"][0]["source"] == "health_connect"

def test_nutrition_intake_does_not_mix_with_burned_calories():
    records = [
        rec(type="daily_aggregate", source="health_connect", total_kcal=1905, active_kcal=0, steps=1000),
        rec(type="nutrition", source="com.myfitnesspal.android", energy_kcal=600, protein_g=45, carbs_g=70, fat_g=20, start="2026-05-19T13:00:00Z", end="2026-05-19T13:10:00Z"),
    ]
    summary, _ = server.compute_daily_summary(records)
    assert summary["calories"] == 1905
    assert summary["total_kcal"] == 1905
    assert summary["nutrition_kcal"] == 600
    assert summary["protein_g"] == 45

def test_legacy_wide_aggregate_is_ignored():
    records = [
        rec(type="aggregated", source="health_connect", steps=95229, total_kcal=13332, period_start="2026-05-11T22:44:38Z", period_end="2026-05-18T22:44:38Z"),
        rec(type="exercise", source="health_connect", exercise_type="79", minutes=27, start="2026-05-11T23:34:38Z", end="2026-05-12T00:02:12Z"),
    ]
    summary, provenance = server.compute_daily_summary(records)
    assert summary["steps"] == 0
    assert summary["total_kcal"] == 0
    assert summary["auto_activity_minutes"] == 27
    assert provenance["ignored"][0]["reason"] == "stale_wide_aggregate"

def test_rebuild_summary_writes_new_columns(tmp_path, monkeypatch):
    db = sqlite3.connect(tmp_path / "health.db")
    db.execute("PRAGMA journal_mode=WAL")
    monkeypatch.setattr(server, "DB_PATH", str(tmp_path / "health.db"))
    db.close()
    db = server.init_db()
    records = [
        rec(type="daily_aggregate", source="health_connect", steps=10, distance_m=20),
        rec(type="exercise", source="com.hevy", exercise_type="70", title="Lift", minutes=30, start="2026-05-19T10:00:00Z", end="2026-05-19T10:30:00Z"),
    ]
    for r in records:
        n = server._normalize_record(r, server.DEFAULT_TIMEZONE, "chicho")
        db.execute(
            "INSERT INTO raw_data (subject, data_type, record_key, local_date, source, data_json) VALUES (?, ?, ?, ?, ?, ?)",
            (n["subject"], n["data_type"], n["record_key"], n["local_date"], n["source"], __import__('json').dumps(n["record"])),
        )
    db.commit()
    server._rebuild_daily_summary(db, "chicho", "2026-05-19")
    row = db.execute("SELECT steps, workout_minutes, auto_activity_minutes, exercise_minutes, workout_count FROM daily_summary WHERE subject='chicho' AND date='2026-05-19'").fetchone()
    assert row == (10, 30, 0, 30, 1)

def test_summary_dict_uses_explicit_column_order_for_migrated_schema(tmp_path, monkeypatch):
    monkeypatch.setattr(server, "DB_PATH", str(tmp_path / "health.db"))
    db = server.init_db()
    db.execute(
        "INSERT INTO daily_summary (subject, date, steps, total_kcal, workout_minutes, auto_activity_minutes, exercise_minutes, workout_count, auto_activity_count) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
        ("chicho", "2026-05-19", 10, 1000.0, 30, 40, 70, 1, 2),
    )
    row = db.execute(f"SELECT {', '.join(server.SUMMARY_COLUMNS)} FROM daily_summary WHERE subject='chicho'").fetchone()
    d = server._summary_dict(row)
    assert d["steps"] == 10
    assert d["total_kcal"] == 1000.0
    assert d["workout_minutes"] == 30
    assert d["auto_activity_minutes"] == 40

exec
/bin/bash -lc "sed -n '1,220p' server.py" in /home/ubuntu/health-bridge
 succeeded in 0ms:
"""Health Bridge — private Health Connect ingestion API for Pipo.

Receives Pixel Health Connect batches over Tailscale, stores raw records
idempotently, and rebuilds local-day summaries per subject.

Design goals:
- raw_data preserves source records with provenance
- daily_summary is derived, idempotent, dashboard-friendly
- /debug/day explains every number by source/classification
"""
import hashlib
import json
import os
import sqlite3
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from zoneinfo import ZoneInfo

from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
import uvicorn

DB_PATH = os.path.expanduser("~/health-bridge/health.db")
AUTH_TOKEN=os.env...EN", "changeme")
DEFAULT_TIMEZONE = os.environ.get("HEALTH_BRIDGE_TIMEZONE", "America/Argentina/Buenos_Aires")
DEFAULT_SUBJECT = os.environ.get("HEALTH_BRIDGE_DEFAULT_SUBJECT", "chicho")

SUMMARY_COLUMNS = [
    "subject", "date", "steps", "distance_m", "floors", "active_minutes",
    "exercise_minutes", "workout_minutes", "auto_activity_minutes",
    "calories", "active_kcal", "total_kcal", "nutrition_kcal",
    "heart_rate_avg", "heart_rate_min", "heart_rate_max", "sleep_minutes",
    "workouts", "workout_count", "auto_activity_count", "weight_kg",
    "body_fat_percentage", "height_m", "protein_g", "carbs_g", "fat_g",
    "hydration_ml", "updated_at",
]

SOURCE_RULES = {
    "com.hevy": {"class": "explicit_workout", "label": "Hevy", "trust": "high"},
    "com.google.android.apps.fitness": {"class": "auto_activity", "label": "Google Fit", "trust": "medium"},
    "com.fitbit.FitbitMobile": {"class": "wearable_activity", "label": "Fitbit", "trust": "high"},
    "health_connect": {"class": "legacy_unknown", "label": "Health Connect legacy", "trust": "low"},
}
EXERCISE_TYPE_STRENGTH = {"70"}

def init_db():
    conn = sqlite3.connect(DB_PATH)
    conn.execute("PRAGMA journal_mode=WAL")
    conn.executescript("""
        CREATE TABLE IF NOT EXISTS raw_data (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            received_at TEXT NOT NULL DEFAULT (datetime('now')),
            subject TEXT NOT NULL DEFAULT 'chicho',
            data_type TEXT NOT NULL,
            record_key TEXT,
            local_date TEXT,
            source TEXT,
            data_json TEXT NOT NULL
        );
        CREATE INDEX IF NOT EXISTS idx_raw_data_type ON raw_data(data_type);
        CREATE INDEX IF NOT EXISTS idx_raw_data_received ON raw_data(received_at);

        CREATE TABLE IF NOT EXISTS daily_summary (
            subject TEXT NOT NULL DEFAULT 'chicho',
            date TEXT NOT NULL,
            steps INTEGER DEFAULT 0,
            distance_m REAL DEFAULT 0,
            floors REAL DEFAULT 0,
            active_minutes INTEGER DEFAULT 0,
            exercise_minutes INTEGER DEFAULT 0,
            workout_minutes INTEGER DEFAULT 0,
            auto_activity_minutes INTEGER DEFAULT 0,
            calories REAL DEFAULT 0,
            active_kcal REAL DEFAULT 0,
            total_kcal REAL DEFAULT 0,
            nutrition_kcal REAL DEFAULT 0,
            heart_rate_avg REAL,
            heart_rate_min REAL,
            heart_rate_max REAL,
            sleep_minutes INTEGER DEFAULT 0,
            workouts INTEGER DEFAULT 0,
            workout_count INTEGER DEFAULT 0,
            auto_activity_count INTEGER DEFAULT 0,
            weight_kg REAL,
            body_fat_percentage REAL,
            height_m REAL,
            protein_g REAL DEFAULT 0,
            carbs_g REAL DEFAULT 0,
            fat_g REAL DEFAULT 0,
            hydration_ml REAL DEFAULT 0,
            updated_at TEXT NOT NULL DEFAULT (datetime('now')),
            PRIMARY KEY (subject, date)
        );
    """)
    _migrate_schema(conn)
    conn.commit()
    return conn

def _table_columns(conn: sqlite3.Connection, table: str) -> dict:
    return {r[1]: r for r in conn.execute(f"PRAGMA table_info({table})").fetchall()}

def _migrate_schema(conn: sqlite3.Connection):
    raw_cols = _table_columns(conn, "raw_data")
    for col, ddl in {
        "subject": "ALTER TABLE raw_data ADD COLUMN subject TEXT NOT NULL DEFAULT 'chicho'",
        "record_key": "ALTER TABLE raw_data ADD COLUMN record_key TEXT",
        "local_date": "ALTER TABLE raw_data ADD COLUMN local_date TEXT",
        "source": "ALTER TABLE raw_data ADD COLUMN source TEXT",
    }.items():
        if col not in raw_cols:
            conn.execute(ddl)

    summary_cols = _table_columns(conn, "daily_summary")
    if "subject" not in summary_cols or not _daily_summary_has_subject_pk(conn):
        conn.execute("ALTER TABLE daily_summary RENAME TO daily_summary_legacy")
        _create_daily_summary_table(conn)
        legacy_cols = _table_columns(conn, "daily_summary_legacy")
        select_exprs = []
        for col in SUMMARY_COLUMNS:
            if col in legacy_cols:
                select_exprs.append(col)
            elif col == "subject":
                select_exprs.append("'chicho'")
            elif col == "updated_at":
                select_exprs.append("datetime('now')")
            else:
                select_exprs.append("0")
        conn.execute(
            f"INSERT OR REPLACE INTO daily_summary ({', '.join(SUMMARY_COLUMNS)}) "
            f"SELECT {', '.join(select_exprs)} FROM daily_summary_legacy"
        )
        conn.execute("DROP TABLE daily_summary_legacy")
    else:
        summary_cols = _table_columns(conn, "daily_summary")
        for col, ddl in {
            "distance_m": "ALTER TABLE daily_summary ADD COLUMN distance_m REAL DEFAULT 0",
            "floors": "ALTER TABLE daily_summary ADD COLUMN floors REAL DEFAULT 0",
            "exercise_minutes": "ALTER TABLE daily_summary ADD COLUMN exercise_minutes INTEGER DEFAULT 0",
            "workout_minutes": "ALTER TABLE daily_summary ADD COLUMN workout_minutes INTEGER DEFAULT 0",
            "auto_activity_minutes": "ALTER TABLE daily_summary ADD COLUMN auto_activity_minutes INTEGER DEFAULT 0",
            "active_kcal": "ALTER TABLE daily_summary ADD COLUMN active_kcal REAL DEFAULT 0",
            "total_kcal": "ALTER TABLE daily_summary ADD COLUMN total_kcal REAL DEFAULT 0",
            "nutrition_kcal": "ALTER TABLE daily_summary ADD COLUMN nutrition_kcal REAL DEFAULT 0",
            "workouts": "ALTER TABLE daily_summary ADD COLUMN workouts INTEGER DEFAULT 0",
            "workout_count": "ALTER TABLE daily_summary ADD COLUMN workout_count INTEGER DEFAULT 0",
            "auto_activity_count": "ALTER TABLE daily_summary ADD COLUMN auto_activity_count INTEGER DEFAULT 0",
            "body_fat_percentage": "ALTER TABLE daily_summary ADD COLUMN body_fat_percentage REAL",
            "height_m": "ALTER TABLE daily_summary ADD COLUMN height_m REAL",
            "protein_g": "ALTER TABLE daily_summary ADD COLUMN protein_g REAL DEFAULT 0",
            "carbs_g": "ALTER TABLE daily_summary ADD COLUMN carbs_g REAL DEFAULT 0",
            "fat_g": "ALTER TABLE daily_summary ADD COLUMN fat_g REAL DEFAULT 0",
            "hydration_ml": "ALTER TABLE daily_summary ADD COLUMN hydration_ml REAL DEFAULT 0",
        }.items():
            if col not in summary_cols:
                conn.execute(ddl)

    index_sql = conn.execute(
        "SELECT sql FROM sqlite_master WHERE type = 'index' AND name = 'idx_raw_data_record_key'"
    ).fetchone()
    if index_sql and "WHERE" in (index_sql[0] or "").upper():
        conn.execute("DROP INDEX idx_raw_data_record_key")

    _backfill_legacy_raw_metadata(conn)
    conn.execute("CREATE INDEX IF NOT EXISTS idx_raw_data_subject_date ON raw_data(subject, local_date)")
    conn.execute("CREATE INDEX IF NOT EXISTS idx_daily_summary_subject_date ON daily_summary(subject, date)")
    conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_raw_data_record_key ON raw_data(record_key)")

def _create_daily_summary_table(conn: sqlite3.Connection):
    conn.executescript("""
        CREATE TABLE daily_summary (
            subject TEXT NOT NULL DEFAULT 'chicho',
            date TEXT NOT NULL,
            steps INTEGER DEFAULT 0,
            distance_m REAL DEFAULT 0,
            floors REAL DEFAULT 0,
            active_minutes INTEGER DEFAULT 0,
            exercise_minutes INTEGER DEFAULT 0,
            workout_minutes INTEGER DEFAULT 0,
            auto_activity_minutes INTEGER DEFAULT 0,
            calories REAL DEFAULT 0,
            active_kcal REAL DEFAULT 0,
            total_kcal REAL DEFAULT 0,
            nutrition_kcal REAL DEFAULT 0,
            heart_rate_avg REAL,
            heart_rate_min REAL,
            heart_rate_max REAL,
            sleep_minutes INTEGER DEFAULT 0,
            workouts INTEGER DEFAULT 0,
            workout_count INTEGER DEFAULT 0,
            auto_activity_count INTEGER DEFAULT 0,
            weight_kg REAL,
            body_fat_percentage REAL,
            height_m REAL,
            protein_g REAL DEFAULT 0,
            carbs_g REAL DEFAULT 0,
            fat_g REAL DEFAULT 0,
            hydration_ml REAL DEFAULT 0,
            updated_at TEXT NOT NULL DEFAULT (datetime('now')),
            PRIMARY KEY (subject, date)
        );
    """)

def _daily_summary_has_subject_pk(conn: sqlite3.Connection) -> bool:
    cols = conn.execute("PRAGMA table_info(daily_summary)").fetchall()
    pk_cols = [r[1] for r in sorted([r for r in cols if r[5]], key=lambda r: r[5])]
    return pk_cols == ["subject", "date"]

def _backfill_legacy_raw_metadata(conn: sqlite3.Connection):
    rows = conn.execute(
        "SELECT id, data_type, data_json FROM raw_data WHERE record_key IS NULL OR local_date IS NULL OR source IS NULL OR subject IS NULL"
    ).fetchall()
    for row_id, data_type, data_json in rows:
        try:

exec
/bin/bash -lc "sed -n '300,620p' server.py" in /home/ubuntu/health-bridge
 succeeded in 0ms:
                subject = excluded.subject,
                data_type = excluded.data_type,
                local_date = excluded.local_date,
                source = excluded.source,
                data_json = excluded.data_json,
                received_at = datetime('now')
            """,
            (
                normalized["subject"], normalized["data_type"], normalized["record_key"],
                normalized["local_date"], normalized["source"],
                json.dumps(normalized["record"], separators=(",", ":"), sort_keys=True),
            ),
        )
        if normalized["local_date"]:
            affected.add((normalized["subject"], normalized["local_date"]))
        count += 1
    db.commit()

    rebuilt = []
    for subj, local_date in sorted(affected):
        _rebuild_daily_summary(db, subj, local_date)
        rebuilt.append({"subject": subj, "date": local_date})
    return {"status": "ok", "records": count, "dates_rebuilt": rebuilt}

@app.get("/data")
async def get_data(request: Request, data_type: str = None, subject: str = None, limit: int = 100):
    await check_token(request)
    db: sqlite3.Connection = app.state.db
    subject = subject or DEFAULT_SUBJECT
    if data_type:
        rows = db.execute(
            "SELECT data_json, received_at FROM raw_data WHERE subject = ? AND data_type = ? ORDER BY id DESC LIMIT ?",
            (subject, data_type, limit),
        ).fetchall()
    else:
        rows = db.execute(
            "SELECT data_json, received_at FROM raw_data WHERE subject = ? ORDER BY id DESC LIMIT ?",
            (subject, limit),
        ).fetchall()
    return {"subject": subject, "count": len(rows), "data": [{"record": json.loads(r[0]), "received_at": r[1]} for r in rows]}

@app.get("/summary")
async def get_summary(request: Request, days: int = 7, subject: str = None):
    await check_token(request)
    db: sqlite3.Connection = app.state.db
    subject = subject or DEFAULT_SUBJECT
    rows = db.execute(
        f"SELECT {', '.join(SUMMARY_COLUMNS)} FROM daily_summary WHERE subject = ? ORDER BY date DESC LIMIT ?",
        (subject, days),
    ).fetchall()
    return {"subject": subject, "days": [_summary_dict(r) for r in rows]}

@app.get("/overview")
async def get_overview(request: Request, days: int = 7, subject: str = None):
    await check_token(request)
    db: sqlite3.Connection = app.state.db
    subject = subject or DEFAULT_SUBJECT
    rows = db.execute(
        f"SELECT {', '.join(SUMMARY_COLUMNS)} FROM daily_summary WHERE subject = ? ORDER BY date DESC LIMIT ?",
        (subject, days),
    ).fetchall()
    summaries = [_summary_dict(r) for r in rows]
    freshness = db.execute(
        """
        SELECT MAX(received_at), COUNT(*), COUNT(DISTINCT local_date), COUNT(DISTINCT source)
        FROM raw_data WHERE subject = ?
        """,
        (subject,),
    ).fetchone()
    return {
        "subject": subject,
        "timezone": DEFAULT_TIMEZONE,
        "freshness": {
            "last_sync_at": freshness[0],
            "raw_records": freshness[1],
            "covered_days": freshness[2],
            "sources": freshness[3],
        },
        "today": summaries[0] if summaries else None,
        "days": summaries,
        "legend": {
            "workout_minutes": "explicit workout sessions, primarily Hevy/strength",
            "auto_activity_minutes": "auto-detected activity, usually Google Fit walking/movement",
            "exercise_minutes": "workout_minutes + auto_activity_minutes",
            "calories": "burned calories only; nutrition_kcal is intake",
        },
    }

@app.get("/debug/day")
async def debug_day(request: Request, date: str, subject: str = None):
    await check_token(request)
    db: sqlite3.Connection = app.state.db
    subject = subject or DEFAULT_SUBJECT
    return _debug_day(db, subject, date)

@app.post("/rebuild-summary")
async def rebuild_summary(request: Request, subject: str = None):
    await check_token(request)
    db: sqlite3.Connection = app.state.db
    params = []
    where = "WHERE local_date IS NOT NULL"
    if subject:
        where += " AND subject = ?"
        params.append(subject)
    pairs = db.execute(
        f"SELECT DISTINCT subject, local_date FROM raw_data {where} ORDER BY subject, local_date",
        params,
    ).fetchall()
    rebuilt = []
    for subj, local_date in pairs:
        _rebuild_daily_summary(db, subj, local_date)
        rebuilt.append({"subject": subj, "date": local_date})
    return {"status": "ok", "dates_rebuilt": rebuilt}

def _summary_dict(row) -> dict:
    return dict(zip(SUMMARY_COLUMNS, row))

def _normalize_record(record: dict, client_tz: str, default_subject: str = DEFAULT_SUBJECT) -> dict:
    data_type = record.get("type", "unknown")
    subject = record.get("subject") or default_subject or DEFAULT_SUBJECT
    local_date = record.get("local_date") or record.get("date") or _local_date_for_record(record, client_tz)
    normalized = dict(record)
    normalized["subject"] = subject
    if local_date:
        normalized["local_date"] = local_date
    source = normalized.get("source") or normalized.get("app") or normalized.get("package_name") or "health_connect"
    normalized["source"] = source
    normalized["source_class"] = classify_record(normalized)["class"]
    record_key = normalized.get("record_key") or normalized.get("id")
    if record_key and not str(record_key).startswith(f"{subject}:"):
        record_key = f"{subject}:{record_key}"
    if not record_key:
        record_key = _stable_record_key(subject, data_type, normalized, local_date)
    normalized["record_key"] = record_key
    normalized["fingerprint"] = canonical_fingerprint(normalized)
    return {
        "subject": subject,
        "data_type": data_type,
        "record_key": record_key,
        "local_date": local_date,
        "source": source,
        "record": normalized,
    }

def _local_date_for_record(record: dict, client_tz: str) -> str | None:
    zone = _zone(client_tz)
    for field in ("time", "start", "period_start"):
        value = record.get(field)
        if value:
            try:
                return _parse_instant(value).astimezone(zone).date().isoformat()
            except ValueError:
                continue
    return None

def _stable_record_key(subject: str, data_type: str, record: dict, local_date: str | None) -> str:
    if data_type in {"daily_aggregate", "aggregated", "heart_rate"} and local_date:
        return f"{subject}:{data_type}:{local_date}"
    digest = hashlib.sha256(json.dumps(_identity(record, subject, data_type, local_date), sort_keys=True, separators=(",", ":")).encode()).hexdigest()[:24]
    return f"{subject}:{data_type}:{digest}"

def _identity(record: dict, subject: str, data_type: str, local_date: str | None) -> dict:
    identity = {
        "subject": subject,
        "type": data_type,
        "local_date": local_date,
        "time": record.get("time"),
        "start": record.get("start"),
        "end": record.get("end"),
        "period_start": record.get("period_start"),
        "period_end": record.get("period_end"),
        "source": record.get("source"),
        "value": record.get("value") or record.get("minutes") or record.get("steps"),
    }
    return identity if any(v is not None for v in identity.values()) else {"subject": subject, **record}

def _zone(name: str) -> ZoneInfo:
    try:
        return ZoneInfo(name)
    except Exception:
        return ZoneInfo(DEFAULT_TIMEZONE)

def _parse_instant(value: str) -> datetime:
    return datetime.fromisoformat(value.replace("Z", "+00:00"))

def _latest(records: list[dict], field: str = "time") -> dict | None:
    candidates = [r for r in records if r.get(field)]
    if not candidates:
        return records[-1] if records else None
    return max(candidates, key=lambda r: r.get(field))

def classify_record(record: dict) -> dict:
    source = record.get("source") or "health_connect"
    t = record.get("type") or "unknown"
    rule = SOURCE_RULES.get(source, {"class": "unknown", "label": source, "trust": "unknown"}).copy()
    if t == "exercise":
        title = (record.get("title") or "").lower()
        exercise_type = str(record.get("exercise_type") or "")
        if source == "com.hevy" or exercise_type in EXERCISE_TYPE_STRENGTH or "min-max" in title:
            rule.update({"class": "explicit_workout", "trust": "high"})
        elif source == "com.google.android.apps.fitness":
            rule.update({"class": "auto_activity", "trust": "medium"})
    return rule

def canonical_fingerprint(record: dict) -> str:
    t = record.get("type")
    if t in {"exercise", "sleep", "nutrition", "hydration"}:
        identity = {
            "subject": record.get("subject"),
            "type": t,
            "local_date": record.get("local_date"),
            "start": record.get("start"),
            "end": record.get("end"),
            "minutes": int(record.get("minutes") or 0) if record.get("minutes") is not None else None,
            "exercise_type": str(record.get("exercise_type")) if record.get("exercise_type") is not None else None,
            "title": record.get("title"),
            "energy_kcal": record.get("energy_kcal"),
            "volume_ml": record.get("volume_ml"),
        }
    elif t in {"daily_aggregate", "aggregated", "heart_rate"}:
        identity = {"subject": record.get("subject"), "type": t, "local_date": record.get("local_date")}
    else:
        identity = _identity(record, record.get("subject"), t, record.get("local_date"))
    return hashlib.sha256(json.dumps(identity, sort_keys=True, separators=(",", ":")).encode()).hexdigest()[:24]

def _is_stale_wide_aggregate(record: dict) -> bool:
    """Ignore legacy aggregates that cover multi-day windows but were stored as one local day."""
    if record.get("type") not in {"daily_aggregate", "aggregated"}:
        return False
    start = record.get("period_start")
    end = record.get("period_end")
    if not start or not end:
        return False
    try:
        start_dt = _parse_instant(start)
        end_dt = _parse_instant(end)
    except Exception:
        return False
    return (end_dt - start_dt).total_seconds() > 36 * 3600

def dedupe_records(records: list[dict]) -> tuple[list[dict], list[dict]]:
    """Deduplicate same semantic records, preferring named app sources over legacy health_connect."""
    ranked = []
    for idx, record in enumerate(records):
        classification = classify_record(record)
        fingerprint = record.get("fingerprint") or canonical_fingerprint(record)
        source = record.get("source") or "health_connect"
        trust_rank = {"high": 3, "medium": 2, "low": 1, "unknown": 0}.get(classification.get("trust"), 0)
        source_rank = 0 if source == "health_connect" else 1
        ranked.append((fingerprint, trust_rank, source_rank, idx, record, classification))

    winners = {}
    ignored = []
    for fingerprint, trust_rank, source_rank, idx, record, classification in ranked:
        candidate = (trust_rank, source_rank, idx, record, classification)
        current = winners.get(fingerprint)
        if current is None or candidate[:3] > current[:3]:
            if current is not None:
                ignored.append(_debug_record(current[3], current[4], reason="duplicate_lower_priority"))
            winners[fingerprint] = candidate
        else:
            ignored.append(_debug_record(record, classification, reason="duplicate_lower_priority"))
    kept = [v[3] for v in sorted(winners.values(), key=lambda x: x[2])]
    return kept, ignored

def _records_for_day(db: sqlite3.Connection, subject: str, local_date: str) -> list[dict]:
    rows = db.execute(
        "SELECT data_json FROM raw_data WHERE subject = ? AND local_date = ? ORDER BY received_at ASC, id ASC",
        (subject, local_date),
    ).fetchall()
    return [json.loads(row[0]) for row in rows]

def _debug_record(record: dict, classification: dict | None = None, reason: str | None = None) -> dict:
    classification = classification or classify_record(record)
    out = {
        "type": record.get("type"),
        "source": record.get("source"),
        "source_label": classification.get("label"),
        "classification": classification.get("class"),
        "trust": classification.get("trust"),
        "record_key": record.get("record_key"),
        "fingerprint": record.get("fingerprint") or canonical_fingerprint(record),
        "local_date": record.get("local_date"),
    }
    for key in ("minutes", "steps", "distance_m", "active_kcal", "total_kcal", "energy_kcal", "volume_ml", "start", "end", "time", "exercise_type", "title"):
        if record.get(key) is not None:
            out[key] = record.get(key)
    if reason:
        out["reason"] = reason
    return out

def compute_daily_summary(records: list[dict]) -> tuple[dict, dict]:
    records, ignored = dedupe_records(records)
    summary = {
        "steps": 0, "distance_m": 0.0, "floors": 0.0, "active_minutes": 0,
        "exercise_minutes": 0, "workout_minutes": 0, "auto_activity_minutes": 0,
        "calories": 0.0, "active_kcal": 0.0, "total_kcal": 0.0, "nutrition_kcal": 0.0,
        "heart_rate_avg": None, "heart_rate_min": None, "heart_rate_max": None,
        "sleep_minutes": 0, "workouts": 0, "workout_count": 0, "auto_activity_count": 0,
        "weight_kg": None, "body_fat_percentage": None, "height_m": None,
        "protein_g": 0.0, "carbs_g": 0.0, "fat_g": 0.0, "hydration_ml": 0.0,

codex
The endpoint reads are now consistently using `SUMMARY_COLUMNS`; I do not see any remaining `SELECT * FROM daily_summary` path. I’m doing one quick check of the exact regression setup before signing off.
exec
/bin/bash -lc 'pytest -q test_server.py' in /home/ubuntu/health-bridge
 succeeded in 737ms:
.......                                                                  [100%]
7 passed in 0.35s

codex
SIGN OFF FOR DEPLOY

Reviewed only `server.py` / `test_server.py` compatibility fix surface. `daily_summary` endpoint reads now select explicit `SUMMARY_COLUMNS`, no remaining `SELECT * FROM daily_summary` paths found, and the regression test suite passes: `7 passed`.
tokens used
31,588
SIGN OFF FOR DEPLOY

Reviewed only `server.py` / `test_server.py` compatibility fix surface. `daily_summary` endpoint reads now select explicit `SUMMARY_COLUMNS`, no remaining `SELECT * FROM daily_summary` paths found, and the regression test suite passes: `7 passed`.


Codex micro-review también firmó. Cierro estado.

todo

Pedir Codex review/sign-off económico antes de dejarlo como deploy final


Inspeccionar backend actual y datos raw para definir reglas de provenance/dedup

Implementar classification/provenance, summary limpio y /debug/day

Agregar tests/fixtures para idempotencia, dedupe, exercise split y nutrition separation

Correr checks, rebuild summary y verificar días reales


Listo.

Implementado y deployado backend sólido para dashboard futuro.

Cambios principales:
- `workout_minutes` separado de `auto_activity_minutes`
- Hevy = workout explícito
- Google Fit = actividad automática
- `exercise_minutes = workout + auto`
- `nutrition_kcal` separado de calorías quemadas
- dedupe por fingerprint canónico
- legacy `health_connect` duplicado filtrado
- aggregate viejo de 7 días ignorado
- `/debug/day?date=YYYY-MM-DD&subject=chicho` para auditar cualquier número
- endpoints usan columnas explícitas, no `SELECT *`, para migraciones SQLite seguras

Tests:
```txt
7 passed
```

Codex:
```txt
SIGN OFF FOR DEPLOY
```
usando `gpt-5.5` con `reasoning low`.

Producción actual:

```txt
2026-05-19 steps 11540 workout 117 auto 107 exercise 224 kcal 1544
2026-05-18 steps  9527 workout   0 auto 154 exercise 154 kcal 1905
2026-05-17 steps 21007 workout   0 auto 158 exercise 158 kcal 1905
2026-05-16 steps 17579 workout   0 auto 185 exercise 185 kcal 1905
2026-05-15 steps 13013 workout  99 auto 180 exercise 279 kcal 1905
2026-05-14 steps 10236 workout   0 auto 114 exercise 114 kcal 1905
2026-05-13 steps 11169 workout 103 auto  75 exercise 178 kcal 1905
2026-05-12 steps     0 workout  93 auto 140 exercise 233 kcal 0
2026-05-11 steps     0 workout   0 auto  27 exercise 27  kcal 0
```

Base mucho más sólida ahora. Next logical step después: endpoint/dashboard “daily card” más humano, no más backend core.