many updates
This commit is contained in:
@@ -35,9 +35,9 @@
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<select class="form-select" id="filter-type">
|
||||
<option value="">All</option>
|
||||
<option value="">All Types</option>
|
||||
<option value="running">Running</option>
|
||||
<option value="cycling">Cycling</option>
|
||||
<option value="cycling">Cycling (All)</option>
|
||||
<option value="swimming">Swimming</option>
|
||||
<option value="walking">Walking</option>
|
||||
<option value="hiking">Hiking</option>
|
||||
@@ -45,6 +45,15 @@
|
||||
<option value="yoga">Yoga</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<label for="filter-bike" class="col-form-label">Bike:</label>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<select class="form-select" id="filter-bike">
|
||||
<option value="">All Bikes</option>
|
||||
<!-- Populated by JS -->
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-secondary" id="apply-filters-btn">Filter</button>
|
||||
</div>
|
||||
@@ -315,7 +324,10 @@
|
||||
|
||||
detailsModal = new bootstrap.Modal(document.getElementById('activityDetailsModal'));
|
||||
|
||||
detailsModal = new bootstrap.Modal(document.getElementById('activityDetailsModal'));
|
||||
|
||||
loadActivities();
|
||||
fetchBikeSetups();
|
||||
|
||||
document.getElementById('prev-page-btn').addEventListener('click', () => changePage(-1));
|
||||
document.getElementById('next-page-btn').addEventListener('click', () => changePage(1));
|
||||
@@ -375,10 +387,19 @@
|
||||
tbody.innerHTML = '<tr><td colspan="9" class="text-center">Loading...</td></tr>';
|
||||
|
||||
const typeFilter = document.getElementById('filter-type').value;
|
||||
const bikeFilter = document.getElementById('filter-bike').value;
|
||||
|
||||
let url = `/api/activities/list?limit=${limit}&offset=${currentPage * limit}`;
|
||||
|
||||
if (typeFilter) {
|
||||
url = `/api/activities/query?activity_type=${typeFilter}`;
|
||||
// If any filter is active, force query mode
|
||||
if (typeFilter || bikeFilter) {
|
||||
url = `/api/activities/query?`;
|
||||
const params = new URLSearchParams();
|
||||
if (typeFilter) params.append('activity_type', typeFilter);
|
||||
if (bikeFilter) params.append('bike_setup_id', bikeFilter);
|
||||
|
||||
url += params.toString();
|
||||
|
||||
document.getElementById('prev-page-btn').disabled = true;
|
||||
document.getElementById('next-page-btn').disabled = true;
|
||||
} else {
|
||||
@@ -438,8 +459,8 @@
|
||||
<button class="btn btn-outline-primary" onclick="downloadFile('${act.garmin_activity_id}')" title="Download Local File">
|
||||
<i class="bi bi-download"></i>
|
||||
</button>
|
||||
<button class="btn btn-outline-warning" onclick="redownload('${act.garmin_activity_id}')" title="Redownload from Garmin">
|
||||
<i class="bi bi-cloud-download"></i>
|
||||
<button class="btn btn-outline-warning" onclick="redownload('${act.garmin_activity_id}')" title="Refresh Data & Rematch Bike">
|
||||
<i class="bi bi-arrow-clockwise"></i>
|
||||
</button>
|
||||
<a href="/activity/${act.garmin_activity_id}" target="_blank" class="btn btn-outline-info" title="View Details">
|
||||
<i class="bi bi-eye"></i>
|
||||
@@ -577,6 +598,44 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchBikeSetups() {
|
||||
try {
|
||||
const res = await fetch('/api/bike-setups');
|
||||
if (!res.ok) throw new Error("Failed to fetch bikes");
|
||||
const bikes = await res.json();
|
||||
const select = document.getElementById('filter-bike');
|
||||
bikes.forEach(bike => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = bike.id;
|
||||
opt.textContent = bike.name ? `${bike.name} (${bike.frame})` : bike.frame;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshActivity(garminId) {
|
||||
if (!confirm("Are you sure you want to re-download this activity from Garmin and run bike matching?")) {
|
||||
return;
|
||||
}
|
||||
|
||||
showToast("Processing...", "Refreshing activity data...", "info");
|
||||
try {
|
||||
const res = await fetch(`/api/activities/${garminId}/redownload`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
showToast("Success", data.message, "success");
|
||||
loadActivities(); // Reload table
|
||||
} else {
|
||||
throw new Error(data.detail || "Refresh failed");
|
||||
}
|
||||
} catch (e) {
|
||||
showToast("Error", e.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
window.showActivityDetails = async function (id) {
|
||||
// Reset fields
|
||||
document.querySelectorAll('[id^="det-"]').forEach(el => el.textContent = '-');
|
||||
|
||||
@@ -56,6 +56,12 @@
|
||||
<button class="btn btn-primary" id="download-btn">
|
||||
<i class="bi bi-download"></i> Download
|
||||
</button>
|
||||
<button class="btn btn-outline-warning" id="refresh-btn" onclick="refreshActivity()">
|
||||
<i class="bi bi-arrow-clockwise"></i> Refresh & Match
|
||||
</button>
|
||||
<button class="btn btn-warning" id="estimate-power-btn" onclick="estimatePower()">
|
||||
<i class="bi bi-lightning-fill"></i> Estimate Power
|
||||
</button>
|
||||
<button class="btn btn-success" id="create-segment-btn" onclick="toggleSegmentMode()">
|
||||
<i class="bi bi-bezier2"></i> Create Segment
|
||||
</button>
|
||||
@@ -228,7 +234,10 @@
|
||||
<!-- Bike Info (New) -->
|
||||
<div class="col-md-4">
|
||||
<div class="card h-100 metric-card border-light shadow-sm">
|
||||
<div class="card-header bg-light text-dark">Bike Setup</div>
|
||||
<div class="card-header bg-light text-dark d-flex justify-content-between align-items-center">
|
||||
<span>Bike Setup</span>
|
||||
<button class="btn btn-sm btn-link p-0 text-decoration-none" onclick="editBikeSetup()">Edit</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="m-bike-info" class="text-center text-muted">No Setup</div>
|
||||
</div>
|
||||
@@ -647,6 +656,132 @@
|
||||
setupDrag(endMarker, false);
|
||||
}
|
||||
|
||||
async function refreshActivity() {
|
||||
if (!confirm("Are you sure you want to re-download this activity from Garmin and run bike matching? This will overwrite any manual bike selection.")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.getElementById('refresh-btn');
|
||||
const origHtml = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Refreshing...';
|
||||
|
||||
// Helper toast fallback if showToast not defined in this view (it inherits from base.html usually?)
|
||||
// base.html usually has showToast.
|
||||
if (typeof showToast === 'function') showToast("Processing...", "Refreshing activity data...", "info");
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/activities/${activityId}/redownload`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
if (typeof showToast === 'function') showToast("Success", data.message, "success");
|
||||
else alert(data.message);
|
||||
|
||||
// Reload details
|
||||
loadDetails();
|
||||
loadCharts();
|
||||
} else {
|
||||
throw new Error(data.detail || "Refresh failed");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
if (typeof showToast === 'function') showToast("Error", e.message, "error");
|
||||
else alert("Error: " + e.message);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = origHtml;
|
||||
}
|
||||
}
|
||||
|
||||
let allBikes = [];
|
||||
async function fetchAllBikes() {
|
||||
try {
|
||||
const res = await fetch('/api/bike-setups');
|
||||
if (res.ok) allBikes = await res.json();
|
||||
} catch (e) { console.error(e); }
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', fetchAllBikes);
|
||||
|
||||
function editBikeSetup() {
|
||||
const container = document.getElementById('m-bike-info');
|
||||
if (container.querySelector('select')) return;
|
||||
|
||||
// Current text
|
||||
const currentHtml = container.innerHTML;
|
||||
|
||||
let initialSelect = `<div class="input-group input-group-sm">
|
||||
<select class="form-select" id="bike-select">
|
||||
<option value="">-- No Bike --</option>`;
|
||||
|
||||
allBikes.forEach(b => {
|
||||
initialSelect += `<option value="${b.id}">${b.name || b.frame} (${b.chainring}/${b.rear_cog})</option>`;
|
||||
});
|
||||
|
||||
initialSelect += `</select>
|
||||
<button class="btn btn-success" onclick="saveBikeSetup()"><i class="bi bi-check"></i></button>
|
||||
<button class="btn btn-outline-secondary" onclick="loadDetails()"><i class="bi bi-x"></i></button>
|
||||
</div>`;
|
||||
|
||||
container.innerHTML = initialSelect;
|
||||
}
|
||||
|
||||
async function saveBikeSetup() {
|
||||
const sel = document.getElementById('bike-select');
|
||||
const newId = sel.value ? parseInt(sel.value) : null;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/activities/${activityId}/bike`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ bike_setup_id: newId, manual_override: true })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
if (typeof showToast === 'function') showToast("Success", "Bike setup updated", "success");
|
||||
else alert("Bike setup updated");
|
||||
loadDetails();
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert("Error: " + err.detail);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert("Save failed");
|
||||
}
|
||||
}
|
||||
|
||||
async function estimatePower() {
|
||||
if (!confirm("Estimate power for this activity using physics usage calculation? This will update average/max power stats.")) return;
|
||||
|
||||
const btn = document.getElementById('estimate-power-btn');
|
||||
const origText = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Estimating...';
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/activities/${window.currentDbId}/estimate_power`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
alert("Power estimation complete! Avg: " + data.stats.avg_power + " W");
|
||||
loadDetails(); // Refresh stats
|
||||
loadCharts(); // Refresh charts if stream updated (Service returns stream but we'd need to reload)
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert("Error: " + err.detail);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert("Estimate failed: " + e.message);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = origText;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSegment() {
|
||||
if (startIndex >= endIndex) {
|
||||
alert("Start point must be before End point.");
|
||||
|
||||
@@ -75,6 +75,11 @@
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.path == '/segments' %}active{% endif %}" href="/segments">Segments</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.path == '/discovery' %}active{% endif %}"
|
||||
href="/discovery">Discovery</a>
|
||||
</li>
|
||||
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.path == '/garmin-health' %}active{% endif %}"
|
||||
href="/garmin-health">Garmin Health</a>
|
||||
|
||||
@@ -21,6 +21,10 @@
|
||||
<th>Chainring</th>
|
||||
<th>Rear Cog</th>
|
||||
<th>Gear Ratio</th>
|
||||
<th>Rides</th>
|
||||
<th>Distance</th>
|
||||
<th>Weight</th>
|
||||
<th>Active</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -51,16 +55,33 @@
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="frame" class="form-label">Frame</label>
|
||||
<input type="text" class="form-control" id="frame" name="frame" required placeholder="e.g. Dolan Pre Cursa">
|
||||
<input type="text" class="form-control" id="frame" name="frame" required
|
||||
placeholder="e.g. Dolan Pre Cursa">
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="chainring" class="form-label">Chainring</label>
|
||||
<input type="number" class="form-control" id="chainring" name="chainring" required min="1" placeholder="Teeth">
|
||||
<input type="number" class="form-control" id="chainring" name="chainring" required min="1"
|
||||
placeholder="Teeth">
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="rearCog" class="form-label">Rear Cog</label>
|
||||
<input type="number" class="form-control" id="rearCog" name="rear_cog" required min="1" placeholder="Teeth">
|
||||
<input type="number" class="form-control" id="rearCog" name="rear_cog" required min="1"
|
||||
placeholder="Teeth">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3">
|
||||
<label for="weightKg" class="form-label">Weight (kg)</label>
|
||||
<input type="number" class="form-control" id="weightKg" name="weight_kg" step="0.1" min="0"
|
||||
placeholder="e.g. 9.0">
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label for="purchaseDate" class="form-label">Purchase Date</label>
|
||||
<input type="date" class="form-control" id="purchaseDate" name="purchase_date">
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label for="retirementDate" class="form-label">Retired Date</label>
|
||||
<input type="date" class="form-control" id="retirementDate" name="retirement_date">
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@@ -96,7 +117,7 @@
|
||||
function renderTable() {
|
||||
const tbody = document.getElementById('setupsTableBody');
|
||||
tbody.innerHTML = '';
|
||||
|
||||
|
||||
currentSetups.forEach(setup => {
|
||||
const ratio = (setup.chainring / setup.rear_cog).toFixed(2);
|
||||
const tr = document.createElement('tr');
|
||||
@@ -106,6 +127,10 @@
|
||||
<td>${setup.chainring}t</td>
|
||||
<td>${setup.rear_cog}t</td>
|
||||
<td>${ratio}</td>
|
||||
<td><span class="badge bg-secondary">${setup.activity_count || 0}</span></td>
|
||||
<td>${setup.total_distance ? (setup.total_distance / 1000).toFixed(0) + ' km' : '-'}</td>
|
||||
<td>${setup.weight_kg ? setup.weight_kg + 'kg' : '-'}</td>
|
||||
<td>${setup.retirement_date ? '<span class="badge bg-danger">Retired</span>' : '<span class="badge bg-success">Active</span>'}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline-primary me-1" onclick="editSetup(${setup.id})">
|
||||
<i class="bi bi-pencil"></i>
|
||||
@@ -128,11 +153,11 @@
|
||||
// Hook into modal show event to reset form if adding
|
||||
document.getElementById('addSetupModal').addEventListener('show.bs.modal', function (event) {
|
||||
if (!event.relatedTarget || event.relatedTarget.getAttribute('data-bs-target')) {
|
||||
// If triggered by button (not manual show for edit), reset
|
||||
// Actually better to just check if we set an ID
|
||||
// If triggered by button (not manual show for edit), reset
|
||||
// Actually better to just check if we set an ID
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Reset on close
|
||||
document.getElementById('addSetupModal').addEventListener('hidden.bs.modal', resetForm);
|
||||
|
||||
@@ -146,7 +171,10 @@
|
||||
document.getElementById('frame').value = setup.frame;
|
||||
document.getElementById('chainring').value = setup.chainring;
|
||||
document.getElementById('rearCog').value = setup.rear_cog;
|
||||
|
||||
document.getElementById('weightKg').value = setup.weight_kg || '';
|
||||
document.getElementById('purchaseDate').value = setup.purchase_date ? setup.purchase_date.split('T')[0] : '';
|
||||
document.getElementById('retirementDate').value = setup.retirement_date ? setup.retirement_date.split('T')[0] : '';
|
||||
|
||||
document.getElementById('setupModalLabel').textContent = 'Edit Bike Setup';
|
||||
setupModal.show();
|
||||
}
|
||||
@@ -157,7 +185,10 @@
|
||||
name: document.getElementById('name').value || null,
|
||||
frame: document.getElementById('frame').value,
|
||||
chainring: parseInt(document.getElementById('chainring').value),
|
||||
rear_cog: parseInt(document.getElementById('rearCog').value)
|
||||
rear_cog: parseInt(document.getElementById('rearCog').value),
|
||||
weight_kg: document.getElementById('weightKg').value ? parseFloat(document.getElementById('weightKg').value) : null,
|
||||
purchase_date: document.getElementById('purchaseDate').value || null,
|
||||
retirement_date: document.getElementById('retirementDate').value || null
|
||||
};
|
||||
|
||||
if (!data.frame || !data.chainring || !data.rear_cog) {
|
||||
@@ -168,7 +199,7 @@
|
||||
try {
|
||||
const method = id ? 'PUT' : 'POST';
|
||||
const url = id ? `/api/bike-setups/${id}` : '/api/bike-setups/';
|
||||
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -176,7 +207,7 @@
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to save setup');
|
||||
|
||||
|
||||
showToast('Setup saved successfully', 'success');
|
||||
setupModal.hide();
|
||||
loadSetups();
|
||||
@@ -191,7 +222,7 @@
|
||||
try {
|
||||
const response = await fetch(`/api/bike-setups/${id}`, { method: 'DELETE' });
|
||||
if (!response.ok) throw new Error('Failed to delete setup');
|
||||
|
||||
|
||||
showToast('Setup deleted', 'success');
|
||||
loadSetups();
|
||||
} catch (error) {
|
||||
@@ -199,4 +230,4 @@
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
444
FitnessSync/backend/templates/discovery.html
Normal file
444
FitnessSync/backend/templates/discovery.html
Normal file
@@ -0,0 +1,444 @@
|
||||
{% 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>
|
||||
<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>
|
||||
</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-8">
|
||||
<label for="activityId" class="form-label">Activity ID</label>
|
||||
<input type="number" class="form-control" id="activityId" placeholder="e.g. 12345" required>
|
||||
<div class="form-text">Enter the ID of the activity to slice into segments.</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<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>
|
||||
<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 = [];
|
||||
let currentCandidates = []; // Store data for saving
|
||||
|
||||
|
||||
// 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
|
||||
})
|
||||
});
|
||||
|
||||
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) })
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
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';
|
||||
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.
|
||||
|
||||
let actType = document.getElementById('activityType').value;
|
||||
// If single tab active, we might not know.
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -30,7 +30,9 @@
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Distance</th>
|
||||
<th>Distance</th>
|
||||
<th>Elevation</th>
|
||||
<th>Efforts</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -141,7 +143,9 @@
|
||||
<td><strong>${seg.name}</strong></td>
|
||||
<td><span class="badge bg-secondary">${seg.activity_type}</span></td>
|
||||
<td>${(seg.distance / 1000).toFixed(2)} km</td>
|
||||
<td>${(seg.distance / 1000).toFixed(2)} km</td>
|
||||
<td>${seg.elevation_gain ? seg.elevation_gain.toFixed(1) + ' m' : '-'}</td>
|
||||
<td><span class="badge bg-info text-dark">${seg.effort_count || 0}</span></td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline-primary me-1" onclick='viewSegment(${JSON.stringify(seg)})'>
|
||||
<i class="bi bi-eye"></i> View
|
||||
|
||||
Reference in New Issue
Block a user