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:
4aiman 2026-05-27 17:45:50 +03:00
commit 9384bb8d2e
3 changed files with 654 additions and 0 deletions

View file

@ -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;
}

View file

@ -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.

View file

@ -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">&#x22EE;</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}">&#x2715;</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)); }
}