516 lines
16 KiB
Markdown
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)
|
|
|