# Plan de Refonte : Collecte des Données Brutes Health Connect ## Objectif Refactoriser l'ensemble du système HabitForge pour : 1. **Collecter toutes les données brutes** de Health Connect (chaque record individuel avec son timestamp précis) 2. **Stocker les données brutes** dans la base de données 3. **Interpréter les données côté web** (backend/frontend) selon les besoins d'affichage ## Problème Actuel ### Architecture Actuelle (Problématique) ``` Health Connect API ↓ Android App (readDailyMetrics) ──→ AGRÈGE par jour ──→ DailyMetrics payload ↓ Backend /api/health-connect/sync ──→ Stocke dans health_connect_daily_metrics (1 row/jour) ↓ Frontend ──→ Affiche données agrégées ``` ### Symptômes Observés - La tuile "Steps" affiche 0 pour aujourd'hui (l'agrégation ne capture pas les nouveaux records) - L'historique des steps (Nov 29 - Jan 8) montre des bonnes valeurs (agrégation passée) - Les données brutes affichées s'arrêtent au 29 décembre (décalage avec l'agrégation) - La base de données ne contient que des agrégations, pas les records individuels ## Nouvelle Architecture Proposée ``` Health Connect API ↓ Android App (readRawRecords) ──→ Collecte TOUS les records bruts avec timestamps ↓ Backend /api/health-connect/sync-raw ──→ Stocke dans NOUVELLES tables de données brutes ↓ Backend (Aggregation Service) ──→ Calcule les agrégations à la demande ou en cache ↓ Frontend ──→ Affiche données selon la granularité demandée ``` --- ## Phase 1 : Nouveaux Modèles de Base de Données ### 1.1 Nouvelles Tables pour Données Brutes ```python # backend/models/health_connect_raw.py class HealthConnectStepsRecord(Base): """Individual step records from Health Connect""" __tablename__ = "hc_raw_steps" id = Column(Integer, primary_key=True, index=True) user_id = Column(Integer, ForeignKey("users.id"), index=True) # Record identifiers record_id = Column(String(100), unique=True) # Health Connect UID data_source = Column(String(200)) # Package name data_source_name = Column(String(100)) # Timestamps start_time = Column(DateTime, index=True) end_time = Column(DateTime) recorded_at = Column(DateTime) # When it was recorded on device synced_at = Column(DateTime, default=datetime.utcnow) # Data count = Column(Integer) # Metadata sync_id = Column(Integer, ForeignKey("health_connect_sync_logs.id"), nullable=True) class HealthConnectHeartRateRecord(Base): """Individual heart rate records from Health Connect""" __tablename__ = "hc_raw_heart_rate" id = Column(Integer, primary_key=True, index=True) user_id = Column(Integer, ForeignKey("users.id"), index=True) record_id = Column(String(100), unique=True) data_source = Column(String(200)) start_time = Column(DateTime, index=True) end_time = Column(DateTime) synced_at = Column(DateTime, default=datetime.utcnow) sync_id = Column(Integer, ForeignKey("health_connect_sync_logs.id"), nullable=True) class HealthConnectHeartRateSample(Base): """Individual heart rate samples (part of a HeartRateRecord)""" __tablename__ = "hc_raw_heart_rate_samples" id = Column(Integer, primary_key=True, index=True) record_id = Column(Integer, ForeignKey("hc_raw_heart_rate.id"), index=True) time = Column(DateTime) bpm = Column(Integer) class HealthConnectSleepSession(Base): """Sleep sessions from Health Connect""" __tablename__ = "hc_raw_sleep_sessions" id = Column(Integer, primary_key=True, index=True) user_id = Column(Integer, ForeignKey("users.id"), index=True) record_id = Column(String(100), unique=True) data_source = Column(String(200)) start_time = Column(DateTime, index=True) end_time = Column(DateTime) synced_at = Column(DateTime, default=datetime.utcnow) title = Column(String(200), nullable=True) notes = Column(Text, nullable=True) sync_id = Column(Integer, ForeignKey("health_connect_sync_logs.id"), nullable=True) class HealthConnectSleepStage(Base): """Sleep stages within a session""" __tablename__ = "hc_raw_sleep_stages" id = Column(Integer, primary_key=True, index=True) session_id = Column(Integer, ForeignKey("hc_raw_sleep_sessions.id"), index=True) start_time = Column(DateTime) end_time = Column(DateTime) stage_type = Column(Integer) # AWAKE, LIGHT, DEEP, REM, etc. stage_name = Column(String(50)) class HealthConnectDistanceRecord(Base): """Distance records from Health Connect""" __tablename__ = "hc_raw_distance" id = Column(Integer, primary_key=True, index=True) user_id = Column(Integer, ForeignKey("users.id"), index=True) record_id = Column(String(100), unique=True) data_source = Column(String(200)) start_time = Column(DateTime, index=True) end_time = Column(DateTime) synced_at = Column(DateTime, default=datetime.utcnow) distance_meters = Column(Float) sync_id = Column(Integer, ForeignKey("health_connect_sync_logs.id"), nullable=True) class HealthConnectCaloriesRecord(Base): """Calories burned records from Health Connect""" __tablename__ = "hc_raw_calories" id = Column(Integer, primary_key=True, index=True) user_id = Column(Integer, ForeignKey("users.id"), index=True) record_id = Column(String(100), unique=True) data_source = Column(String(200)) start_time = Column(DateTime, index=True) end_time = Column(DateTime) synced_at = Column(DateTime, default=datetime.utcnow) calories = Column(Float) record_type = Column(String(50)) # "active" or "total" sync_id = Column(Integer, ForeignKey("health_connect_sync_logs.id"), nullable=True) class HealthConnectOxygenSaturationRecord(Base): """SpO2 records from Health Connect""" __tablename__ = "hc_raw_spo2" id = Column(Integer, primary_key=True, index=True) user_id = Column(Integer, ForeignKey("users.id"), index=True) record_id = Column(String(100), unique=True) data_source = Column(String(200)) time = Column(DateTime, index=True) synced_at = Column(DateTime, default=datetime.utcnow) percentage = Column(Float) sync_id = Column(Integer, ForeignKey("health_connect_sync_logs.id"), nullable=True) class HealthConnectWeightRecord(Base): """Weight records from Health Connect""" __tablename__ = "hc_raw_weight" id = Column(Integer, primary_key=True, index=True) user_id = Column(Integer, ForeignKey("users.id"), index=True) record_id = Column(String(100), unique=True) data_source = Column(String(200)) time = Column(DateTime, index=True) synced_at = Column(DateTime, default=datetime.utcnow) weight_kg = Column(Float) sync_id = Column(Integer, ForeignKey("health_connect_sync_logs.id"), nullable=True) class HealthConnectRestingHeartRateRecord(Base): """Resting heart rate records""" __tablename__ = "hc_raw_resting_hr" id = Column(Integer, primary_key=True, index=True) user_id = Column(Integer, ForeignKey("users.id"), index=True) record_id = Column(String(100), unique=True) data_source = Column(String(200)) time = Column(DateTime, index=True) synced_at = Column(DateTime, default=datetime.utcnow) bpm = Column(Integer) sync_id = Column(Integer, ForeignKey("health_connect_sync_logs.id"), nullable=True) ``` --- ## Phase 2 : Modifications Android ### 2.1 Nouvelles Méthodes de Lecture des Données Brutes ```kotlin // HealthConnectManager.kt - Nouvelles méthodes /** * Read all raw step records with full metadata (not aggregated) */ suspend fun readRawStepsRecords(startDate: LocalDate, endDate: LocalDate): List /** * Read all raw heart rate records with samples */ suspend fun readRawHeartRateRecords(startDate: LocalDate, endDate: LocalDate): List /** * Read all raw sleep sessions with stages */ suspend fun readRawSleepSessions(startDate: LocalDate, endDate: LocalDate): List // ... etc pour chaque type de donnée ``` ### 2.2 Nouvelles Classes de Données ```kotlin // RawDataModels.kt data class RawStepsRecord( val recordId: String, // metadata.id val dataSource: String, // metadata.dataOrigin.packageName val startTime: String, // ISO8601 val endTime: String, val count: Long ) data class RawHeartRateRecord( val recordId: String, val dataSource: String, val startTime: String, val endTime: String, val samples: List ) data class HeartRateSample( val time: String, val bpm: Long ) data class RawSleepSession( val recordId: String, val dataSource: String, val startTime: String, val endTime: String, val title: String?, val notes: String?, val stages: List ) data class SleepStage( val startTime: String, val endTime: String, val stageType: Int, val stageName: String ) // ... etc ``` ### 2.3 Nouveau Payload de Synchronisation ```kotlin // ApiModels.kt data class RawDataSyncRequest( val device_id: String, val sync_timestamp: String, val steps_records: List, val heart_rate_records: List, val sleep_sessions: List, val distance_records: List, val calories_records: List, val spo2_records: List, val weight_records: List, val resting_hr_records: List, val exercise_sessions: List ) ``` --- ## Phase 3 : Nouveau Endpoint Backend ### 3.1 Endpoint /api/health-connect/sync-raw ```python # backend/api/v1/health_connect_raw.py @router.post("/sync-raw", response_model=RawSyncResponse) async def receive_raw_health_data( request: RawDataSyncRequest, db: Session = Depends(get_db), current_user: User = Depends(auth.get_current_user) ): """ Receive raw health records from Android companion app. Each record is stored individually with its original timestamp. """ # Upsert each record type using record_id as unique key # ... ``` --- ## Phase 4 : Service d'Agrégation Backend ### 4.1 AggregationService ```python # backend/services/aggregation_service.py class AggregationService: """ Computes aggregated metrics from raw data on-demand. Can optionally cache results in the existing daily_health_metrics tables. """ def get_daily_steps(self, user_id: int, date: date) -> int: """Sum all step records for a given day""" def get_daily_heart_rate_stats(self, user_id: int, date: date) -> HeartRateStats: """Calculate min, max, avg HR for a day from all samples""" def get_weekly_stats(self, user_id: int, ref_date: date) -> WeeklyStats: """Aggregate stats for a week""" def get_monthly_stats(self, user_id: int, ref_date: date) -> MonthlyStats: """Aggregate stats for a month""" def get_metric_history( self, user_id: int, metric_type: str, start_date: date, end_date: date, granularity: str = "day" # "raw", "hour", "day", "week", "month" ) -> List[MetricDataPoint]: """Get historical data at specified granularity""" ``` --- ## Phase 5 : Modifications Frontend ### 5.1 API Client Updates ```javascript // frontend/js/api.js async function fetchRawMetricData(metricType, startDate, endDate, granularity = 'day') { // Appelle le nouveau endpoint avec paramètres de granularité } ``` ### 5.2 Composants de Visualisation Les composants existants peuvent continuer à fonctionner car le backend retournera des données agrégées par défaut. L'option de voir les données brutes sera ajoutée pour les vues détaillées. --- ## Plan d'Implémentation - **ÉTAT D'AVANCEMENT** ### ✅ Étape 1 : Database (Backend) - COMPLÉTÉ - [x] Créer le fichier `backend/models/health_connect_raw.py` (15 tables brutes) - [x] Ajouter migration Alembic `f5g6h7i8j9k0_add_health_connect_raw_tables.py` - [x] Mettre à jour `backend/models/__init__.py` pour inclure les nouveaux modèles ### ✅ Étape 2 : Backend API - COMPLÉTÉ - [x] Créer `backend/api/v1/health_connect_raw.py` avec endpoint `/sync-raw` - [x] Créer les schémas Pydantic pour les 15 types de payloads bruts - [x] Ajouter endpoints de lecture des données brutes (`/raw/steps`, `/raw/heart-rate`, `/raw/stats`) - [x] Ajouter la route au router principal `backend/main.py` ### ✅ Étape 3 : Backend Aggregation Service - COMPLÉTÉ - [x] Créer `backend/services/aggregation_service.py` - [x] Implémenter les méthodes d'agrégation quotidiennes - [x] Implémenter `get_weekly_summary` et `get_monthly_summary` - [x] Implémenter `get_metric_history` pour les graphiques ### ✅ Étape 4 : Android - Reading Raw Data - COMPLÉTÉ - [x] Créer `RawDataModels.kt` avec toutes les classes de données brutes - [x] Créer `HealthConnectRawReader.kt` avec les méthodes `readRaw*` pour 13 types de données - [x] Implémentation avec pagination complète ### ✅ Étape 5 : Android - Sync Service - COMPLÉTÉ - [x] Mettre à jour `HabitForgeApi.kt` avec le nouvel endpoint `/sync-raw` - [x] Créer les 15 classes de `*Payload` pour le format API - [x] Créer `RawDataSyncService.kt` - nouveau service de synchronisation ### ⏳ Étape 6 : Testing & Validation - À FAIRE - [ ] Appliquer la migration Alembic sur la base de données - [ ] Recompiler l'application Android - [ ] Tester la synchronisation complète - [ ] Valider que les agrégations sont correctes - [ ] Vérifier la cohérence entre les vues --- ## Fichiers Créés/Modifiés ### Backend (Python) | Fichier | Action | Description | |---------|--------|-------------| | `backend/models/health_connect_raw.py` | ✅ Créé | 15 modèles SQLAlchemy pour données brutes | | `backend/models/__init__.py` | ✅ Modifié | Imports des nouveaux modèles | | `backend/api/v1/health_connect_raw.py` | ✅ Créé | API endpoint /sync-raw + lecture | | `backend/services/aggregation_service.py` | ✅ Créé | Service d'agrégation on-demand | | `backend/main.py` | ✅ Modifié | Ajout du router | | `alembic/env.py` | ✅ Modifié | Import de tous les modèles | | `alembic/versions/f5g6h7i8j9k0_...py` | ✅ Créé | Migration pour 15 nouvelles tables | ### Android (Kotlin) | Fichier | Action | Description | |---------|--------|-------------| | `RawDataModels.kt` | ✅ Créé | Classes de données brutes | | `HealthConnectRawReader.kt` | ✅ Créé | Lecteur de données brutes HC | | `RawDataSyncService.kt` | ✅ Créé | Service de synchronisation raw | | `HabitForgeApi.kt` | ✅ Modifié | Payloads + endpoint raw | --- ## Prochaines Étapes pour Compléter l'Implémentation 1. **Appliquer la migration de base de données**: ```bash # Dans le container Docker ou en local docker-compose exec web alembic upgrade head ``` 2. **Recompiler l'application Android**: ```bash cd android-companion ./gradlew assembleDebug ``` 3. **Tester la synchronisation**: - Installer l'APK sur le téléphone - Se connecter au backend - Déclencher une synchronisation manuelle - Vérifier les logs du backend pour les records reçus 4. **Intégrer le service d'agrégation avec le frontend** (optionnel): - Modifier les endpoints `/api/health/stats` existants pour utiliser `AggregationService` - Le frontend continuera de fonctionner tel quel --- ## Migration des Données Existantes Les données déjà agrégées dans `health_connect_daily_metrics` seront conservées. Le nouveau système fonctionnera en parallèle. Une option de re-synchronisation complète permettra de repeupler les nouvelles tables avec les données brutes. --- ## Questions Résolues 1. **Volume de données** : Sync limité à 30 jours par défaut pour le service raw. 2. **Compatibilité** : L'ancien endpoint `/sync` est conservé pour backward compatibility. 3. **Performance** : Le service d'agrégation calcule à la demande. Un cache peut être ajouté ultérieurement. --- ## Estimation Mise à Jour **Temps effectif : ~4 heures** (implémentation core) **Restant : ~1-2 heures** (testing et validation)