Files
HabitForge/plans/RAW_DATA_REFACTORING_PLAN.md
2026-07-30 23:25:20 -04:00

16 KiB

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

# 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

// HealthConnectManager.kt - Nouvelles méthodes

/**
 * Read all raw step records with full metadata (not aggregated)
 */
suspend fun readRawStepsRecords(startDate: LocalDate, endDate: LocalDate): List<RawStepsRecord>

/**
 * Read all raw heart rate records with samples
 */
suspend fun readRawHeartRateRecords(startDate: LocalDate, endDate: LocalDate): List<RawHeartRateRecord>

/**
 * Read all raw sleep sessions with stages
 */
suspend fun readRawSleepSessions(startDate: LocalDate, endDate: LocalDate): List<RawSleepSession>

// ... etc pour chaque type de donnée

2.2 Nouvelles Classes de Données

// 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<HeartRateSample>
)

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<SleepStage>
)

data class SleepStage(
    val startTime: String,
    val endTime: String,
    val stageType: Int,
    val stageName: String
)

// ... etc

2.3 Nouveau Payload de Synchronisation

// ApiModels.kt

data class RawDataSyncRequest(
    val device_id: String,
    val sync_timestamp: String,
    val steps_records: List<RawStepsPayload>,
    val heart_rate_records: List<RawHeartRatePayload>,
    val sleep_sessions: List<RawSleepPayload>,
    val distance_records: List<RawDistancePayload>,
    val calories_records: List<RawCaloriesPayload>,
    val spo2_records: List<RawSpo2Payload>,
    val weight_records: List<RawWeightPayload>,
    val resting_hr_records: List<RawRestingHRPayload>,
    val exercise_sessions: List<ExerciseSessionPayload>
)

Phase 3 : Nouveau Endpoint Backend

3.1 Endpoint /api/health-connect/sync-raw

# 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

# 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

// 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É

  • Créer le fichier backend/models/health_connect_raw.py (15 tables brutes)
  • Ajouter migration Alembic f5g6h7i8j9k0_add_health_connect_raw_tables.py
  • Mettre à jour backend/models/__init__.py pour inclure les nouveaux modèles

✅ Étape 2 : Backend API - COMPLÉTÉ

  • Créer backend/api/v1/health_connect_raw.py avec endpoint /sync-raw
  • Créer les schémas Pydantic pour les 15 types de payloads bruts
  • Ajouter endpoints de lecture des données brutes (/raw/steps, /raw/heart-rate, /raw/stats)
  • Ajouter la route au router principal backend/main.py

✅ Étape 3 : Backend Aggregation Service - COMPLÉTÉ

  • Créer backend/services/aggregation_service.py
  • Implémenter les méthodes d'agrégation quotidiennes
  • Implémenter get_weekly_summary et get_monthly_summary
  • Implémenter get_metric_history pour les graphiques

✅ Étape 4 : Android - Reading Raw Data - COMPLÉTÉ

  • Créer RawDataModels.kt avec toutes les classes de données brutes
  • Créer HealthConnectRawReader.kt avec les méthodes readRaw* pour 13 types de données
  • Implémentation avec pagination complète

✅ Étape 5 : Android - Sync Service - COMPLÉTÉ

  • Mettre à jour HabitForgeApi.kt avec le nouvel endpoint /sync-raw
  • Créer les 15 classes de *Payload pour le format API
  • 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:

    # Dans le container Docker ou en local
    docker-compose exec web alembic upgrade head
    
  2. Recompiler l'application Android:

    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)