- 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)
545 lines
22 KiB
HTML
545 lines
22 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block head %}
|
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
|
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="" />
|
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
|
|
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
|
|
<style>
|
|
#map {
|
|
height: 600px;
|
|
width: 100%;
|
|
border-radius: 8px;
|
|
}
|
|
|
|
.candidate-card {
|
|
cursor: pointer;
|
|
transition: transform 0.2s;
|
|
}
|
|
|
|
.candidate-card:hover {
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
|
}
|
|
|
|
.candidate-card.active {
|
|
border-color: #0d6efd;
|
|
background-color: #f8f9fa;
|
|
}
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="row mb-4">
|
|
<div class="col-12">
|
|
<h2>Segment Discovery</h2>
|
|
<p class="text-muted">Find frequent routes in your activity history.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card mb-4">
|
|
<div class="card-header">
|
|
<ul class="nav nav-tabs card-header-tabs" id="discoveryTabs" role="tablist">
|
|
<li class="nav-item" role="presentation">
|
|
<button class="nav-link active" id="global-tab" data-bs-toggle="tab" data-bs-target="#global-pane"
|
|
type="button" role="tab">Global Discovery</button>
|
|
</li>
|
|
<li class="nav-item" role="presentation">
|
|
<button class="nav-link" id="single-tab" data-bs-toggle="tab" data-bs-target="#single-pane"
|
|
type="button" role="tab">Single Activity</button>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="tab-content" id="discoveryTabContent">
|
|
<!-- Global Discovery Pane -->
|
|
<div class="tab-pane fade show active" id="global-pane" role="tabpanel">
|
|
<form id="discoveryForm" class="row g-3 align-items-end">
|
|
<div class="col-md-4">
|
|
<label for="activityType" class="form-label">Activity Type</label>
|
|
<select id="activityType" class="form-select">
|
|
<option value="cycling">Cycling</option>
|
|
<option value="running">Running</option>
|
|
<option value="hiking">Hiking</option>
|
|
</select>
|
|
</div>
|
|
<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-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>
|
|
|
|
<!-- 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>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Save Modal -->
|
|
<div class="modal fade" id="saveSegmentModal" tabindex="-1">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title">Save Segment</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div class="mb-3">
|
|
<label for="segmentName" class="form-label">Segment Name</label>
|
|
<input type="text" class="form-control" id="segmentName" required>
|
|
</div>
|
|
<div class="mb-3">
|
|
<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">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
<button type="button" class="btn btn-primary" onclick="confirmSave()">Save</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row">
|
|
<!-- List Column -->
|
|
<div class="col-md-5">
|
|
<div id="resultsArea" class="row">
|
|
<!-- Results will be injected here -->
|
|
<div class="col-12 text-center text-muted" id="placeholder">
|
|
Run a search to see recommendations.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Map Column -->
|
|
<div class="col-md-7">
|
|
<div class="sticky-top" style="top: 20px; z-index: 0;">
|
|
<div id="map"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
// Initialize Map
|
|
const map = L.map('map').setView([0, 0], 2);
|
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
attribution: '© OpenStreetMap contributors'
|
|
}).addTo(map);
|
|
|
|
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
|
|
const resultsArea = document.getElementById('resultsArea');
|
|
|
|
// Toggle listener
|
|
document.getElementById('showDebugLayer').addEventListener('change', function (e) {
|
|
if (e.target.checked) {
|
|
renderDebugLayer();
|
|
} else {
|
|
debugLayerGroup.clearLayers();
|
|
}
|
|
});
|
|
|
|
function renderDebugLayer() {
|
|
debugLayerGroup.clearLayers();
|
|
if (!debugPathsData || debugPathsData.length === 0) return;
|
|
|
|
debugPathsData.forEach(path => {
|
|
const latlngs = path.map(p => [p[1], p[0]]);
|
|
L.polyline(latlngs, {
|
|
color: '#999',
|
|
weight: 1,
|
|
opacity: 0.3,
|
|
dashArray: '5, 10'
|
|
}).addTo(debugLayerGroup);
|
|
});
|
|
}
|
|
|
|
function renderCandidates(candidates, append = false) {
|
|
if (!append) {
|
|
resultsArea.innerHTML = '';
|
|
currentCandidates = candidates;
|
|
} else {
|
|
currentCandidates = currentCandidates.concat(candidates);
|
|
}
|
|
|
|
const bounds = L.latLngBounds();
|
|
|
|
if (!candidates || candidates.length === 0) {
|
|
resultsArea.innerHTML = '<div class="col-12 text-center">No segments found matching criteria.</div>';
|
|
return;
|
|
}
|
|
|
|
candidates.forEach((cand, index) => {
|
|
// Check if bounds valid
|
|
let latlngs;
|
|
try {
|
|
latlngs = cand.points.map(p => [p[1], p[0]]);
|
|
} catch (e) { return; }
|
|
|
|
if (latlngs.length < 2) return;
|
|
|
|
const polyline = L.polyline(latlngs, {
|
|
color: 'blue',
|
|
weight: 4,
|
|
opacity: 0.7
|
|
}).addTo(map);
|
|
|
|
polyline.bindPopup(`<strong>Matches: ${cand.frequency}</strong><br>Dist: ${(cand.distance / 1000).toFixed(2)}km`);
|
|
|
|
// Add interactions
|
|
polyline.on('mouseover', function () {
|
|
this.setStyle({ color: 'red', weight: 6 });
|
|
highlightCard(index);
|
|
});
|
|
polyline.on('mouseout', function () {
|
|
this.setStyle({ color: 'blue', weight: 4 });
|
|
unhighlightCard(index);
|
|
});
|
|
|
|
currentLayers.push(polyline);
|
|
bounds.extend(latlngs);
|
|
|
|
const card = `
|
|
<div class="col-12 mb-2">
|
|
<div class="card candidate-card" id="card-${index}"
|
|
onmouseover="highlightMap(${index})"
|
|
onmouseout="unhighlightMap(${index})"
|
|
onclick="focusMap(${index})">
|
|
<div class="card-body p-3">
|
|
<div class="d-flex justify-content-between align-items-center">
|
|
<h6 class="mb-0">Candidate #${index + 1}</h6>
|
|
<span class="badge bg-success">${cand.frequency} Runs</span>
|
|
</div>
|
|
<small class="text-muted">Distance: ${(cand.distance / 1000).toFixed(2)} km</small>
|
|
<div class="mt-2">
|
|
<div class="mt-2">
|
|
<button class="btn btn-sm btn-outline-primary w-100" onclick="openSaveModal(${index})">Save to Library</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
resultsArea.insertAdjacentHTML('beforeend', card);
|
|
const cardEl = document.getElementById(`card-${index}`);
|
|
if (cardEl) cardEl.dataset.layerId = currentLayers.length - 1;
|
|
});
|
|
|
|
if (currentLayers.length > 0) {
|
|
map.fitBounds(bounds, { padding: [50, 50] });
|
|
}
|
|
}
|
|
|
|
document.getElementById('discoveryForm').addEventListener('submit', async function (e) {
|
|
e.preventDefault();
|
|
const btn = document.getElementById('searchBtn');
|
|
const spinner = btn.querySelector('.spinner-border');
|
|
|
|
btn.disabled = true;
|
|
spinner.classList.remove('d-none');
|
|
|
|
const type = document.getElementById('activityType').value;
|
|
const start = document.getElementById('startDate').value;
|
|
|
|
try {
|
|
const response = await fetch('/api/discovery/segments', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
activity_type: type,
|
|
start_date: start ? new Date(start).toISOString() : null,
|
|
min_frequency: parseInt(document.getElementById('minRuns').value),
|
|
max_candidates: parseInt(document.getElementById('maxCands').value)
|
|
})
|
|
});
|
|
|
|
if (!response.ok) throw new Error('Discovery failed');
|
|
|
|
const data = await response.json();
|
|
|
|
// Clear map
|
|
currentLayers.forEach(l => map.removeLayer(l));
|
|
currentLayers = [];
|
|
|
|
debugLayerGroup.clearLayers();
|
|
debugPathsData = data.debug_paths || [];
|
|
if (document.getElementById('showDebugLayer').checked) {
|
|
renderDebugLayer();
|
|
}
|
|
|
|
renderCandidates(data.candidates);
|
|
|
|
} catch (err) {
|
|
console.error(err);
|
|
resultsArea.innerHTML = `<div class="alert alert-danger">Error: ${err.message}</div>`;
|
|
} finally {
|
|
btn.disabled = false;
|
|
spinner.classList.add('d-none');
|
|
}
|
|
});
|
|
|
|
document.getElementById('singleForm').addEventListener('submit', async function (e) {
|
|
e.preventDefault();
|
|
const btn = document.getElementById('singleSearchBtn');
|
|
const spinner = btn.querySelector('.spinner-border');
|
|
const actId = document.getElementById('activityId').value;
|
|
|
|
btn.disabled = true;
|
|
spinner.classList.remove('d-none');
|
|
|
|
try {
|
|
const response = await fetch('/api/discovery/single', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
activity_id: parseInt(actId),
|
|
pause_threshold: parseFloat(document.getElementById('pauseLimit').value),
|
|
rdp_epsilon: parseFloat(document.getElementById('rdpEpsilon').value),
|
|
turn_threshold: parseFloat(document.getElementById('turnAngle').value),
|
|
min_length: parseFloat(document.getElementById('minLength').value)
|
|
})
|
|
});
|
|
|
|
if (!response.ok) throw new Error('Single Analysis failed');
|
|
const data = await response.json();
|
|
|
|
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) {
|
|
console.error(err);
|
|
resultsArea.innerHTML = `<div class="alert alert-danger">Error: ${err.message}</div>`;
|
|
} finally {
|
|
btn.disabled = false;
|
|
spinner.classList.add('d-none');
|
|
}
|
|
});
|
|
|
|
// Helper functions for interaction
|
|
function highlightMap(index) {
|
|
const layer = currentLayers[index];
|
|
if (layer) {
|
|
layer.setStyle({ color: 'red', weight: 6 }).bringToFront();
|
|
}
|
|
}
|
|
|
|
function unhighlightMap(index) {
|
|
const layer = currentLayers[index];
|
|
if (layer) {
|
|
layer.setStyle({ color: 'blue', weight: 4 });
|
|
}
|
|
}
|
|
|
|
function focusMap(index) {
|
|
const layer = currentLayers[index];
|
|
if (layer) {
|
|
map.fitBounds(layer.getBounds(), { maxZoom: 14 });
|
|
}
|
|
}
|
|
|
|
function highlightCard(index) {
|
|
const card = document.getElementById(`card-${index}`);
|
|
if (card) card.classList.add('active');
|
|
}
|
|
|
|
function unhighlightCard(index) {
|
|
const card = document.getElementById(`card-${index}`);
|
|
if (card) card.classList.remove('active');
|
|
}
|
|
|
|
// Save Logic
|
|
let saveModal = null;
|
|
|
|
function openSaveModal(index) {
|
|
if (!saveModal) {
|
|
saveModal = new bootstrap.Modal(document.getElementById('saveSegmentModal'));
|
|
}
|
|
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();
|
|
}
|
|
|
|
async function confirmSave() {
|
|
const index = document.getElementById('saveCandidateIndex').value;
|
|
const name = document.getElementById('segmentName').value;
|
|
const desc = document.getElementById('segmentDesc').value;
|
|
const cand = currentCandidates[index];
|
|
|
|
if (!name) {
|
|
alert("Name is required");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Determine activity type (from form or candidate if we stored it?
|
|
// Candidate doesn't strictly have type, but we can infer or pass it.
|
|
// For now, let's grab from the global form or default to 'cycling'.
|
|
// Single Mode: We don't have type selector easily accessible if tab switched?
|
|
// Actually Activity ID analysis implies we know the activity...
|
|
// but the candidate obj doesn't have type.
|
|
// Let's assume 'cycling' or try to grab from UI.
|
|
|
|
// Determine activity type
|
|
let actType = document.getElementById('saveActivityType').value;
|
|
|
|
const payload = {
|
|
name: name,
|
|
description: desc,
|
|
activity_type: actType, // Best guess
|
|
points: cand.points
|
|
};
|
|
|
|
const response = await fetch('/api/segments/save_custom', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload)
|
|
});
|
|
|
|
if (!response.ok) throw new Error('Save failed');
|
|
const data = await response.json();
|
|
|
|
alert('Segment saved successfully!');
|
|
saveModal.hide();
|
|
} catch (err) {
|
|
console.error(err);
|
|
alert('Error saving segment: ' + err.message);
|
|
}
|
|
}
|
|
// Handle Query Params
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
const actId = urlParams.get('activity_id');
|
|
|
|
if (actId) {
|
|
// Switch to single tab
|
|
const tabEl = document.getElementById('single-tab');
|
|
const tab = new bootstrap.Tab(tabEl);
|
|
tab.show();
|
|
|
|
// Fill ID
|
|
document.getElementById('activityId').value = actId;
|
|
|
|
// Trigger search
|
|
document.getElementById('singleSearchBtn').click();
|
|
}
|
|
});
|
|
</script>
|
|
{% endblock %} |