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

516 lines
16 KiB
Markdown

# 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<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
```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<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
```kotlin
// 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
```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)