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:
@@ -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>°</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>°</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,
|
||||
|
||||
Reference in New Issue
Block a user