diff --git a/README.md b/README.md index 10843ea..000059c 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ - **Дневник питания** — ~13000 продуктов с меткой источника (Магнит, 5ка, ИОН, KDV, Гриднев, свои). Поиск порядко-независимый (слова в любом порядке). Каталог чистится от неиспользованного импорта без метки (health-diet и пр.) кнопкой в настройках — съеденные и в рецептах сохраняются. 37 нутриентов (витамины, минералы, omega-3/6, вода, клетчатка). Рецепты с термообработкой. Формулы порций (4*30, 300+50). - **GPS трекинг** — запись маршрутов на велосипеде/пешком в фоне (foreground service, точки пишутся при выключенном экране; один файл на сессию с id сессии в имени `gps_points__s.jsonl` для однозначной привязки, переживает START_STICKY-перезапуск ОС; полный до-импорт нативных точек по timestamp при финализации — без потерь, даже если экран был выключен всю запись, а при пробуждении экрана точки видимо нарастают на карте по мере импорта). При живой записи нативный файл — источник истины для БД, watchPosition только рисует маркер (без дублей одного фикса с разными ts). Восстановление точек из файлов на устройстве — матч файла к сессии по id/дате, дозаполнение разрывов с дедупом по близости ±2.5с, безопасный пересчёт без удаления точек, чистка уже импортированных файлов. Оффлайн карты (Leaflet + IndexedDB кэш). Импорт GPX/FTB (FitoTrack). Экспорт GPX. Фильтр altitude outlier'ов и GPS-залипаний. Отбор точек по точности (жёсткий порог по настройке «Мин. точность» — рубит грубые сотовые фиксы ~800м, оставляет слабый но точный GPS; где сигнала нет — честный разрыв). Направление (bearing) каждой точки пишется в БД. Вращение карты по компасу (leaflet-rotate, EMA-сглаживание, gimbal lock компенсация) и course-up по GPS-курсу (в ландшафте карта доворачивается вместе с экраном — курс к физическому верху). Ландшафтный режим (CSS rotation + панель справа, 3 режима: off/auto/lock). Dead reckoning при потере GPS (гироскоп + акселерометр). Placeholder до GPS (пиксельарт). Карта проезженных дорог (дедупликация 2м, вело/пешие). Настраиваемый цвет линии трека (чтобы отличать от цветных дорог). Навигационный маркер позиции с треугольником-направленцем (по компасу или GPS-курсу); порядок слоёв тайлы<дороги<маршрут<трек<маркер. Импорт тайлов по регионам (SQLite, роутер LAN). - **BLE пульсометр** — подключение к FitPro браслетам и стандартным HR датчикам (0x180D). ЧСС, SpO2, давление в реальном времени при записи трека. -- **Расчёт ккал** — велосипед: НЕТТО-модель (суточный баланс уже учитывает BMR, поэтому активность считается сверх покоя). Кручение = max(физика мощности из gradient+Crr+CdA / КПД, эмпирический MET-нетто по скорости); накат (freewheel, `power≤0` — «велик катился бы сам») = только базовый обмен. Crr на грунтах уточняется по вибрации акселерометра (до 0.020 на траве/колее). Ходьба с велосипедом в горку (только односкоростной) отделяется автоматически по скорости ниже каденса 30 об/мин и не засчитывается как езда. MET-таблица для ходьбы/бега и тренировок (приседания 6.0, скакалка 12.3, растяжки 2.3, бассейн 8.0). Набор высоты — кумулятивный гистерезис (устойчив на плотных треках, где per-point разница <1м); абсолютная точность набора ограничена сырым GPS-альтиметром (порядка десятков процентов, без модели рельефа не разрешается). Известное ограничение: `max(физика, MET)` не складывает накладные расходы, поэтому подъёмы по ккал слегка недосчитываются. Пункт меню «Пересчитать высоту/калории» пересчитывает статистику всех треков без изменения точек. Профили велосипедов. BMR по Mifflin-St Jeor с коэффициентом активности. +- **Расчёт ккал** — велосипед: НЕТТО-модель (суточный баланс уже учитывает BMR, поэтому активность считается сверх покоя). Кручение = max(физика мощности из gradient+Crr+CdA / КПД, эмпирический MET-нетто по скорости); накат (freewheel, `power≤0` — «велик катился бы сам») = только базовый обмен. Crr на грунтах уточняется по вибрации акселерометра (до 0.020 на траве/колее). Ходьба с велосипедом в горку (только односкоростной) отделяется автоматически по скорости ниже каденса 30 об/мин и не засчитывается как езда. MET-таблица для ходьбы/бега и тренировок (приседания 6.0, скакалка 12.3, растяжки 2.3, бассейн 8.0). Набор высоты — кумулятивный гистерезис (устойчив на плотных треках, где per-point разница <1м); абсолютная точность набора ограничена сырым GPS-альтиметром (порядка десятков процентов, без модели рельефа не разрешается). Известное ограничение: `max(физика, MET)` не складывает накладные расходы, поэтому подъёмы по ккал слегка недосчитываются. Пункт меню «Пересчитать высоту/калории» пересчитывает статистику всех треков без изменения точек. Профили велосипедов. BMR по Mifflin-St Jeor с коэффициентом активности. Вес водителя для ккал берётся из **последнего взвешивания** (настройки - запас); каждая новая поездка **хранит вес на момент записи** (`gps_sessions.rider_weight_kg`), поэтому пересчёт старого трека считает по тогдашнему весу, а не текущему (старые треки без сохранённого веса - по последнему взвешиванию). Поле веса в настройках авто-синкается с последним взвешиванием. Баланс ккал вверху, тренд «Среднее за 7 дн.» и графики (вес/баланс, окна 30/90) считаются от **выбранного дня** (окно кончается на нём), а не всегда от «сегодня». - **Дистанция на разрывах GPS** — на «слепых» участках (интервал между фиксами > `gapThresholdSec`) путь считается по скорости (speed×dt, с ограничением по макс. скорости), а не прямой хордой — иначе разрывы занижают/искажают длину. Телепорт-скачки без временного разрыва отбрасываются. Расчёт зависит только от времени и скорости, поэтому live-счётчик, «Стоп» и пересчёт дают одинаковую длину. Порог `gapThresholdSec` настраивается; при изменении и пересчёте применяется сразу. - **Тренировки** — приседания (подходы x повторения), скакалка, растяжки, бассейн (дистанция). Автоматический расчёт ккал по MET и весу. - **Нутриенты** — 37 микронутриентов (витамины, минералы, жирные кислоты, женьшень, ресвератрол, CoQ10). Нормы РСП/АУП (РФ/ЕС/США). Фармакокинетика — запасы в организме по полураспаду. diff --git a/docs/session-logs/2026-09-22_balance-selected-day-and-weight.md b/docs/session-logs/2026-09-22_balance-selected-day-and-weight.md new file mode 100644 index 0000000..21e54cc --- /dev/null +++ b/docs/session-logs/2026-09-22_balance-selected-day-and-weight.md @@ -0,0 +1,35 @@ +# Сессия 2026-09-22 - баланс/графики от выбранного дня + вес + +По утверждённому плану `~/.claude/plans/merry-discovering-dream.md`. + +## Часть 1 - окно от выбранного дня +- `getRecentWeights/getDailyFoodTotals/getDailyActivityTotals` (db.js) получили опциональный + `endDate` (по умолч. 'now'). Диапазон сменён с `date >= date('now','-N days')` на + `date > date(,'-N days') AND date <= date()` - ровно N дней, кончая на endDate + (заодно чинит старый off-by-one N+1). +- `renderToday` (main.js): BMR верхнего баланса `getRecentWeights(7, state.date)`; тренд + `getDaily*(7, state.date)` (убрал костыль `slice(-7)`); `renderStatsCharts(...,refDate=state.date)` + - все три запроса графиков и чипы 30/90 теперь от выбранного дня. +- Единый BMR у баланс-графика оставлен (от веса выбранного дня); per-day BMR не делал. + +## Часть 2 - вес +### 2a синк настроек +- `DB.addWeight`: после вставки `setProfileValue("weight_kg", latest.weight_kg)` - настройки + всегда = последнее взвешивание. Колбэк модалки веса (main.js) обновляет `state.latestWeight` + и `state.profile.weight_kg`. +### 2b вес на момент поездки +- Миграция `gps_sessions.rider_weight_kg REAL`. Новый сеттер `DB.setSessionRiderWeight` (отдельно + от finishGpsSession, её зовёт и пересчёт). +- Запись: при финализации трека (gps.js) сохраняется `GPS.recording.weightKg`. +- Резолв веса везде первым берёт `session.rider_weight_kg`: `recalculateSession` (recorded+manual), + `recomputeAllStats` (per-session в цикле), деталь трека (2 места), `recomputeStatsFor`, разбор + повторов. Старые сессии (NULL) -> latest weigh-in (без регресса). + +## Ответ пользователю (вопрос) +Ккал брал вес из последнего взвешивания (не из настроек; настройки - запас). Теперь новые +поездки хранят вес на момент записи, пересчёт использует его; настройки авто-синкаются с весом. + +## Проверка +- SQL-диапазон на десктоп-БД: `> date(end,'-7') AND <= date(end)` = ровно 7 дней, кончая на + выбранном дне (старый `>=` давал 8). Подтверждено. +- Дальше: десктоп (Xvfb) - листание дней двигает баланс/тренд/графики; вес-синк; затем APK+тело. diff --git a/frontend/js/db.js b/frontend/js/db.js index c94dc91..00e9198 100644 --- a/frontend/js/db.js +++ b/frontend/js/db.js @@ -48,6 +48,9 @@ async function addWeight(date, time, kg) { "INSERT INTO weight_log (date, time, weight_kg) VALUES (?1, ?2, ?3)", [date, time, kg] ); + // Keep the settings weight in sync with the latest weigh-in (today's if there is one). + const latest = await getLatestWeight(); + if (latest && latest.weight_kg != null) await setProfileValue("weight_kg", latest.weight_kg); } async function deleteWeight(id) { @@ -65,13 +68,16 @@ async function getLatestWeight() { return rows.length ? rows[0] : null; } -async function getRecentWeights(days) { +// `days` calendar days ENDING at endDate (default today). Range is (end-days, end] so it is +// exactly `days` days (the old `>= date('now','-N')` gave N+1) and follows the selected day. +async function getRecentWeights(days, endDate) { + const end = endDate || "now"; return select( `SELECT date, MIN(weight_kg) as weight_kg FROM weight_log - WHERE date >= date('now', ?1) + WHERE date > date(?2, ?1) AND date <= date(?2) GROUP BY date ORDER BY date ASC`, - [`-${days ?? 30} days`] + [`-${days ?? 30} days`, end] ); } @@ -680,25 +686,28 @@ async function getActivityTotalsForDate(date) { return { kcal: manualKcal + gpsKcal, minutes: manual[0]?.minutes ?? 0 }; } -async function getDailyFoodTotals(days) { +// N calendar days ending at endDate (default today), range (end-N, end] = exactly N days. +async function getDailyFoodTotals(days, endDate) { + const end = endDate || "now"; return select( `SELECT date, SUM(kcal) as kcal, SUM(protein_g) as protein_g, SUM(fat_g) as fat_g, SUM(carbs_g) as carbs_g - FROM food_log WHERE date >= date('now', ?1) + FROM food_log WHERE date > date(?2, ?1) AND date <= date(?2) GROUP BY date ORDER BY date ASC`, - [`-${days ?? 30} days`] + [`-${days ?? 30} days`, end] ); } -async function getDailyActivityTotals(days) { +async function getDailyActivityTotals(days, endDate) { const offset = `-${days ?? 30} days`; + const end = endDate || "now"; return select( `SELECT date, SUM(kcal) as kcal FROM ( - SELECT date, kcal FROM activity_log WHERE date >= date('now', ?1) + SELECT date, kcal FROM activity_log WHERE date > date(?2, ?1) AND date <= date(?2) UNION ALL - SELECT date, total_kcal as kcal FROM gps_sessions WHERE date >= date('now', ?2) AND total_kcal IS NOT NULL + SELECT date, total_kcal as kcal FROM gps_sessions WHERE date > date(?2, ?1) AND date <= date(?2) AND total_kcal IS NOT NULL ) GROUP BY date ORDER BY date ASC`, - [offset, offset] + [offset, end] ); } @@ -1072,6 +1081,13 @@ async function updateSessionBike(sessionId, bikeId) { await execute("UPDATE gps_sessions SET bike_id = ?1 WHERE id = ?2", [bikeId, sessionId]); } +// Store the rider's weight at ride time (set once when a recording finalises). A separate +// setter from finishGpsSession, which recompute also calls — recompute must NOT overwrite it. +async function setSessionRiderWeight(sessionId, kg) { + if (kg == null || isNaN(kg)) return; + await execute("UPDATE gps_sessions SET rider_weight_kg = ?1 WHERE id = ?2", [kg, sessionId]); +} + // Bulk-set terrain altitude (altitude_srtm_m) for points by id. rows: [{id, srtm}]. // Batched CASE update — values are numbers (ids small, no i64 corruption), safe to inline. async function updatePointsSrtm(rows) { @@ -1375,6 +1391,7 @@ window.DB.saveBike = saveBike; window.DB.updateBike = updateBike; window.DB.deleteBike = deleteBike; window.DB.updateSessionBike = updateSessionBike; +window.DB.setSessionRiderWeight = setSessionRiderWeight; window.DB.updatePointsSrtm = updatePointsSrtm; window.DB.getBikeMaintenanceLog = getBikeMaintenanceLog; window.DB.addBikeMaintenance = addBikeMaintenance; @@ -1697,6 +1714,7 @@ async function runMigrations() { if (!gsCols.includes("pauses")) await execute("ALTER TABLE gps_sessions ADD COLUMN pauses TEXT"); // JSON [{startTs,endTs,...}] manual pauses if (!gsCols.includes("wind_marks")) await execute("ALTER TABLE gps_sessions ADD COLUMN wind_marks TEXT"); // JSON [{startTs,endTs}] headwind labels (ground truth for wind estimate) if (!gsCols.includes("gap_filled")) await execute("ALTER TABLE gps_sessions ADD COLUMN gap_filled INTEGER DEFAULT 0"); // BRouter gap-fill applied (reversible) + if (!gsCols.includes("rider_weight_kg")) await execute("ALTER TABLE gps_sessions ADD COLUMN rider_weight_kg REAL"); // rider weight at ride time; recompute uses it (NULL on old sessions -> latest weigh-in) // Add tire_pressure_bar to bikes (for Crr correction by tire pressure) const bikeCols = (await select("PRAGMA table_info(bikes)")).map(r => r.name); diff --git a/frontend/js/gps.js b/frontend/js/gps.js index d580888..d8f80f3 100644 --- a/frontend/js/gps.js +++ b/frontend/js/gps.js @@ -1481,6 +1481,8 @@ GPS.stopRecording = async function() { stats.elevGain, stats.elevLoss, recPauses ); try { await DB.setSessionWindMarks(GPS.recording.sessionId, recWindMarks); } catch (e) { console.error("[GPS] wind marks save failed:", e); } + // Persist the rider weight used for this ride, so a later recompute keeps ride-time weight. + try { await DB.setSessionRiderWeight(GPS.recording.sessionId, GPS.recording.weightKg); } catch (e) { console.error("[GPS] rider weight save failed:", e); } // DB save confirmed — now safe to delete the points file if (nativePointsFile) { try { await window.__TAURI__.core.invoke("delete_points_file", { filename: nativePointsFile }); } catch {} @@ -1949,7 +1951,7 @@ GPS.recalculateSession = async function(sessionId, opts) { // Compute stats and finish const latestW = await DB.getLatestWeight(); const profile = await DB.getFullProfile(); - const weightKg = parseFloat(latestW?.weight_kg) || parseFloat(profile?.weight_kg) || 80; + const weightKg = parseFloat(session.rider_weight_kg) || parseFloat(latestW?.weight_kg) || parseFloat(profile?.weight_kg) || 80; const bike = session.bike_id ? await DB.getBike(session.bike_id) : await DB.getActiveBike(); const CRR_MAP = { asphalt: 0.004, mixed: 0.006, gravel: 0.008 }; const stats = GPS.computeStats(sorted, weightKg, { @@ -2072,10 +2074,10 @@ GPS.recalculateSession = async function(sessionId, opts) { } } - // Get user weight: latest weight log, or profile, or 80 + // Rider weight: the ride's stored weight (ride time), else latest weigh-in, else profile, else 80. const latestW = await DB.getLatestWeight(); const profile = await DB.getFullProfile(); - const weightKg = parseFloat(latestW?.weight_kg) || parseFloat(profile?.weight_kg) || 80; + const weightKg = parseFloat(session.rider_weight_kg) || parseFloat(latestW?.weight_kg) || parseFloat(profile?.weight_kg) || 80; // Get bike if linked to session, otherwise use active bike const bike = session.bike_id ? await DB.getBike(session.bike_id) : await DB.getActiveBike(); @@ -2139,7 +2141,8 @@ GPS.recomputeAllStats = async function(onProgress) { const bike = session.bike_id ? await DB.getBike(session.bike_id) : await DB.getActiveBike(); const bulkPauses = Array.isArray(session.pauses) ? session.pauses : (typeof session.pauses === "string" ? (() => { try { return JSON.parse(session.pauses); } catch { return []; } })() : []); - const stats = GPS.computeStats(points, weightKg, { + const sessW = parseFloat(session.rider_weight_kg) || weightKg; // ride-time weight, else the fallback + const stats = GPS.computeStats(points, sessW, { bikeWeightKg: bike?.weight_kg ?? 0, activityType: session.activity_type ?? "bike", surfaceCrr: CRR_MAP[bike?.surface_type] ?? 0.006, diff --git a/frontend/js/main.js b/frontend/js/main.js index 53dc36e..46f5256 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -318,7 +318,7 @@ async function renderToday(container) { return; } - let recentWeightsForBmr = await DB.getRecentWeights(7); + let recentWeightsForBmr = await DB.getRecentWeights(7, state.date); let bmr = calcBmr(recentWeightsForBmr); let actLevel = parseFloat(state.profile.activity_level) || 1.2; let dailyBase = bmr ? Math.round(bmr * actLevel) : null; @@ -512,12 +512,10 @@ async function renderToday(container) { let trendEl = container.querySelector("#balance-trend"); if (!trendEl || dailyBase == null) return; let [food7, act7] = await Promise.all([ - DB.getDailyFoodTotals(7), - DB.getDailyActivityTotals(7), + DB.getDailyFoodTotals(7, state.date), + DB.getDailyActivityTotals(7, state.date), ]); - // `date('now','-7 days')` is inclusive of both ends → 8 calendar days. Keep the latest 7 - // so the "7-day rolling" trend really covers 7 (matches the 7-day chart below). - food7 = food7.slice(-7); + // The range is now exactly 7 days ending at the selected day (no off-by-one slice needed). if (food7.length === 0) return; let foodMap = {}; food7.forEach(d => foodMap[d.date] = Math.round(d.kcal ?? 0)); @@ -619,12 +617,13 @@ async function renderToday(container) { let statsContainer = container.querySelector("#stats-charts"); let statsPeriod = container.querySelector(".stats-period"); if (statsContainer && statsPeriod) { - renderStatsCharts(statsContainer, 30, foodTotals, dailyBase); + let chartRefDate = state.date; // window ends at the selected day + renderStatsCharts(statsContainer, 30, foodTotals, dailyBase, chartRefDate); statsPeriod.querySelectorAll(".type-chip").forEach(chip => { chip.onclick = () => { statsPeriod.querySelectorAll(".type-chip").forEach(c => c.classList.remove("selected")); chip.classList.add("selected"); - renderStatsCharts(statsContainer, parseInt(chip.dataset.days), foodTotals, dailyBase); + renderStatsCharts(statsContainer, parseInt(chip.dataset.days), foodTotals, dailyBase, chartRefDate); }; }); } @@ -636,7 +635,7 @@ async function renderToday(container) { let statsChartInstances = []; -async function renderStatsCharts(container, days, foodTotals, bmr) { +async function renderStatsCharts(container, days, foodTotals, bmr, refDate) { // Destroy old charts statsChartInstances.forEach(c => c.destroy()); statsChartInstances.length = 0; @@ -646,9 +645,9 @@ async function renderStatsCharts(container, days, foodTotals, bmr) { let weights, dailyFood, dailyActivity; try { [weights, dailyFood, dailyActivity] = await Promise.all([ - DB.getRecentWeights(days), - DB.getDailyFoodTotals(days), - DB.getDailyActivityTotals(days), + DB.getRecentWeights(days, refDate), + DB.getDailyFoodTotals(days, refDate), + DB.getDailyActivityTotals(days, refDate), ]); } catch (e) { container.innerHTML = `
Ошибка загрузки статистики
`; @@ -1168,7 +1167,12 @@ function showWeightModal() { let kg = parseFloat(overlay.querySelector("#m-kg").value); let time = overlay.querySelector("#m-time").value; if (isNaN(kg)) return false; - await DB.addWeight(state.date, time, kg); + await DB.addWeight(state.date, time, kg); // also syncs profile weight_kg to the latest weigh-in + // Refresh in-memory state so kcal/BMR and the settings weight field are current at once. + try { + state.latestWeight = await DB.getLatestWeight(); + if (state.profile && state.latestWeight?.weight_kg != null) state.profile.weight_kg = state.latestWeight.weight_kg; + } catch {} }); } @@ -5173,7 +5177,7 @@ async function showSessionDetail(sessionId) { elevGain: Math.round(session.elevation_gain_m || 0), elevLoss: Math.round(session.elevation_loss_m || 0), duration: 0, - } : GPS.computeStats(points, state.latestWeight?.weight_kg ?? state.profile?.weight_kg ?? 80, { pauses: session.pauses || [] }); + } : GPS.computeStats(points, session.rider_weight_kg ?? state.latestWeight?.weight_kg ?? state.profile?.weight_kg ?? 80, { pauses: session.pauses || [] }); // Manual-pause total (ms) to exclude from displayed duration let pauseMs = (session.pauses || []).reduce((a, z) => a + Math.max(0, (z.endTs || 0) - (z.startTs || 0)), 0); // Compute duration from points regardless (minus manual pauses) @@ -5182,7 +5186,7 @@ async function showSessionDetail(sessionId) { } // Always compute segments from points (needed for the Segments section) if (!stats.segments && points.length >= 3) { - let fullStats = GPS.computeStats(points, state.latestWeight?.weight_kg ?? state.profile?.weight_kg ?? 80, { pauses: session.pauses || [] }); + let fullStats = GPS.computeStats(points, session.rider_weight_kg ?? state.latestWeight?.weight_kg ?? state.profile?.weight_kg ?? 80, { pauses: session.pauses || [] }); stats.segments = fullStats.segments; } @@ -5514,7 +5518,7 @@ async function showSessionDetail(sessionId) { }; async function recomputeStatsFor(pts) { let latestW = await DB.getLatestWeight(), profile = await DB.getFullProfile(); - let weightKg = parseFloat(latestW?.weight_kg) || parseFloat(profile?.weight_kg) || 80; + let weightKg = parseFloat(session.rider_weight_kg) || parseFloat(latestW?.weight_kg) || parseFloat(profile?.weight_kg) || 80; let bike = session.bike_id ? await DB.getBike(session.bike_id) : await DB.getActiveBike(); let CRR = { asphalt: 0.004, mixed: 0.006, gravel: 0.008 }; let st = GPS.computeStats(pts, weightKg, { @@ -5671,7 +5675,7 @@ async function showSessionDetail(sessionId) { let rep = GPS.detectRepeats(points); if (!rep.found) { body.textContent = "Повторов не найдено (обычная поездка)."; return; } let bike = session.bike_id ? await DB.getBike(session.bike_id) : await DB.getActiveBike(); - let weightKg = state.latestWeight?.weight_kg ?? state.profile?.weight_kg ?? 80; + let weightKg = session.rider_weight_kg ?? state.latestWeight?.weight_kg ?? state.profile?.weight_kg ?? 80; let opts = { activityType: "bike", bikeWeightKg: bike?.weight_kg ?? 0, surfaceCrr: bike?.surface_type, chainringTeeth: bike?.chainring_teeth, cogTeeth: bike?.cog_teeth,