Goal markers, 7-day kcal balance, weight-calorie correlation, food cost
Idea 2 — Hill progress markers: - goal_markers table, CRUD, map-based placement - Session detail shows closest approach to each marker - Progress comparison with previous sessions Idea 6 — 7-day kcal balance widget: - Rolling average below summary cards on Today screen - Green = deficit, red = surplus Idea 7 — Weight vs calorie correlation chart: - Dual Y-axis: weight (left) + 7-day rolling balance (right) - Added to stats section in renderStatsCharts Idea 8 — Food cost of workout: - Shows session kcal as portions of frequently eaten foods - "460 kcal = 2.3 x гречка или 1.4 x творог"
This commit is contained in:
parent
1d6efcd851
commit
9384bb8d2e
3 changed files with 654 additions and 0 deletions
|
|
@ -1707,3 +1707,151 @@ select {
|
|||
border-color: var(--primary);
|
||||
background: rgba(187, 134, 252, 0.05);
|
||||
}
|
||||
|
||||
/* 7-day balance trend (IDEA 6) */
|
||||
.balance-trend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
min-height: 0;
|
||||
}
|
||||
.balance-trend:empty {
|
||||
display: none;
|
||||
}
|
||||
.balance-trend-label {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.balance-trend-val {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.trend-deficit {
|
||||
color: #03dac6;
|
||||
}
|
||||
.trend-surplus {
|
||||
color: #cf6679;
|
||||
}
|
||||
|
||||
/* Goal markers (IDEA 2) */
|
||||
.goal-markers-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-height: 50vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.goal-marker-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.goal-marker-info {
|
||||
flex: 1;
|
||||
}
|
||||
.goal-marker-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--on-surface);
|
||||
}
|
||||
.goal-marker-coords {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.goal-marker-notes {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-top: 2px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Goal marker map (add marker modal) */
|
||||
.gm-map-wrap {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.gm-map {
|
||||
height: 250px;
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
.gm-coords {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-top: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Goal markers in session detail */
|
||||
.goal-marker-item {
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.04);
|
||||
}
|
||||
.goal-marker-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.goal-marker-dist {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.goal-bar-wrap {
|
||||
height: 6px;
|
||||
background: var(--border);
|
||||
border-radius: 3px;
|
||||
margin-top: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.goal-bar-fill {
|
||||
height: 100%;
|
||||
background: var(--primary);
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
.goal-progress {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
margin-top: 4px;
|
||||
display: block;
|
||||
}
|
||||
.goal-better {
|
||||
color: #03dac6;
|
||||
}
|
||||
.goal-worse {
|
||||
color: #cf6679;
|
||||
}
|
||||
|
||||
/* Food cost of workout (IDEA 8) */
|
||||
.food-cost {
|
||||
padding: 4px 0;
|
||||
}
|
||||
.food-cost-total {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--on-surface);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.food-cost-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.food-cost-line {
|
||||
font-size: 14px;
|
||||
color: var(--on-bg);
|
||||
padding: 2px 0;
|
||||
}
|
||||
.food-cost-or {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
font-style: italic;
|
||||
padding: 0 0 0 8px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -780,6 +780,69 @@ async function getBikeMaintenanceDue(bikeId) {
|
|||
return all.filter(e => e.next_service_km != null && mileage >= e.next_service_km);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Goal markers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function getGoalMarkers() {
|
||||
return select("SELECT * FROM goal_markers ORDER BY created_at DESC");
|
||||
}
|
||||
|
||||
async function addGoalMarker(name, lat, lon, notes) {
|
||||
const r = await execute(
|
||||
`INSERT INTO goal_markers (name, lat, lon, notes) VALUES (?1, ?2, ?3, ?4)`,
|
||||
[name, lat, lon, notes ?? null]
|
||||
);
|
||||
return r.lastInsertId;
|
||||
}
|
||||
|
||||
async function deleteGoalMarker(id) {
|
||||
await execute("DELETE FROM goal_markers WHERE id = ?1", [id]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Food cost helper — most frequently eaten foods in last 7 days
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function getFrequentFoods(days) {
|
||||
return select(
|
||||
`SELECT product_name, kcal, amount_g,
|
||||
CASE WHEN amount_g > 0 THEN ROUND(kcal * 100.0 / amount_g) ELSE kcal END as kcal_per_100g
|
||||
FROM (
|
||||
SELECT p.name as product_name, fl.kcal, fl.amount_g,
|
||||
COUNT(*) as cnt
|
||||
FROM food_log fl
|
||||
JOIN products p ON fl.product_id = p.id
|
||||
WHERE fl.date >= date('now', ?1)
|
||||
AND fl.kcal > 0 AND fl.amount_g > 0
|
||||
GROUP BY p.name
|
||||
ORDER BY cnt DESC
|
||||
LIMIT 5
|
||||
)`,
|
||||
[`-${days ?? 7} days`]
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Closest approach of a session to a goal marker
|
||||
// Returns {minDist, closestIdx} — computed in JS since SQLite can't do haversine
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function getGoalProgress(markerId, sessionId) {
|
||||
// Fetch all sessions that got close to this marker, ordered by date
|
||||
// We actually compute this in JS, but store the result concept here
|
||||
// This function just returns all sessions with their closest approach to a marker
|
||||
const sessions = await select(
|
||||
`SELECT gs.id, gs.date, gs.start_time, gs.total_distance_m
|
||||
FROM gps_sessions gs
|
||||
WHERE gs.id != ?1
|
||||
ORDER BY gs.date DESC, gs.start_time DESC
|
||||
LIMIT 20`,
|
||||
[sessionId]
|
||||
);
|
||||
return sessions;
|
||||
}
|
||||
|
||||
// Expose as globals — no ES module system, loaded via <script> tags.
|
||||
// main.js accesses these as window.DB.xxx
|
||||
window.DB = {};
|
||||
|
|
@ -851,6 +914,11 @@ window.DB.addBikeMaintenance = addBikeMaintenance;
|
|||
window.DB.deleteBikeMaintenance = deleteBikeMaintenance;
|
||||
window.DB.getBikeTotalMileage = getBikeTotalMileage;
|
||||
window.DB.getBikeMaintenanceDue = getBikeMaintenanceDue;
|
||||
window.DB.getGoalMarkers = getGoalMarkers;
|
||||
window.DB.addGoalMarker = addGoalMarker;
|
||||
window.DB.deleteGoalMarker = deleteGoalMarker;
|
||||
window.DB.getFrequentFoods = getFrequentFoods;
|
||||
window.DB.getGoalProgress = getGoalProgress;
|
||||
|
||||
// JS-side migrations — safe for any DB state (checks before altering)
|
||||
async function runMigrations() {
|
||||
|
|
@ -1001,6 +1069,16 @@ async function runMigrations() {
|
|||
console.log("[db.js] Seeded default bike");
|
||||
}
|
||||
|
||||
// --- Goal markers table ---
|
||||
await execute(`CREATE TABLE IF NOT EXISTS goal_markers (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
lat REAL NOT NULL,
|
||||
lon REAL NOT NULL,
|
||||
notes TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
)`);
|
||||
|
||||
console.log("[db.js] recipe migrations done, checking seq repair...");
|
||||
|
||||
// Repair old tracks: assign seq to points where it's NULL, ordered by timestamp.
|
||||
|
|
|
|||
|
|
@ -230,6 +230,8 @@ async function renderToday(container) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="balance-trend" id="balance-trend"></div>
|
||||
|
||||
${buildMicroStrip(suppTotals)}
|
||||
|
||||
<section class="log-section">
|
||||
|
|
@ -361,6 +363,37 @@ async function renderToday(container) {
|
|||
};
|
||||
});
|
||||
|
||||
// 7-day rolling balance trend (IDEA 6)
|
||||
(async () => {
|
||||
try {
|
||||
const trendEl = container.querySelector("#balance-trend");
|
||||
if (!trendEl || bmr == null) return;
|
||||
const [food7, act7] = await Promise.all([
|
||||
DB.getDailyFoodTotals(7),
|
||||
DB.getDailyActivityTotals(7),
|
||||
]);
|
||||
if (food7.length === 0) return;
|
||||
const foodMap = {};
|
||||
food7.forEach(d => foodMap[d.date] = Math.round(d.kcal ?? 0));
|
||||
const actMap = {};
|
||||
act7.forEach(d => actMap[d.date] = Math.round(d.kcal ?? 0));
|
||||
// Build per-day balance for all dates that have food data
|
||||
const balances = [];
|
||||
for (const d of food7) {
|
||||
const dayIntake = foodMap[d.date] ?? 0;
|
||||
const dayAct = actMap[d.date] ?? 0;
|
||||
const dayBalance = dayIntake - (bmr + dayAct);
|
||||
balances.push(dayBalance);
|
||||
}
|
||||
if (balances.length === 0) return;
|
||||
const avg = Math.round(balances.reduce((a, b) => a + b, 0) / balances.length);
|
||||
const cls = avg <= 0 ? "trend-deficit" : "trend-surplus";
|
||||
trendEl.innerHTML = `
|
||||
<span class="balance-trend-label">Среднее за ${balances.length} дн.:</span>
|
||||
<span class="balance-trend-val ${cls}">${avg >= 0 ? "+" : ""}${avg} ккал/день</span>`;
|
||||
} catch (e) { console.error("[7d trend]", e); }
|
||||
})();
|
||||
|
||||
const strip = container.querySelector(".micro-strip");
|
||||
if (strip) strip.onwheel = e => { e.preventDefault(); strip.scrollLeft += e.deltaY; };
|
||||
|
||||
|
|
@ -598,6 +631,135 @@ async function renderStatsCharts(container, days, foodTotals, bmr) {
|
|||
statsChartInstances.push(chart);
|
||||
}
|
||||
|
||||
// --- Weight vs 7-day rolling caloric balance (IDEA 7) ---
|
||||
if (weights.length > 3 && dailyFood.length > 3) {
|
||||
const block = document.createElement("div");
|
||||
block.className = "stats-chart-block";
|
||||
block.innerHTML = `
|
||||
<div class="sd-title">Вес и баланс калорий</div>
|
||||
<div class="chart-wrap"><canvas id="stats-corr-chart" height="200" style="touch-action:pan-y"></canvas></div>`;
|
||||
container.appendChild(block);
|
||||
|
||||
// Build date-indexed maps
|
||||
const wMap = {};
|
||||
weights.forEach(w => wMap[w.date] = w.weight_kg);
|
||||
const fMap = {};
|
||||
dailyFood.forEach(d => fMap[d.date] = Math.round(d.kcal ?? 0));
|
||||
const aMap = {};
|
||||
dailyActivity.forEach(d => aMap[d.date] = Math.round(d.kcal ?? 0));
|
||||
|
||||
// Generate all dates in range
|
||||
const allDates = [];
|
||||
if (weights.length > 0 && dailyFood.length > 0) {
|
||||
const startDate = weights[0].date < dailyFood[0].date ? weights[0].date : dailyFood[0].date;
|
||||
const endDate = new Date().toISOString().slice(0, 10);
|
||||
let cur = new Date(startDate + "T00:00:00");
|
||||
const end = new Date(endDate + "T00:00:00");
|
||||
while (cur <= end) {
|
||||
allDates.push(cur.toISOString().slice(0, 10));
|
||||
cur.setDate(cur.getDate() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Interpolate weight (fill gaps with last known value)
|
||||
const weightData = [];
|
||||
let lastW = null;
|
||||
for (const d of allDates) {
|
||||
if (wMap[d] != null) lastW = wMap[d];
|
||||
weightData.push(lastW);
|
||||
}
|
||||
|
||||
// 7-day rolling average of caloric balance
|
||||
const balanceData = [];
|
||||
for (let i = 0; i < allDates.length; i++) {
|
||||
const window7 = [];
|
||||
for (let j = Math.max(0, i - 6); j <= i; j++) {
|
||||
const dDate = allDates[j];
|
||||
if (fMap[dDate] != null) {
|
||||
const dayBalance = fMap[dDate] - (bmr ?? 0) - (aMap[dDate] ?? 0);
|
||||
window7.push(dayBalance);
|
||||
}
|
||||
}
|
||||
balanceData.push(window7.length > 0 ? Math.round(window7.reduce((a, b) => a + b, 0) / window7.length) : null);
|
||||
}
|
||||
|
||||
const corrLabels = allDates.map(d => {
|
||||
const parts = d.split("-");
|
||||
return parts[2] + "." + parts[1];
|
||||
});
|
||||
|
||||
const ctx = block.querySelector("#stats-corr-chart");
|
||||
const chart = new Chart(ctx, {
|
||||
type: "line",
|
||||
data: {
|
||||
labels: corrLabels,
|
||||
datasets: [
|
||||
{
|
||||
label: "Вес",
|
||||
data: weightData,
|
||||
borderColor: "#bb86fc",
|
||||
borderWidth: 1.5,
|
||||
pointRadius: 0,
|
||||
fill: false,
|
||||
tension: 0.3,
|
||||
yAxisID: "y",
|
||||
spanGaps: true,
|
||||
},
|
||||
{
|
||||
label: "Баланс 7д",
|
||||
data: balanceData,
|
||||
borderColor: "#81c784",
|
||||
borderWidth: 1.5,
|
||||
pointRadius: 0,
|
||||
fill: false,
|
||||
tension: 0.3,
|
||||
yAxisID: "y1",
|
||||
spanGaps: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: "bottom",
|
||||
labels: { color: "#888", font: { size: 12 } },
|
||||
},
|
||||
zoom: {
|
||||
zoom: {
|
||||
wheel: { enabled: true, modifierKey: "ctrl", speed: 0.02 },
|
||||
pinch: { enabled: true },
|
||||
mode: "x",
|
||||
onZoomStart: ({chart, event}) => {
|
||||
if (event && event.type === "wheel") return true;
|
||||
const wrap = chart.canvas.closest(".chart-wrap");
|
||||
return wrap && wrap.classList.contains("chart-full");
|
||||
},
|
||||
},
|
||||
pan: { enabled: false },
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: { ticks: { color: "#888", maxTicksLimit: 8 }, grid: { color: "#333" } },
|
||||
y: {
|
||||
position: "left",
|
||||
title: { display: true, text: "кг", color: "#bb86fc" },
|
||||
ticks: { color: "#bb86fc" },
|
||||
grid: { color: "#333" },
|
||||
},
|
||||
y1: {
|
||||
position: "right",
|
||||
title: { display: true, text: "ккал", color: "#81c784" },
|
||||
ticks: { color: "#81c784" },
|
||||
grid: { drawOnChartArea: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
statsChartInstances.push(chart);
|
||||
}
|
||||
|
||||
// If no data at all
|
||||
if (container.innerHTML === "") {
|
||||
container.innerHTML = `<div class="log-empty">Недостаточно данных для графиков</div>`;
|
||||
|
|
@ -1617,6 +1779,7 @@ async function renderGpsScreen(container) {
|
|||
<button id="gps-rec-btn" class="btn-primary gps-rec ${isRec ? "recording" : ""}">
|
||||
${isRec ? "Трекинг" : "Старт"}
|
||||
</button>
|
||||
<button id="gps-goals-btn" class="btn-secondary" style="padding:8px 12px;font-size:13px">Цели</button>
|
||||
<div class="gps-menu-wrap">
|
||||
<button id="gps-menu-btn" class="gps-menu-btn">⋮</button>
|
||||
<div id="gps-menu" class="gps-menu hidden">
|
||||
|
|
@ -1734,6 +1897,144 @@ async function renderGpsScreen(container) {
|
|||
}
|
||||
}, true);
|
||||
};
|
||||
|
||||
// Goals button — manage goal markers
|
||||
container.querySelector("#gps-goals-btn").onclick = () => showGoalMarkersModal();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Goal markers management modal (IDEA 2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function showGoalMarkersModal() {
|
||||
const markers = await DB.getGoalMarkers();
|
||||
|
||||
let html = `
|
||||
<h3>Цели (маркеры)</h3>
|
||||
<div id="gm-list" class="goal-markers-list">
|
||||
${markers.length ? markers.map(m => `
|
||||
<div class="goal-marker-card" data-mid="${m.id}">
|
||||
<div class="goal-marker-info">
|
||||
<div class="goal-marker-name">${esc(m.name)}</div>
|
||||
<div class="goal-marker-coords">${m.lat.toFixed(5)}, ${m.lon.toFixed(5)}</div>
|
||||
${m.notes ? `<div class="goal-marker-notes">${esc(m.notes)}</div>` : ""}
|
||||
</div>
|
||||
<button class="del-btn gm-del" data-mid="${m.id}">✕</button>
|
||||
</div>
|
||||
`).join("") : `<div class="prod-hint">Нет маркеров. Нажмите "Добавить" для создания.</div>`}
|
||||
</div>
|
||||
<button id="gm-add" class="btn-primary full-width" style="margin-top:12px">Добавить маркер</button>`;
|
||||
|
||||
showModal(html, () => {});
|
||||
|
||||
// Hide save/cancel since this is a management modal
|
||||
setTimeout(() => {
|
||||
const overlay = document.querySelector(".modal-overlay");
|
||||
if (!overlay) return;
|
||||
const btns = overlay.querySelector(".modal-btns");
|
||||
if (btns) btns.style.display = "none";
|
||||
|
||||
// Delete buttons
|
||||
overlay.querySelectorAll(".gm-del").forEach(btn => {
|
||||
btn.onclick = async (e) => {
|
||||
e.stopPropagation();
|
||||
await DB.deleteGoalMarker(parseInt(btn.dataset.mid));
|
||||
// Refresh modal
|
||||
overlay.remove();
|
||||
history.back();
|
||||
showGoalMarkersModal();
|
||||
};
|
||||
});
|
||||
|
||||
// Add button
|
||||
overlay.querySelector("#gm-add").onclick = () => {
|
||||
overlay.remove();
|
||||
history.back();
|
||||
showAddGoalMarkerModal();
|
||||
};
|
||||
}, 100);
|
||||
}
|
||||
|
||||
async function showAddGoalMarkerModal() {
|
||||
showModal(`
|
||||
<h3>Новый маркер</h3>
|
||||
<div class="form-group">
|
||||
<label>Название</label>
|
||||
<input id="gm-name" type="text" placeholder="Вершина холма, парк..." />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Заметка</label>
|
||||
<input id="gm-notes" type="text" placeholder="" />
|
||||
</div>
|
||||
<div id="gm-map-wrap" class="gm-map-wrap">
|
||||
<div id="gm-map" class="gm-map"></div>
|
||||
<div id="gm-coords" class="gm-coords">Нажмите на карту для выбора точки</div>
|
||||
</div>
|
||||
<input id="gm-lat" type="hidden" />
|
||||
<input id="gm-lon" type="hidden" />
|
||||
`, async overlay => {
|
||||
const name = overlay.querySelector("#gm-name").value.trim();
|
||||
const lat = parseFloat(overlay.querySelector("#gm-lat").value);
|
||||
const lon = parseFloat(overlay.querySelector("#gm-lon").value);
|
||||
const notes = overlay.querySelector("#gm-notes").value.trim() || null;
|
||||
if (!name) return false;
|
||||
if (isNaN(lat) || isNaN(lon)) return false;
|
||||
await DB.addGoalMarker(name, lat, lon, notes);
|
||||
// Return to goals list
|
||||
showGoalMarkersModal();
|
||||
});
|
||||
|
||||
// Initialize map after DOM is ready
|
||||
setTimeout(() => {
|
||||
const mapEl = document.querySelector("#gm-map");
|
||||
const coordsEl = document.querySelector("#gm-coords");
|
||||
const latEl = document.querySelector("#gm-lat");
|
||||
const lonEl = document.querySelector("#gm-lon");
|
||||
if (!mapEl) return;
|
||||
|
||||
// Center on user's last known position or default
|
||||
let defaultLat = 55.75, defaultLon = 37.62;
|
||||
// Try to get last GPS point from recent sessions
|
||||
DB.getGpsSessions(1).then(async sessions => {
|
||||
if (sessions.length > 0) {
|
||||
const pts = await DB.getGpsPoints(sessions[0].id);
|
||||
if (pts.length > 0) {
|
||||
const last = pts[pts.length - 1];
|
||||
defaultLat = last.lat;
|
||||
defaultLon = last.lon;
|
||||
map.setView([defaultLat, defaultLon], 14);
|
||||
}
|
||||
}
|
||||
}).catch(() => {});
|
||||
|
||||
L.Icon.Default.imagePath = "lib/";
|
||||
const map = L.map(mapEl, { attributionControl: false }).setView([defaultLat, defaultLon], 14);
|
||||
addZoomDisplay(map);
|
||||
const CachedLayer = TileCache.createLayer();
|
||||
new CachedLayer({ maxZoom: 19 }).addTo(map);
|
||||
|
||||
let marker = null;
|
||||
map.on("click", (e) => {
|
||||
const lat = e.latlng.lat;
|
||||
const lon = e.latlng.lng;
|
||||
latEl.value = lat;
|
||||
lonEl.value = lon;
|
||||
coordsEl.textContent = `${lat.toFixed(5)}, ${lon.toFixed(5)}`;
|
||||
if (marker) map.removeLayer(marker);
|
||||
marker = L.marker([lat, lon]).addTo(map);
|
||||
});
|
||||
|
||||
// Also try current position
|
||||
navigator.geolocation?.getCurrentPosition(
|
||||
pos => {
|
||||
if (!latEl.value) {
|
||||
map.setView([pos.coords.latitude, pos.coords.longitude], 15);
|
||||
}
|
||||
},
|
||||
() => {},
|
||||
{ enableHighAccuracy: false, timeout: 5000, maximumAge: 60000 }
|
||||
);
|
||||
}, 200);
|
||||
}
|
||||
|
||||
function renderRecordingInline(container) {
|
||||
|
|
@ -2214,6 +2515,16 @@ async function showSessionDetail(sessionId) {
|
|||
</div>
|
||||
</div>` : ""}
|
||||
|
||||
<div class="sd-section" id="sd-goals-section" style="display:none">
|
||||
<div class="sd-title">Цели</div>
|
||||
<div id="sd-goals-list"></div>
|
||||
</div>
|
||||
|
||||
<div class="sd-section" id="sd-food-cost-section" style="display:none">
|
||||
<div class="sd-title">Стоимость тренировки</div>
|
||||
<div id="sd-food-cost" class="food-cost"></div>
|
||||
</div>
|
||||
|
||||
${session.bike_id ? `<div class="sd-section"><div class="sd-title">Велосипед</div><div class="sd-row"><div class="sd-stat"><span class="sd-val" id="sd-bike-name">...</span><span class="sd-lbl">Название</span></div></div></div>` : ''}
|
||||
${session.activity_type === 'bike' ? `<div class="sd-section"><div class="sd-row"><div class="form-group" style="width:100%;margin:0"><label>Велосипед</label><select id="sd-bike-select" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg)"><option value="">-- Нет --</option></select></div></div></div>` : ''}
|
||||
<button id="sd-compare" class="btn-secondary full-width" style="margin-top:16px">Сравнить с...</button>
|
||||
|
|
@ -2552,6 +2863,123 @@ async function showSessionDetail(sessionId) {
|
|||
renderScreen("gps", true);
|
||||
};
|
||||
}, 100);
|
||||
|
||||
// IDEA 2: Goal markers — show closest approach for each marker
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const goalSection = document.querySelector("#sd-goals-section");
|
||||
const goalsList = document.querySelector("#sd-goals-list");
|
||||
if (!goalSection || !goalsList || points.length < 2) return;
|
||||
|
||||
const markers = await DB.getGoalMarkers();
|
||||
if (markers.length === 0) return;
|
||||
|
||||
// For each marker, find closest point in this track
|
||||
const results = [];
|
||||
for (const marker of markers) {
|
||||
let minDist = Infinity;
|
||||
let closestIdx = 0;
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const d = GPS.distance(points[i].lat, points[i].lon, marker.lat, marker.lon);
|
||||
if (d < minDist) { minDist = d; closestIdx = i; }
|
||||
}
|
||||
|
||||
// Check previous sessions for progress comparison
|
||||
const prevSessions = await DB.getGpsSessions(50);
|
||||
let bestPrevDist = Infinity;
|
||||
let bestPrevDate = null;
|
||||
for (const ps of prevSessions) {
|
||||
if (ps.id === sessionId) continue;
|
||||
const psPts = await DB.getGpsPoints(ps.id);
|
||||
if (psPts.length < 2) continue;
|
||||
for (const pp of psPts) {
|
||||
const d = GPS.distance(pp.lat, pp.lon, marker.lat, marker.lon);
|
||||
if (d < bestPrevDist) {
|
||||
bestPrevDist = d;
|
||||
bestPrevDate = ps.date;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results.push({
|
||||
name: marker.name,
|
||||
minDist: Math.round(minDist),
|
||||
closestIdx,
|
||||
bestPrevDist: bestPrevDist < Infinity ? Math.round(bestPrevDist) : null,
|
||||
bestPrevDate,
|
||||
});
|
||||
}
|
||||
|
||||
goalSection.style.display = "";
|
||||
goalsList.innerHTML = results.map(r => {
|
||||
const distStr = r.minDist >= 1000 ? (r.minDist / 1000).toFixed(1) + " км" : r.minDist + " м";
|
||||
let progressHtml = "";
|
||||
if (r.bestPrevDist != null) {
|
||||
const diff = r.bestPrevDist - r.minDist;
|
||||
if (diff > 0) {
|
||||
progressHtml = `<span class="goal-progress goal-better">На ${diff >= 1000 ? (diff / 1000).toFixed(1) + " км" : diff + " м"} ближе (было ${r.bestPrevDist >= 1000 ? (r.bestPrevDist / 1000).toFixed(1) + " км" : r.bestPrevDist + " м"}, ${r.bestPrevDate})</span>`;
|
||||
} else if (diff < 0) {
|
||||
progressHtml = `<span class="goal-progress goal-worse">Рекорд: ${r.bestPrevDist >= 1000 ? (r.bestPrevDist / 1000).toFixed(1) + " км" : r.bestPrevDist + " м"} (${r.bestPrevDate})</span>`;
|
||||
} else {
|
||||
progressHtml = `<span class="goal-progress">Повторение рекорда</span>`;
|
||||
}
|
||||
}
|
||||
// Progress bar: ratio of current distance to a reasonable max (e.g. 5000m)
|
||||
const maxBar = Math.max(r.minDist, r.bestPrevDist ?? r.minDist, 100);
|
||||
const pct = Math.max(0, Math.min(100, 100 - (r.minDist / maxBar * 100)));
|
||||
return `<div class="goal-marker-item">
|
||||
<div class="goal-marker-name">${esc(r.name)}</div>
|
||||
<div class="goal-marker-dist">Ближайшая точка: ${distStr} (точка #${r.closestIdx})</div>
|
||||
<div class="goal-bar-wrap"><div class="goal-bar-fill" style="width:${pct}%"></div></div>
|
||||
${progressHtml}
|
||||
</div>`;
|
||||
}).join("");
|
||||
} catch (e) { console.error("[goals]", e); }
|
||||
}, 500);
|
||||
|
||||
// IDEA 8: Food cost of workout
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const costSection = document.querySelector("#sd-food-cost-section");
|
||||
const costEl = document.querySelector("#sd-food-cost");
|
||||
if (!costSection || !costEl) return;
|
||||
const sessionKcal = session.total_kcal ?? stats.kcal;
|
||||
if (!sessionKcal || sessionKcal <= 0) return;
|
||||
|
||||
const foods = await DB.getFrequentFoods(7);
|
||||
if (foods.length === 0) return;
|
||||
|
||||
// Build "cost" descriptions: N portions of food
|
||||
const parts = [];
|
||||
for (const f of foods) {
|
||||
if (!f.kcal || f.kcal <= 0) continue;
|
||||
// kcal per one serving (as user ate it)
|
||||
const servingKcal = Math.round(f.kcal);
|
||||
if (servingKcal <= 0) continue;
|
||||
const portions = Math.round(sessionKcal / servingKcal * 10) / 10;
|
||||
const amountStr = f.amount_g ? f.amount_g + "г" : "";
|
||||
parts.push({
|
||||
name: f.product_name,
|
||||
portions,
|
||||
servingKcal,
|
||||
amountStr,
|
||||
});
|
||||
}
|
||||
|
||||
if (parts.length === 0) return;
|
||||
|
||||
// Take first 3, build readable string
|
||||
const display = parts.slice(0, 3);
|
||||
const lines = display.map(p =>
|
||||
`${p.portions} x ${esc(p.name)}${p.amountStr ? " (" + p.amountStr + ", " + p.servingKcal + " кк)" : ""}`
|
||||
);
|
||||
|
||||
costSection.style.display = "";
|
||||
costEl.innerHTML = `<div class="food-cost-total">${Math.round(sessionKcal)} ккал =</div>
|
||||
<div class="food-cost-items">${lines.map(l => `<div class="food-cost-line">${l}</div>`).join('<div class="food-cost-or">или</div>')}</div>`;
|
||||
} catch (e) { console.error("[food-cost]", e); }
|
||||
}, 600);
|
||||
|
||||
} catch(e) { console.error("[showSessionDetail] error:", e, JSON.stringify(e)); }
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue