Balance/trend/charts follow the selected day; kcal weight is per-ride (stored) and settings weight auto-syncs to the latest weigh-in

This commit is contained in:
4aiman 2026-09-22 11:12:24 +03:00
commit dff9342c0d
5 changed files with 92 additions and 32 deletions

View file

@ -24,7 +24,7 @@
- **Дневник питания** — ~13000 продуктов с меткой источника (Магнит, 5ка, ИОН, KDV, Гриднев, свои). Поиск порядко-независимый (слова в любом порядке). Каталог чистится от неиспользованного импорта без метки (health-diet и пр.) кнопкой в настройках — съеденные и в рецептах сохраняются. 37 нутриентов (витамины, минералы, omega-3/6, вода, клетчатка). Рецепты с термообработкой. Формулы порций (4*30, 300+50).
- **GPS трекинг** — запись маршрутов на велосипеде/пешком в фоне (foreground service, точки пишутся при выключенном экране; один файл на сессию с id сессии в имени `gps_points_<ms>_s<id>.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). Нормы РСП/АУП (РФ/ЕС/США). Фармакокинетика — запасы в организме по полураспаду.

View file

@ -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(<end>,'-N days') AND date <= date(<end>)` - ровно 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+тело.

View file

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

View file

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

View file

@ -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 = `<div class="log-empty">Ошибка загрузки статистики</div>`;
@ -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,