296 lines
8.1 KiB
Markdown
296 lines
8.1 KiB
Markdown
# Indoor Activity Detection - Implementation Summary
|
|
|
|
## Overview
|
|
|
|
Implemented multi-format indoor activity detection with retroactive migration support. Indoor activities (trainer rides, treadmill runs, virtual activities) are now:
|
|
- ✅ **Visible in timeline** with full GPS visualization
|
|
- ✅ **Excluded from heatmap** to prevent pollution of outdoor activity maps
|
|
- ✅ **Automatically detected** from FIT SubSport field or GPS heuristics
|
|
|
|
---
|
|
|
|
## Database Changes
|
|
|
|
### Migration V20 (Already Applied)
|
|
- Added `indoor` boolean column (defaults to FALSE)
|
|
- Created index `idx_activity_indoor` for efficient heatmap queries
|
|
|
|
### Migration V21 (New - Just Applied)
|
|
- Added `sub_sport` VARCHAR(50) - SubSport from FIT files
|
|
- Added `indoor_detection_method` VARCHAR(20) - How indoor flag was determined
|
|
|
|
### Schema Columns
|
|
|
|
| Column | Type | Purpose |
|
|
|---|---|---|
|
|
| `indoor` | BOOLEAN NOT NULL | Main flag - exclude from heatmap if TRUE |
|
|
| `sub_sport` | VARCHAR(50) NULL | FIT SubSport field (e.g., INDOOR_CYCLING, ROAD, TRAIL) |
|
|
| `indoor_detection_method` | VARCHAR(20) NULL | Detection method enum value |
|
|
|
|
---
|
|
|
|
## Detection Methods
|
|
|
|
### 1. FIT Files - SubSport Field (Most Accurate)
|
|
**Method**: `FIT_SUBSPORT`
|
|
|
|
Reads the SubSport field from FIT file session message and detects:
|
|
- `INDOOR_CYCLING` → Indoor
|
|
- `TREADMILL` → Indoor
|
|
- `VIRTUAL_ACTIVITY` → Indoor (Zwift, RGT, etc.)
|
|
- `TRAINER` → Indoor
|
|
- `ROAD`, `MOUNTAIN`, `TRAIL` → Outdoor
|
|
|
|
**Implementation**: `FitParser.extractSessionData()` (lines 332-348)
|
|
|
|
### 2. FIT Files - No GPS Data
|
|
**Method**: `HEURISTIC_NO_GPS`
|
|
|
|
If FIT file has no GPS track points → marked as indoor.
|
|
|
|
**Implementation**: `FitParser.parse()` (lines 111-120)
|
|
|
|
### 3. GPX Files - Stationary GPS
|
|
**Method**: `HEURISTIC_STATIONARY`
|
|
|
|
If all GPS points are within 50 meters of first point → marked as indoor.
|
|
Detects trainer rides or treadmill runs with GPS enabled (phone/tablet).
|
|
|
|
**Implementation**: `GpxParser.detectIndoorActivity()` + `isStationaryGps()` (lines 627-673)
|
|
|
|
### 4. GPX Files - No GPS Data
|
|
**Method**: `HEURISTIC_NO_GPS`
|
|
|
|
If GPX file has no GPS track points → marked as indoor.
|
|
|
|
**Implementation**: `GpxParser.detectIndoorActivity()` (lines 630-634)
|
|
|
|
### 5. Manual Override (Future)
|
|
**Method**: `MANUAL`
|
|
|
|
User can manually flag activity as indoor (UI not yet implemented).
|
|
|
|
---
|
|
|
|
## How It Works
|
|
|
|
### For NEW Uploads (After This Feature)
|
|
|
|
#### FIT File Upload:
|
|
1. User uploads FIT file
|
|
2. `FitParser.extractSessionData()` reads SubSport field
|
|
3. Sets `parsedData.setSubSport("INDOOR_CYCLING")` (example)
|
|
4. Detects indoor keywords → sets `parsedData.setIndoor(true)`
|
|
5. Sets `parsedData.setIndoorDetectionMethod(FIT_SUBSPORT)`
|
|
6. `ActivityFileService` saves to database with all fields populated
|
|
7. **Done** - no re-parsing needed
|
|
|
|
#### GPX File Upload:
|
|
1. User uploads GPX file
|
|
2. `GpxParser.detectIndoorActivity()` analyzes GPS points
|
|
3. Checks if all points within 50m radius
|
|
4. Sets `parsedData.setIndoor(true)` if stationary
|
|
5. Sets `parsedData.setIndoorDetectionMethod(HEURISTIC_STATIONARY)`
|
|
6. `ActivityFileService` saves to database
|
|
7. **Done** - no re-parsing needed
|
|
|
|
### For Timeline Loading
|
|
|
|
**Fast database query** - no file parsing:
|
|
```sql
|
|
SELECT id, activity_type, title, indoor, sub_sport,
|
|
indoor_detection_method, ...
|
|
FROM activities
|
|
WHERE ...
|
|
```
|
|
|
|
### For Heatmap Generation
|
|
|
|
**Automatically excludes indoor activities**:
|
|
|
|
Updated queries in `UserHeatmapGridRepository`:
|
|
```sql
|
|
-- Single activity update
|
|
WHERE a.id = :activityId
|
|
AND a.track_points_json IS NOT NULL
|
|
AND a.indoor = FALSE -- NEW
|
|
|
|
-- Full user recalculation
|
|
WHERE a.user_id = :userId
|
|
AND a.track_points_json IS NOT NULL
|
|
AND a.indoor = FALSE -- NEW
|
|
```
|
|
|
|
---
|
|
|
|
## Retroactive Migration
|
|
|
|
### For Activities Uploaded BEFORE This Feature
|
|
|
|
**Run once** to populate indoor flags for existing data:
|
|
|
|
```bash
|
|
POST /api/admin/migrate-indoor-flags
|
|
Authorization: Bearer <your-jwt-token>
|
|
```
|
|
|
|
This endpoint:
|
|
1. Fetches all FIT activities with stored raw files
|
|
2. Re-parses FIT files to extract SubSport
|
|
3. Updates `indoor`, `sub_sport`, and `indoor_detection_method`
|
|
4. Only saves if values changed (idempotent)
|
|
|
|
**Response**:
|
|
```json
|
|
{
|
|
"message": "Indoor activity flag migration complete",
|
|
"activitiesUpdated": 15
|
|
}
|
|
```
|
|
|
|
### How to Run Migration
|
|
|
|
#### Option 1: Using Browser DevTools
|
|
1. Login to FitPub
|
|
2. Open DevTools (F12) → Application → Local Storage
|
|
3. Copy `jwt_token` value
|
|
4. Use browser fetch or Postman:
|
|
```javascript
|
|
fetch('http://localhost:8080/api/admin/migrate-indoor-flags', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': 'Bearer YOUR_TOKEN_HERE'
|
|
}
|
|
}).then(r => r.json()).then(console.log);
|
|
```
|
|
|
|
#### Option 2: Using curl
|
|
```bash
|
|
curl -X POST \
|
|
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
|
http://localhost:8080/api/admin/migrate-indoor-flags
|
|
```
|
|
|
|
---
|
|
|
|
## Code Structure
|
|
|
|
### Key Files Modified
|
|
|
|
1. **Database**:
|
|
- `V20__add_indoor_flag_to_activities.sql` - Indoor flag column
|
|
- `V21__add_indoor_detection_metadata.sql` - SubSport & detection method columns
|
|
|
|
2. **Entity**:
|
|
- `Activity.java` - Added `subSport`, `indoorDetectionMethod` fields
|
|
- `Activity.IndoorDetectionMethod` - New enum for detection methods
|
|
|
|
3. **Parsers**:
|
|
- `FitParser.java` - Extract SubSport, detect indoor from FIT files
|
|
- `GpxParser.java` - Heuristic detection with GPS analysis (Haversine distance)
|
|
- `ParsedActivityData.java` - Added fields for parsed data transfer
|
|
|
|
4. **Service**:
|
|
- `ActivityFileService.java` - Save SubSport & detection method to database
|
|
- `IndoorActivityMigrationService.java` - Retroactive migration logic
|
|
|
|
5. **Repository**:
|
|
- `UserHeatmapGridRepository.java` - Exclude indoor activities from heatmap queries
|
|
- `ActivityRepository.java` - Added query method for migration
|
|
|
|
6. **Controller**:
|
|
- `AdminController.java` - Migration endpoint
|
|
- `SecurityConfig.java` - Added `/api/admin/**` route (authenticated)
|
|
|
|
---
|
|
|
|
## Testing
|
|
|
|
### Test Indoor Detection
|
|
|
|
#### 1. Upload Zwift FIT File
|
|
Expected:
|
|
- `indoor = TRUE`
|
|
- `sub_sport = "VIRTUAL_ACTIVITY"`
|
|
- `indoor_detection_method = "FIT_SUBSPORT"`
|
|
- Visible in timeline ✅
|
|
- Not in heatmap ✅
|
|
|
|
#### 2. Upload Treadmill FIT File
|
|
Expected:
|
|
- `indoor = TRUE`
|
|
- `sub_sport = "TREADMILL"`
|
|
- `indoor_detection_method = "FIT_SUBSPORT"`
|
|
|
|
#### 3. Upload GPX with Stationary GPS
|
|
Expected:
|
|
- `indoor = TRUE`
|
|
- `sub_sport = NULL` (GPX doesn't have SubSport)
|
|
- `indoor_detection_method = "HEURISTIC_STATIONARY"`
|
|
|
|
#### 4. Upload Outdoor Ride FIT
|
|
Expected:
|
|
- `indoor = FALSE`
|
|
- `sub_sport = "ROAD"` or `"MOUNTAIN"`
|
|
- `indoor_detection_method = NULL` (not indoor)
|
|
|
|
---
|
|
|
|
## Performance
|
|
|
|
- **New uploads**: SubSport extracted during normal parsing (no overhead)
|
|
- **Timeline loading**: Simple column read (instant)
|
|
- **Heatmap queries**: Added `AND indoor = FALSE` filter (uses index)
|
|
- **Migration**: One-time operation, only re-parses FIT files with raw data
|
|
|
|
---
|
|
|
|
## Future Enhancements
|
|
|
|
### Phase 2 (Optional):
|
|
1. **GPX Extension Parsing**: Read Garmin/Strava custom XML extensions
|
|
2. **Manual Override UI**: Allow users to manually mark activities as indoor
|
|
3. **Activity Edit**: Update indoor flag via activity edit form
|
|
4. **Statistics**: Show "X indoor activities excluded from heatmap" message
|
|
|
|
---
|
|
|
|
## Database Query Examples
|
|
|
|
### Find All Indoor Activities
|
|
```sql
|
|
SELECT id, title, activity_type, sub_sport, indoor_detection_method
|
|
FROM activities
|
|
WHERE indoor = TRUE;
|
|
```
|
|
|
|
### Find FIT Activities with SubSport
|
|
```sql
|
|
SELECT id, title, sub_sport
|
|
FROM activities
|
|
WHERE sub_sport IS NOT NULL
|
|
ORDER BY created_at DESC;
|
|
```
|
|
|
|
### Count Detection Methods
|
|
```sql
|
|
SELECT indoor_detection_method, COUNT(*) as count
|
|
FROM activities
|
|
WHERE indoor = TRUE
|
|
GROUP BY indoor_detection_method;
|
|
```
|
|
|
|
---
|
|
|
|
## Summary
|
|
|
|
✅ **Fully implemented** multi-format indoor detection
|
|
✅ **Backward compatible** - existing activities default to outdoor
|
|
✅ **Retroactive migration** endpoint for old data
|
|
✅ **Heatmap exclusion** automatic via SQL filters
|
|
✅ **Timeline display** includes all activities
|
|
✅ **Works for FIT and GPX** files with different detection strategies
|
|
✅ **Type-safe** detection method enum
|
|
✅ **Well documented** with inline comments
|
|
|
|
The solution is production-ready and scales to all supported file formats!
|