16 KiB
Plan de Refonte : Collecte des Données Brutes Health Connect
Objectif
Refactoriser l'ensemble du système HabitForge pour :
- Collecter toutes les données brutes de Health Connect (chaque record individuel avec son timestamp précis)
- Stocker les données brutes dans la base de données
- 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__.pypour inclure les nouveaux modèles
✅ Étape 2 : Backend API - COMPLÉTÉ
- Créer
backend/api/v1/health_connect_raw.pyavec 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_summaryetget_monthly_summary - Implémenter
get_metric_historypour les graphiques
✅ Étape 4 : Android - Reading Raw Data - COMPLÉTÉ
- Créer
RawDataModels.ktavec toutes les classes de données brutes - Créer
HealthConnectRawReader.ktavec les méthodesreadRaw*pour 13 types de données - Implémentation avec pagination complète
✅ Étape 5 : Android - Sync Service - COMPLÉTÉ
- Mettre à jour
HabitForgeApi.ktavec le nouvel endpoint/sync-raw - Créer les 15 classes de
*Payloadpour 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
-
Appliquer la migration de base de données:
# Dans le container Docker ou en local docker-compose exec web alembic upgrade head -
Recompiler l'application Android:
cd android-companion ./gradlew assembleDebug -
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
-
Intégrer le service d'agrégation avec le frontend (optionnel):
- Modifier les endpoints
/api/health/statsexistants pour utiliserAggregationService - Le frontend continuera de fonctionner tel quel
- Modifier les endpoints
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
-
Volume de données : Sync limité à 30 jours par défaut pour le service raw.
-
Compatibilité : L'ancien endpoint
/syncest conservé pour backward compatibility. -
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)