feat: implement Fitbit OAuth, Garmin MFA, and optimize segment discovery

- Add Fitbit authentication flow (save credentials, OAuth callback handling)
- Implement Garmin MFA support with successful session/cookie handling
- Optimize segment discovery with new sampling and activity query services
- Refactor database session management in discovery API for better testability
- Enhance activity data parsing for charts and analysis
- Update tests to use testcontainers and proper dependency injection
- Clean up repository by ignoring and removing tracked transient files (.pyc, .db)
This commit is contained in:
2026-01-16 15:35:26 -08:00
parent 45dbc32295
commit d1cfd0fd8e
217 changed files with 1795 additions and 922 deletions

View File

@@ -63,95 +63,99 @@
<option value="hiking">Hiking</option>
</select>
</div>
<div class="col-md-4">
<label for="startDate" class="form-label">Start Date</label>
<input type="date" class="form-control" id="startDate"
value="{{ (now - timedelta(days=90)).strftime('%Y-%m-%d') }}">
</div>
<div class="col-md-2 d-flex align-items-end mb-2">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="showDebugLayer">
<label class="form-check-label small" for="showDebugLayer">Show All</label>
</div>
</div>
<div class="col-md-2">
<button type="submit" class="btn btn-primary w-100" id="searchBtn">
<span class="spinner-border spinner-border-sm d-none" role="status"
aria-hidden="true"></span>
Discover
</button>
</div>
</form>
<label for="startDate" class="form-label">Start Date</label>
<input type="date" class="form-control" id="startDate"
value="{{ (now - timedelta(days=90)).strftime('%Y-%m-%d') }}">
</div>
<!-- Single Activity Pane -->
<div class="tab-pane fade" id="single-pane" role="tabpanel">
<form id="singleForm" class="row g-3 align-items-end">
<div class="col-md-4">
<label for="activityId" class="form-label">Activity ID</label>
<input type="number" class="form-control" id="activityId" placeholder="e.g. 12345" required>
</div>
<div class="col-md-8">
<!-- Submit Button moved below sliders? Or Keep here? -->
<!-- Let's put parsing options in a card/accordion -->
</div>
<div class="col-md-4">
<label class="form-label">Min Runs: <span id="minRunsVal">2</span></label>
<input type="range" class="form-range" id="minRuns" min="2" max="20" value="2"
oninput="document.getElementById('minRunsVal').innerText = this.value">
</div>
<div class="col-md-4">
<label class="form-label">Max Results: <span id="maxCandsVal">10</span></label>
<input type="range" class="form-range" id="maxCands" min="5" max="50" step="5" value="10"
oninput="document.getElementById('maxCandsVal').innerText = this.value">
</div>
<div class="col-md-2 d-flex align-items-end mb-2">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="showDebugLayer">
<label class="form-check-label small" for="showDebugLayer">Show All</label>
</div>
</div>
<div class="col-md-2">
<button type="submit" class="btn btn-primary w-100" id="searchBtn">
<span class="spinner-border spinner-border-sm d-none" role="status" aria-hidden="true"></span>
Discover
</button>
</div>
</form>
</div>
<div class="col-12">
<div class="card bg-light">
<div class="card-body">
<h6 class="card-title">Parsing Options</h6>
<div class="row">
<div class="col-md-6 mb-3">
<label for="pauseLimit" class="form-label">Pause Threshold: <span
id="pauseVal">10</span>s</label>
<input type="range" class="form-range" id="pauseLimit" min="0" max="120"
step="1" value="10"
oninput="document.getElementById('pauseVal').textContent=this.value">
<div class="form-text small">Split segment if stopped longer than this.</div>
</div>
<div class="col-md-6 mb-3">
<label for="rdpEpsilon" class="form-label">Smoother (RDP): <span
id="rdpVal">10</span>m</label>
<input type="range" class="form-range" id="rdpEpsilon" min="1" max="50" step="1"
value="10"
oninput="document.getElementById('rdpVal').textContent=this.value">
<div class="form-text small">Higher values simplify path more (ignores small
wiggles).</div>
</div>
<div class="col-md-6 mb-3">
<label for="turnAngle" class="form-label">Turn Threshold: <span
id="turnVal">60</span>&deg;</label>
<input type="range" class="form-range" id="turnAngle" min="10" max="120"
step="5" value="60"
oninput="document.getElementById('turnVal').textContent=this.value">
<div class="form-text small">Split segment if sharp turn detected.</div>
</div>
<div class="col-md-6 mb-3">
<label for="minLength" class="form-label">Min Length: <span
id="lenVal">100</span>m</label>
<input type="range" class="form-range" id="minLength" min="50" max="1000"
step="50" value="100"
oninput="document.getElementById('lenVal').textContent=this.value">
<div class="form-text small">Ignore segments shorter than this.</div>
</div>
<!-- Single Activity Pane -->
<div class="tab-pane fade" id="single-pane" role="tabpanel">
<form id="singleForm" class="row g-3 align-items-end">
<div class="col-md-4">
<label for="activityId" class="form-label">Activity ID</label>
<input type="number" class="form-control" id="activityId" placeholder="e.g. 12345" required>
</div>
<div class="col-md-8">
<!-- Submit Button moved below sliders? Or Keep here? -->
<!-- Let's put parsing options in a card/accordion -->
</div>
<div class="col-12">
<div class="card bg-light">
<div class="card-body">
<h6 class="card-title">Parsing Options</h6>
<div class="row">
<div class="col-md-6 mb-3">
<label for="pauseLimit" class="form-label">Pause Threshold: <span
id="pauseVal">10</span>s</label>
<input type="range" class="form-range" id="pauseLimit" min="0" max="120" step="1"
value="10" oninput="document.getElementById('pauseVal').textContent=this.value">
<div class="form-text small">Split segment if stopped longer than this.</div>
</div>
<div class="col-md-6 mb-3">
<label for="rdpEpsilon" class="form-label">Smoother (RDP): <span
id="rdpVal">10</span>m</label>
<input type="range" class="form-range" id="rdpEpsilon" min="1" max="50" step="1"
value="10" oninput="document.getElementById('rdpVal').textContent=this.value">
<div class="form-text small">Higher values simplify path more (ignores small
wiggles).</div>
</div>
<div class="col-md-6 mb-3">
<label for="turnAngle" class="form-label">Turn Threshold: <span
id="turnVal">60</span>&deg;</label>
<input type="range" class="form-range" id="turnAngle" min="10" max="120" step="5"
value="60" oninput="document.getElementById('turnVal').textContent=this.value">
<div class="form-text small">Split segment if sharp turn detected.</div>
</div>
<div class="col-md-6 mb-3">
<label for="minLength" class="form-label">Min Length: <span
id="lenVal">100</span>m</label>
<input type="range" class="form-range" id="minLength" min="50" max="1000" step="50"
value="100" oninput="document.getElementById('lenVal').textContent=this.value">
<div class="form-text small">Ignore segments shorter than this.</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-12 mt-3">
<button type="submit" class="btn btn-primary w-100" id="singleSearchBtn">
<span class="spinner-border spinner-border-sm d-none" role="status"
aria-hidden="true"></span>
Analyze Activity
</button>
</div>
</form>
</div>
<div class="col-12 mt-3">
<button type="submit" class="btn btn-primary w-100" id="singleSearchBtn">
<span class="spinner-border spinner-border-sm d-none" role="status" aria-hidden="true"></span>
Analyze Activity
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<!-- Save Modal -->
<div class="modal fade" id="saveSegmentModal" tabindex="-1">
@@ -170,6 +174,15 @@
<label for="segmentDesc" class="form-label">Description (Optional)</label>
<textarea class="form-control" id="segmentDesc" rows="2"></textarea>
</div>
<div class="mb-3">
<label for="saveActivityType" class="form-label">Activity Type</label>
<select class="form-select" id="saveActivityType">
<option value="cycling">Cycling</option>
<option value="running">Running</option>
<option value="hiking">Hiking</option>
<option value="walking">Walking</option>
</select>
</div>
<input type="hidden" id="saveCandidateIndex">
</div>
<div class="modal-footer">
@@ -209,7 +222,9 @@
let currentLayers = [];
let debugLayerGroup = L.layerGroup().addTo(map);
let debugPathsData = [];
// removed duplicates
let currentCandidates = []; // Store data for saving
let currentAnalyzedType = null; // Store type from single analysis
// Results DOM
@@ -332,7 +347,9 @@
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
activity_type: type,
start_date: start ? new Date(start).toISOString() : null
start_date: start ? new Date(start).toISOString() : null,
min_frequency: parseInt(document.getElementById('minRuns').value),
max_candidates: parseInt(document.getElementById('maxCands').value)
})
});
@@ -386,11 +403,13 @@
if (!response.ok) throw new Error('Single Analysis failed');
const data = await response.json();
// Clear map
currentLayers.forEach(l => map.removeLayer(l));
currentLayers = [];
debugLayerGroup.clearLayers();
// Store the type returned by backend
currentAnalyzedType = data.analyzed_activity_type;
renderCandidates(data.candidates);
} catch (err) {
@@ -444,6 +463,17 @@
document.getElementById('saveCandidateIndex').value = index;
document.getElementById('segmentName').value = `New Segment #${index + 1}`;
document.getElementById('segmentDesc').value = 'Discovered from activity analysis';
// Pre-select Activity Type
let defaultType = 'cycling';
if (currentAnalyzedType) {
defaultType = currentAnalyzedType;
} else {
// Fallback to global dropdown value
defaultType = document.getElementById('activityType').value;
}
document.getElementById('saveActivityType').value = defaultType;
saveModal.show();
}
@@ -467,8 +497,8 @@
// but the candidate obj doesn't have type.
// Let's assume 'cycling' or try to grab from UI.
let actType = document.getElementById('activityType').value;
// If single tab active, we might not know.
// Determine activity type
let actType = document.getElementById('saveActivityType').value;
const payload = {
name: name,