From 7717a731293b8908e6414e048bb2d1b21a6ca0d1 Mon Sep 17 00:00:00 2001 From: 4aiman Date: Thu, 17 Sep 2026 22:11:10 +0300 Subject: [PATCH 01/65] Repeat/interval analysis: auto-find repeated segments in one ride, table + last/first verdict --- PROJECT_MAP.md | 1 + README.md | 1 + .../2026-09-17_repeat-intervals.md | 48 ++++++++++ frontend/css/styles.css | 12 +++ frontend/js/gps.js | 89 +++++++++++++++++++ frontend/js/main.js | 52 +++++++++++ 6 files changed, 203 insertions(+) create mode 100644 docs/session-logs/2026-09-17_repeat-intervals.md diff --git a/PROJECT_MAP.md b/PROJECT_MAP.md index 3f87437..bc4a5f2 100644 --- a/PROJECT_MAP.md +++ b/PROJECT_MAP.md @@ -151,6 +151,7 @@ - Рецепты: showRecipeDetailModal, showRecipeEditorModal, showAddIngredientModal - Тренировки: showSessionDetail (карта, графики скорости/высоты, секция «Сегменты» - сворачиваемый `
`-аккордеон, свёрнут по умолчанию, чтобы кнопки внизу были доступны; пересчёт, оверлей маршрута, панель де-джиттера SG), showRoutesScreen (сохранённые маршруты) - Похвастаться: showShareModal / buildShareImage (сторис-PNG: форма трека + статы; `opts.fields` галочками в модалке, выбор в `localStorage.baldav_share_fields`; прозрачный/фото; в стеке окон - `winPush("share")`), exportShareImage (нативный share/save) +- Разбор повторов: `GPS.detectRepeats(points)` (gps.js) - самоподобие: сетка ~10м, горячие ячейки (>=3 входов), один эталонный проход, разметка проходов по возрастанию позиции (порт `tools/track/pers.py:passes`); возвращает `{found,segLength,passCount,passes}`. Секция «Разбор повторов» в `showSessionDetail` (только вело): таблица заездов + вердикт последний/первый + подсветка участка на карте. Мощность на заезд - из `avgPower` (новое поле возврата `GPS.computeStats`) - Роутинг по проезженному: loadRideGraph (кэш графа из `get_ride_graph`, grid-индекс) / rideRoute (Dijkstra между концами ноги, снап ≤60м, отсев кривого) - в `showRouteEditor` пробуется до BRouter - Прочее: showWeightModal, showSleepModal, showActivityModal, showProfileModal, showBikeModal, showMapDownloadModal, showLogSupplementModal diff --git a/README.md b/README.md index b817e18..59da995 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ - **Объезд с возвратом на маршрут (на ходу)** — на карте записи кнопка **«↪»**: если активный маршрут ведёт через непроезжее место, строит от текущей позиции объезд обратно НА маршрут за местом затыка (отступ ~1.5 км вперёд, растёт при неудаче до конца маршрута), избегая nogo и разворота на 180° (учитывает курс). Показывается ярким навигационным оверлеем (не меняет сохранённый маршрут и не прерывает запись); долгий тап по кнопке убирает оверлей. - **Детект ходьбы в вело-треке** - устойчиво низкая скорость (по умолчанию < 8 км/ч дольше 60с; порог уточняется по передаче для односкоростного) в вело/другой сессии = ведёшь/идёшь пешком. Такие участки красятся отдельно (цвет ходьбы) на детали трека, в статах показывается «пешком: N мин / X км», и они НЕ попадают в «проезженные дороги» (не строятся по ним). Флаг хранится по точкам (`gps_points.walk`), пересчитывается на «Пересчитать»/«Пересчитать всё». - **Роутинг сначала по проезженному** - публичные данные BRouter часто не совпадают с землёй (роутит по несуществующим дорогам). Поэтому планировщик для каждой дорожной ноги сначала пробует проложить путь **по дорогам, где ты реально ездил** (граф из своих треков: узлы на сетке ~20м, исключая пешие куски), и только если проезженного рядом нет или путь выходит сильно кривой - обращается к BRouter. Ридден-нога рисуется тонкой линией (без высот), BRouter-нога - оковкой с раскраской. Граф строится из `gps_points` командой `get_ride_graph`, кэшируется. +- **Разбор повторов (интервалы)** - катаешь один подъём несколько раз за поездку (один трек, без стоп/старт на каждом заезде) - деталь трека сама находит повторяемый участок по самоподобию (сетка ~10м, повторно посещённые ячейки, один эталонный проход) и в сворачиваемой секции «Разбор повторов» показывает таблицу заездов (номер, время, скорость нормированная на длину участка, каденс, мощность) и главное число - **последний / первый**, с вердиктом: >=100% зелёный (можно завтра), 90-99% жёлтый (тяжёлая норма), <90% красный (два дня спокойно). Найденный участок подсвечивается на карте. Только вело. `GPS.detectRepeats`; повторов нет (обычная поездка A-B) - так и пишет. - **Похвастаться (шэр-картинка)** - в детали трека кнопка **«Похвастаться»**: генерирует картинку в формате сторис (9:16) с формой трека и статами (Дистанция/Время/Средняя скорость крупно + набор/ккал/дата), **прозрачную** (PNG с альфой для наложения на своё фото) или **на фото-подложке** из настроек. В модалке предпросмотра галочками выбирается, что класть на картинку (форма трека, дистанция, время, средняя/макс. скорость, набор высоты, калории, дата; выбор запоминается), переключатель прозрачный/фото и кнопки **Поделиться** (системный шэр) и **Сохранить** (SAF). Подложка выбирается в Настройках. - **Сегменты - сворачиваемо** - в детали трека список сегментов высот (climb/descent, до десятков штук) свёрнут в аккордеон (`
`, свёрнут по умолчанию, счётчик в заголовке); тап разворачивает/сворачивает. Кнопки внизу (Похвастаться, Экспорт, Пересчитать, Удалить) остаются доступны без долгого скролла. - **Де-джиттер трека** — сглаживание дрожащего GPS-трека методом Савицкого-Голея (настраиваемое окно/число итераций, живое превью на карте детали трека). Хранится обратимо: оригинал и сглаженный вариант лежат раздельно, калории пересчитываются по сглаженным точкам, откат к оригиналу в один тап. Точность каждого фикса (`accuracy_m`) пишется в БД: при пересчёте трека точки хуже порога «Макс. погрешность GPS» исключаются из расчёта и линии (обратимо — точки в БД остаются; поднять порог = вернуть). Работает во всех пересчётах (кнопка «Пересчитать», «Пересчитать всё», после де-джиттера); импорт/старьё без точности и dead-reckoning заполнения не трогает. diff --git a/docs/session-logs/2026-09-17_repeat-intervals.md b/docs/session-logs/2026-09-17_repeat-intervals.md new file mode 100644 index 0000000..f648790 --- /dev/null +++ b/docs/session-logs/2026-09-17_repeat-intervals.md @@ -0,0 +1,48 @@ +# Сессия 2026-09-17 - автоматический разбор повторов внутри поездки (интервалы) + +## Контекст +Заказ из `docs/gps-model-findings.md` (раздел «БЭКЛОГ: автоматический разбор повторов»). +Пользователь катает интервалы: один подъём 5 раз за поездку, один трек, без стоп/старт. +Нужно: повторы находятся сами по самоподобию, таблица заездов и число «последний/первый». +Действовали по утверждённому плану `~/.claude/plans/merry-discovering-dream.md` +(в docs/plans не копировал - план короткий и одноразовый; здесь ссылка). + +## Сделано +- `GPS.detectRepeats(points, opts)` (gps.js) - порт `tools/track/pers.py`, но эталонный + проход находится сам: + 1. сетка ~10м (формула из `wind_by_passes`/`heightmap.py`); + 2. горячие ячейки = >= minPasses (3) входов (заход = смена ключа ячейки); + 3. эталон = ОДИН чистый проход: от первой горячей ячейки, добавляя новые, до первого + возврата к более ранней (склейка кругов давала абсурд - см. баги); + 4. проходы = порт `passes()`: позиция в seq растёт от старта (допуск назад 3, гэп 25), + покрытие >= 80%; старт-гвард `pos<=15%` не даёт спуску открыть заезд; + 5. нормировка: сравнение по `L_seq / T_прохода` (снимает 5% разброса длины GPS). + Направление без явной проверки bearing: seq хранит первую (подъёмную) позицию ячейки, + спуск роняет позицию и закрывает заезд. +- `avgPower` в возврат `GPS.computeStats` (накопление `power*dt` в вело-ветке / время + педалирования) - для колонки «Вт». +- UI: секция-аккордеон «Разбор повторов» в `showSessionDetail` (только вело), таблица + (№/время/км-ч норм/каденс/Вт), вердикт последний/первый (зелёный >=100%, жёлтый >=90%, + красный <90%), подсветка участка на карте (бирюза `#00e5ff`). CSS в styles.css. + +## Баги по пути (и фиксы) +- Эталон как «самый длинный горячий ран» склеивал 5 кругов -> segLength 4406м и заезды по + 9с (норм. скорость 1762 км/ч). Фикс: эталон = один проход до первого возврата. +- Следующая точка в ТОЙ ЖЕ ячейке считалась «возвратом» и сразу обрывала seq (seq=1). + Фикс: `c===seq[last]` -> продолжить, не обрывать. + +## Проверка +- Node-харнесс на `tools/track/baldav.db` (реальные данные): + - 11 сен (3214 тчк): участок 1577м, **5 заездов**, норм. скорости 15.0/17.1/17.8/17.9/17.5, + последний/первый 117%. + - 17 сен (3079 тчк): участок 1630м, **5 заездов**, 16.9/15.8/17.3/16.4/17.7, 105%. + Совпадает с находками («11 и 17 сентября, по 5 повторов»). + - Контроль: 5 обычных вело-поездок (8-23 км) + 2 короткие -> «повторов не найдено» (0 ложных). +- Десктоп (Xvfb): временно подсажена сессия 17 сен в дев-БД (бэкап + точечное удаление + после, БД байт-в-байт восстановлена). Секция рисуется, аккордеон работает, таблица 5 строк + совпадает с харнессом, вердикт зелёный 105%, участок подсвечен бирюзой на карте. Скрин. +- Далее: сборка APK + проверка на телефоне (реальные интервалы там же). + +## Не в этой задаче +Из того же файла НЕ делалось: поправка модели п.2 (аддитивная мощность вместо max(физика,MET); +п.1 про Crr уже был сделан ранее), массовое уточнение высот, ветер (Часть 2 - там вопросы). diff --git a/frontend/css/styles.css b/frontend/css/styles.css index 168b50c..a3edd9b 100644 --- a/frontend/css/styles.css +++ b/frontend/css/styles.css @@ -1542,6 +1542,18 @@ body.dark-map .poi-badge { background: #111; border-color: #eee; } font-weight: 600; } +/* Repeat / interval analysis table + verdict */ +.repeats-head { font-size: 12px; color: var(--on-bg); opacity: 0.8; margin-bottom: 8px; } +.repeats-tbl { width: 100%; border-collapse: collapse; font-size: 13px; } +.repeats-tbl th, .repeats-tbl td { padding: 5px 6px; text-align: right; border-bottom: 1px solid var(--border); } +.repeats-tbl th:first-child, .repeats-tbl td:first-child { text-align: left; } +.repeats-tbl th { color: var(--primary); font-weight: 600; } +.repeats-verdict { margin-top: 10px; padding: 10px; border-radius: var(--radius); text-align: center; line-height: 1.4; } +.repeats-verdict .rv-num { font-size: 22px; font-weight: 700; } +.repeats-verdict.ok { background: rgba(46, 160, 67, 0.18); color: #4caf50; } +.repeats-verdict.warn { background: rgba(255, 179, 0, 0.16); color: #ffb300; } +.repeats-verdict.bad { background: rgba(207, 102, 121, 0.18); color: #cf6679; } + .sd-row { display: flex; gap: 8px; diff --git a/frontend/js/gps.js b/frontend/js/gps.js index 7b91e7e..3031599 100644 --- a/frontend/js/gps.js +++ b/frontend/js/gps.js @@ -91,6 +91,88 @@ GPS.detectWalk = function(points, opts) { return out; }; +// Auto-detect repeated segments within ONE ride (interval training: the same climb +// ridden N times, one track, no stop/restart). The repeated corridor is found by +// self-similarity — no user marks. Ported from tools/track/pers.py (a pass = a run +// where position along a reference segment rises monotonically), but the reference +// segment is DISCOVERED from the ride itself (the longest corridor of cells the ride +// revisits), not given as road_points ids. +// Returns { found:false } or { found:true, segLength(m), passCount, passes:[{startIdx,endIdx}] }. +// Direction is handled without an explicit bearing check: seq keeps each cell's FIRST +// (uphill) position, so coasting back down makes position fall and ends the pass, and +// the "must start near seq beginning" guard stops a descent from opening a new pass. +GPS.detectRepeats = function(points, opts) { + opts = opts || {}; + let cell = opts.cellM ?? 10; // grid cell ~10 m (as in tools/track heightmap) + let minLenM = opts.minLenM ?? 300; // repeated segment must be at least this long + let minPasses = opts.minPasses ?? 3; // and ridden at least this many times + let minFrac = opts.minFrac ?? 0.8; // a pass must cover this fraction of the segment + let n = points ? points.length : 0; + if (n < 20) return { found: false }; + + // Grid cell key per point. + let key = new Array(n); + for (let i = 0; i < n; i++) { + let la = points[i].lat, lo = points[i].lon; + if (la == null || lo == null) { key[i] = null; continue; } + let ky = Math.floor(la * 111320 / cell); + let kx = Math.floor(lo * 111320 * Math.cos(la * Math.PI / 180) / cell); + key[i] = ky + "," + kx; + } + + // Entries per cell: count each time the track enters a cell (key changes into it). + // A corridor ridden 5x (up+down the same road) racks up ~10 entries; approach/return + // cells get 1. So >= minPasses entries marks the repeated corridor. + let visits = new Map(); + for (let i = 0; i < n; i++) { + if (key[i] == null) continue; + if (i === 0 || key[i] !== key[i - 1]) visits.set(key[i], (visits.get(key[i]) || 0) + 1); + } + let isHot = (k) => k != null && (visits.get(k) || 0) >= minPasses; + + // Reference segment = ONE clean traversal of the corridor: start at the first hot + // cell, add each new hot cell in order, and STOP at the first revisit (that means the + // rider turned back / started the next lap). Gluing several laps into the reference + // (a long hot run) inflates its length and scrambles the order — measured, gives + // absurd 9-second "passes" over a 4.4 km "segment". One traversal fixes both. + let s0 = 0; + while (s0 < n && !isHot(key[s0])) s0++; + if (s0 >= n) return { found: false }; + let seq = [], order = new Map(), s1 = s0; + for (let k = s0, gap = 0; k < n; k++) { + let c = key[k]; + if (c == null || !isHot(c)) { gap++; if (gap > 5) break; continue; } // left the corridor + gap = 0; + if (seq.length && c === seq[seq.length - 1]) { s1 = k; continue; } // still in the same cell + if (order.has(c)) break; // revisit of an earlier cell → first traversal complete + order.set(c, seq.length); seq.push(c); s1 = k; + } + let segLength = 0; + for (let k = s0 + 1; k <= s1; k++) + segLength += GPS.distance(points[k - 1].lat, points[k - 1].lon, points[k].lat, points[k].lon); + if (seq.length < 3 || segLength < minLenM) return { found: false }; + + // Passes: scan the ride; a pass is a run where position along seq rises from the + // start (tolerating 3-cell backsteps and gaps), covering >= minFrac of the segment. + let pos = key.map(k => (order.has(k) ? order.get(k) : null)); + let passes = []; + let p = 0; + while (p < n) { + if (pos[p] == null || pos[p] > seq.length * 0.15) { p++; continue; } + let q = p, lo = pos[p], hi = pos[p], gap = 0; + while (q < n - 1) { + q++; + if (pos[q] == null) { gap++; if (gap > 25) break; continue; } + if (pos[q] < hi - 3) break; // went backward — pass ended (e.g. coasting down) + gap = 0; hi = Math.max(hi, pos[q]); + } + if ((hi - lo) >= seq.length * minFrac) passes.push({ startIdx: p, endIdx: q }); + p = q + 1; + } + if (passes.length < minPasses) return { found: false }; + return { found: true, segLength: Math.round(segLength), passCount: passes.length, passes }; +}; + // One-time migration: accuracy gate is now a HARD reject (coarse cell/wifi fixes // "sit at the tower"). Bump the old default 20m -> 50m so weak but real GPS survives. // Only touch the value if it's still the old default; respect a user-set threshold. @@ -246,6 +328,9 @@ GPS.computeStats = function(points, weightKg, opts) { let totalKcal = 0; const basalPerMin = (1.0 * 3.5 * wKg) / 200; + // Mechanical work while pedalling, for avgPower output (used by the repeat/interval + // analysis to compare passes). Averaged over pedalling time, like tools/track pers.py. + let mechWorkJ = 0, powerSec = 0; // Cadence tracking let totalPedalRevs = 0; @@ -407,6 +492,7 @@ GPS.computeStats = function(points, weightKg, opts) { // 2. MET floor: Compendium cycling METs by speed, MINUS the 1 MET of rest // BMR already covers — the empirically measured cost. const physicsKcal = Math.max(0, power) * dt / efficiency / 4184; + mechWorkJ += Math.max(0, power) * dt; powerSec += dt; // avg mechanical power const vKmh = v * 3.6; if (vKmh >= 5) { const met = vKmh < 16 ? 6.0 : vKmh < 19 ? 6.8 : vKmh < 22 ? 8.0 : vKmh < 25 ? 10.0 : vKmh < 28 ? 12.0 : 14.0; @@ -474,6 +560,8 @@ GPS.computeStats = function(points, weightKg, opts) { const avgCadence = (hasGearData && pedalingSeconds > 0) ? Math.round(totalPedalRevs / (pedalingSeconds / 60)) : null; + // Average mechanical power (W) over pedalling time, null if never pedalled. + const avgPower = powerSec > 0 ? Math.round(mechWorkJ / powerSec) : null; // --------------------------------------------------------------------------- // Auto climb segments: detect climb (gradient > 2%), descent (< -2%), flat @@ -570,6 +658,7 @@ GPS.computeStats = function(points, weightKg, opts) { kcal: Math.round(totalKcal), duration: Math.round(duration), avgCadence, + avgPower, segments, walkBikeSec: Math.round(walkBikeSec), // time spent pushing the bike (single-speed, ≥30s runs) walkBikeMeters: Math.round(walkBikeDist), // distance walked pushing it (excluded from ride distance) diff --git a/frontend/js/main.js b/frontend/js/main.js index e55336d..90e00b4 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -5231,6 +5231,12 @@ async function showSessionDetail(sessionId) {
` : ""} + ${session.activity_type === 'bike' ? ` +
+ Разбор повторов +
Считаю...
+
` : ""} + + `; @@ -2783,11 +2788,75 @@ async function renderGpsScreen(container) { } }; - // Session click → detail view. Keep sid as a STRING (numeric id from the - // SQL plugin is corrupted off-by-1 for large i64 values). - let bindSessionClick = (item) => { item.onclick = () => showSessionDetail(item.dataset.sid); }; + // Session click → detail view; LONG-PRESS → multi-select for merging tracks. Keep sid a + // STRING (numeric id from the SQL plugin is corrupted off-by-1 for large i64 values). + let selMode = false, selected = new Set(); + let selBar = container.querySelector("#gps-sel-bar"); + let selCount = container.querySelector("#gps-sel-count"); + let selMergeBtn = container.querySelector("#gps-sel-merge"); + let refreshSel = () => { + container.querySelectorAll(".gps-session-item").forEach(it => it.classList.toggle("selected", selected.has(it.dataset.sid))); + if (selBar) selBar.hidden = !selMode; + if (selCount) selCount.textContent = "Выбрано " + selected.size; + if (selMergeBtn) selMergeBtn.disabled = selected.size < 2; + }; + let exitSel = () => { selMode = false; selected.clear(); refreshSel(); }; + let toggleSel = (sid) => { + if (selected.has(sid)) selected.delete(sid); else selected.add(sid); + if (selected.size === 0) { exitSel(); return; } + refreshSel(); + }; + let enterSel = (sid) => { selMode = true; selected.add(sid); refreshSel(); }; + + // Long-press: hold ~500 ms without moving. Works for touch and (desktop) mouse; the + // click that follows is suppressed so a long-press doesn't also open the detail. + let attachLongPress = (item) => { + let timer = null, longFired = false; + let cancel = () => { if (timer) { clearTimeout(timer); timer = null; } }; + let start = () => { longFired = false; cancel(); timer = setTimeout(() => { timer = null; longFired = true; if (!selMode) enterSel(item.dataset.sid); else toggleSel(item.dataset.sid); }, 500); }; + item.addEventListener("touchstart", start, { passive: true }); + item.addEventListener("touchmove", cancel, { passive: true }); + item.addEventListener("touchend", cancel); + item.addEventListener("mousedown", start); + item.addEventListener("mouseup", cancel); + item.addEventListener("mouseleave", cancel); + item.onclick = () => { if (longFired) { longFired = false; return; } if (selMode) toggleSel(item.dataset.sid); else showSessionDetail(item.dataset.sid); }; + }; + let bindSessionClick = attachLongPress; container.querySelectorAll(".gps-session-item").forEach(bindSessionClick); + let sessTs = (s, which) => new Date((s.date || "1970-01-01") + "T" + ((which === "end" ? s.end_time : s.start_time) || "00:00:00")); + if (container.querySelector("#gps-sel-cancel")) container.querySelector("#gps-sel-cancel").onclick = () => exitSel(); + if (selMergeBtn) selMergeBtn.onclick = async () => { + if (selected.size < 2) return; + let ids = [...selected]; + let sess = []; + for (let id of ids) { let s = await DB.getGpsSession(id); if (s) sess.push(s); } + if (sess.length < 2) return; + sess.sort((a, b) => sessTs(a) - sessTs(b)); + let gapWarn = false; + for (let i = 1; i < sess.length; i++) { + let gapMin = (sessTs(sess[i]) - sessTs(sess[i - 1], "end")) / 60000; + if (gapMin > 5) gapWarn = true; + } + let msg = "Объединить " + sess.length + " тренировки в одну?" + + (gapWarn ? "\n\nВнимание: между треками разрыв больше 5 минут - точки лягут с пропусками." : "") + + "\n\nОбратимо: исходные тренировки помечаются в точках (можно будет разделить позже)."; + if (!(await appConfirm(msg, "Объединить"))) return; + try { + selMergeBtn.disabled = true; selMergeBtn.textContent = "Объединяю..."; + let targetId = await DB.mergeGpsSessions(ids); + if (targetId) await GPS.recalculateSession(targetId, { noDelete: true }); + showToast("Объединено в одну тренировку"); + exitSel(); + renderScreen("gps"); + } catch (e) { + console.error("[merge]", e); + showToast("Ошибка объединения: " + (e.message || e)); + selMergeBtn.disabled = false; selMergeBtn.textContent = "Объединить"; + } + }; + // Pagination: "Показать ещё" loads the next page from the DB and appends it. let sessOffset = sessions.length; let moreBtn = container.querySelector("#gps-more"); From c5386ae22b5af01e07aaffcfd8b7562214a6074d Mon Sep 17 00:00:00 2001 From: 4aiman Date: Fri, 18 Sep 2026 00:09:14 +0300 Subject: [PATCH 10/65] Bump version to 0.1.462 (build.sh output) --- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 1c1316c..b49f51c 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -139,7 +139,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "baldav" -version = "0.1.461" +version = "0.1.462" dependencies = [ "base64 0.22.1", "btleplug", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 7486e4e..066fe05 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "baldav" -version = "0.1.461" +version = "0.1.462" edition = "2021" [lib] diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index de2a726..558d093 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/nicegui/tauri-v2/refs/heads/main/tooling/cli/schema.json", "productName": "BalDav", - "version": "0.1.461", + "version": "0.1.462", "identifier": "dev.baldav.app", "build": { "frontendDist": "../frontend" From ea462ad9c41d812a4137a1a2b664da3f0b1af1bb Mon Sep 17 00:00:00 2001 From: 4aiman Date: Mon, 21 Sep 2026 11:27:24 +0300 Subject: [PATCH 11/65] Repeat analysis: detect a segment ridden twice (minPasses 3 -> 2); count no longer blocks 2-rep sessions --- PROJECT_MAP.md | 2 +- docs/session-logs/2026-09-17_repeat-intervals.md | 14 ++++++++++++++ frontend/js/gps.js | 6 +++++- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/PROJECT_MAP.md b/PROJECT_MAP.md index e9de6fd..c567abf 100644 --- a/PROJECT_MAP.md +++ b/PROJECT_MAP.md @@ -152,7 +152,7 @@ - Тренировки: showSessionDetail (карта, графики скорости/высоты, секция «Сегменты» - сворачиваемый `
`-аккордеон, свёрнут по умолчанию, чтобы кнопки внизу были доступны; пересчёт, оверлей маршрута, панель де-джиттера SG), showRoutesScreen (сохранённые маршруты) - Похвастаться: showShareModal / buildShareImage (сторис-PNG: форма трека + статы; `opts.fields` галочками в модалке, выбор в `localStorage.baldav_share_fields`; прозрачный/фото; в стеке окон - `winPush("share")`), exportShareImage (нативный share/save) - Объединение поездок: в `renderGpsScreen` долгий тап (500мс) по `.gps-session-item` -> режим выделения (`.selected`, плавающая `#gps-sel-bar`), 2+ -> `DB.mergeGpsSessions(idStrs)` (db.js): точки в самую раннюю сессию, `orig_session_id` per-point (реверс), `seq=NULL` (иначе совпадающие seq перемешивают порядок), лишние сессии удаляются; затем `GPS.recalculateSession(target,{noDelete})`. Колонка `gps_points.orig_session_id` (миграция) -- Разбор повторов: `GPS.detectRepeats(points)` (gps.js) - самоподобие: сетка ~10м, горячие ячейки (>=3 входов), самый длинный эталонный проход -> опорная линия; каждая точка ПРОЕЦИРУЕТСЯ на неё (cell-индекс, допуск вбок 30м) -> прогресс; проход = прогресс растёт от низа до >=80% (порт идеи `tools/track/pers.py`). Мост через короткие остановки/дрожь; время заезда `movingSec` = только движение (стопы вырезаны). Возвращает `{found,segLength,passCount,passes:[{startIdx,endIdx,movingSec}]}`. Секция «Разбор повторов» в `showSessionDetail` (только вело): таблица + вердикт последний/первый + подсветка участка на карте. Мощность на заезд - `avgPower` (новое поле возврата `GPS.computeStats`) +- Разбор повторов: `GPS.detectRepeats(points)` (gps.js) - самоподобие: сетка ~10м, горячие ячейки (>=2 входов = minPasses), самый длинный эталонный проход -> опорная линия; каждая точка ПРОЕЦИРУЕТСЯ на неё (cell-индекс, допуск вбок 30м) -> прогресс; проход = прогресс растёт от низа до >=80% (порт идеи `tools/track/pers.py`). Мост через короткие остановки/дрожь; время заезда `movingSec` = только движение (стопы вырезаны). Возвращает `{found,segLength,passCount,passes:[{startIdx,endIdx,movingSec}]}`. Секция «Разбор повторов» в `showSessionDetail` (только вело): таблица + вердикт последний/первый + подсветка участка на карте. Мощность на заезд - `avgPower` (новое поле возврата `GPS.computeStats`) - Роутинг по проезженному: loadRideGraph (кэш графа из `get_ride_graph`, grid-индекс) / rideRoute (Dijkstra между концами ноги, снап ≤60м, отсев кривого) - в `showRouteEditor` пробуется до BRouter - Прочее: showWeightModal, showSleepModal, showActivityModal, showProfileModal, showBikeModal, showMapDownloadModal, showLogSupplementModal diff --git a/docs/session-logs/2026-09-17_repeat-intervals.md b/docs/session-logs/2026-09-17_repeat-intervals.md index fd822a4..70dc302 100644 --- a/docs/session-logs/2026-09-17_repeat-intervals.md +++ b/docs/session-logs/2026-09-17_repeat-intervals.md @@ -88,6 +88,20 @@ вырезан из первого), норм.скор [20.1,21.1,20.9,22.5], посл/перв 112%. 11 сен 5× (16.6..18.8, 113%), 17 сен 5× (101%), контроль 5 обычных - 0 ложных, короткие - нет. +## Фикс 4: детекция при 2 заездах (порог 3 -> 2) +- Симптом (пользователь + разбор другого агента): при двух проездах туда-обратно детекции + нет по построению. +- Проверка по коду + харнесс: `minPasses` (по умолч. 3) работал И как порог «горячей» ячейки + (>=3 входов), И как финальный порог числа проходов. Урезанный до 2 заездов трек 16 сен: + порог 3 -> НЕ НАЙДЕНО, порог 2 -> найдено (2 заезда, 1555м). Подтверждено. +- Фикс: `minPasses` по умолчанию 2. Перекрёстки отсекаются длиной (`minLenM` 300м), не + счётчиком; обычная поездка «туда-обратно» (коммьют) не ловится, т.к. проходы считаются + монотонно в ОДНУ сторону (спуск/возврат start-guard отсекает). +- Проверка: 2-заездный -> 2×; 16 сен 4×, 11/17 по 5× (без изменений); 5 обычных поездок + (включая туда-обратно) - 0 ложных. Признаки из разбора агента (возвратный участок, время, + высота) не понадобились - длина+направление уже отсекают мусор. Снос вбок и дырки в цепочке + уже покрыты (проекция 30м в разметке проходов; gap>5 в эталонном проходе). + ## Не в этой задаче Из того же файла НЕ делалось: поправка модели п.2 (аддитивная мощность вместо max(физика,MET); п.1 про Crr уже был сделан ранее), массовое уточнение высот, ветер (Часть 2 - там вопросы). diff --git a/frontend/js/gps.js b/frontend/js/gps.js index 1725d30..60e97be 100644 --- a/frontend/js/gps.js +++ b/frontend/js/gps.js @@ -105,7 +105,11 @@ GPS.detectRepeats = function(points, opts) { opts = opts || {}; let cell = opts.cellM ?? 10; // grid cell ~10 m (as in tools/track heightmap) let minLenM = opts.minLenM ?? 300; // repeated segment must be at least this long - let minPasses = opts.minPasses ?? 3; // and ridden at least this many times + let minPasses = opts.minPasses ?? 2; // ridden at least this many times (2: a segment ridden + // twice is already a repeat; 3 missed the normal "there and + // back twice" case. Crossroads are rejected by minLenM, not + // by the count; same-direction monotonic passes reject a plain + // out-and-back commute - verified 0 false positives on rides) let minFrac = opts.minFrac ?? 0.8; // a pass must cover this fraction of the segment (projection // handles lateral drift, so a full rep reads as full coverage // and this stays strict, as in tools/track pers.py) From 44bb08e81ec7e86d95f2bf8db0c2417e0d25f0d3 Mon Sep 17 00:00:00 2001 From: 4aiman Date: Mon, 21 Sep 2026 11:28:37 +0300 Subject: [PATCH 12/65] Bump version to 0.1.463 (build.sh output) --- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index b49f51c..d163a40 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -139,7 +139,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "baldav" -version = "0.1.462" +version = "0.1.463" dependencies = [ "base64 0.22.1", "btleplug", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 066fe05..d2aff0a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "baldav" -version = "0.1.462" +version = "0.1.463" edition = "2021" [lib] diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 558d093..2e92a5b 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/nicegui/tauri-v2/refs/heads/main/tooling/cli/schema.json", "productName": "BalDav", - "version": "0.1.462", + "version": "0.1.463", "identifier": "dev.baldav.app", "build": { "frontendDist": "../frontend" From 15a2881c6da964b56bcd6fe9741412e92639e998 Mon Sep 17 00:00:00 2001 From: 4aiman Date: Mon, 21 Sep 2026 12:36:03 +0300 Subject: [PATCH 13/65] Split a track at a point; block merging tracks with different bikes --- PROJECT_MAP.md | 3 +- README.md | 3 +- .../2026-09-21_split-and-bike-guard.md | 43 +++++++++++++++ frontend/css/styles.css | 15 ++++++ frontend/js/db.js | 26 ++++++++++ frontend/js/main.js | 52 +++++++++++++++++++ 6 files changed, 140 insertions(+), 2 deletions(-) create mode 100644 docs/session-logs/2026-09-21_split-and-bike-guard.md diff --git a/PROJECT_MAP.md b/PROJECT_MAP.md index c567abf..019b032 100644 --- a/PROJECT_MAP.md +++ b/PROJECT_MAP.md @@ -151,7 +151,8 @@ - Рецепты: showRecipeDetailModal, showRecipeEditorModal, showAddIngredientModal - Тренировки: showSessionDetail (карта, графики скорости/высоты, секция «Сегменты» - сворачиваемый `
`-аккордеон, свёрнут по умолчанию, чтобы кнопки внизу были доступны; пересчёт, оверлей маршрута, панель де-джиттера SG), showRoutesScreen (сохранённые маршруты) - Похвастаться: showShareModal / buildShareImage (сторис-PNG: форма трека + статы; `opts.fields` галочками в модалке, выбор в `localStorage.baldav_share_fields`; прозрачный/фото; в стеке окон - `winPush("share")`), exportShareImage (нативный share/save) -- Объединение поездок: в `renderGpsScreen` долгий тап (500мс) по `.gps-session-item` -> режим выделения (`.selected`, плавающая `#gps-sel-bar`), 2+ -> `DB.mergeGpsSessions(idStrs)` (db.js): точки в самую раннюю сессию, `orig_session_id` per-point (реверс), `seq=NULL` (иначе совпадающие seq перемешивают порядок), лишние сессии удаляются; затем `GPS.recalculateSession(target,{noDelete})`. Колонка `gps_points.orig_session_id` (миграция) +- Объединение поездок: в `renderGpsScreen` долгий тап (500мс) по `.gps-session-item` -> режим выделения (`.selected`, плавающая `#gps-sel-bar`), 2+ -> `DB.mergeGpsSessions(idStrs)` (db.js): точки в самую раннюю сессию, `orig_session_id` per-point (реверс), `seq=NULL` (иначе совпадающие seq перемешивают порядок), лишние сессии удаляются; затем `GPS.recalculateSession(target,{noDelete})`. Колонка `gps_points.orig_session_id` (миграция). Склейка блокируется, если у выбранных треков разные `bike_id` (appAlert) +- Разрезание трека: кнопка `#sd-split` в `showSessionDetail` включает `#sd-map.split-mode`; в попапе каждой точки кнопка `.split-here` (видна только в split-mode) -> `confirmSplitAt(k)` -> `DB.splitGpsSession(sessionId, part2Ids, part1EndTime, part2StartTime)` (db.js): вторая половина (точки по id, чанки) в НОВУЮ сессию (id=Date.now(), копия date/activity/bike/source), `seq=NULL`, у старой правится end_time; пересчёт обоих кусков. Велик копируется, потом меняется вручную - Разбор повторов: `GPS.detectRepeats(points)` (gps.js) - самоподобие: сетка ~10м, горячие ячейки (>=2 входов = minPasses), самый длинный эталонный проход -> опорная линия; каждая точка ПРОЕЦИРУЕТСЯ на неё (cell-индекс, допуск вбок 30м) -> прогресс; проход = прогресс растёт от низа до >=80% (порт идеи `tools/track/pers.py`). Мост через короткие остановки/дрожь; время заезда `movingSec` = только движение (стопы вырезаны). Возвращает `{found,segLength,passCount,passes:[{startIdx,endIdx,movingSec}]}`. Секция «Разбор повторов» в `showSessionDetail` (только вело): таблица + вердикт последний/первый + подсветка участка на карте. Мощность на заезд - `avgPower` (новое поле возврата `GPS.computeStats`) - Роутинг по проезженному: loadRideGraph (кэш графа из `get_ride_graph`, grid-индекс) / rideRoute (Dijkstra между концами ноги, снап ≤60м, отсев кривого) - в `showRouteEditor` пробуется до BRouter - Прочее: showWeightModal, showSleepModal, showActivityModal, showProfileModal, showBikeModal, showMapDownloadModal, showLogSupplementModal diff --git a/README.md b/README.md index 10687aa..24c3082 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,8 @@ - **Объезд с возвратом на маршрут (на ходу)** — на карте записи кнопка **«↪»**: если активный маршрут ведёт через непроезжее место, строит от текущей позиции объезд обратно НА маршрут за местом затыка (отступ ~1.5 км вперёд, растёт при неудаче до конца маршрута), избегая nogo и разворота на 180° (учитывает курс). Показывается ярким навигационным оверлеем (не меняет сохранённый маршрут и не прерывает запись); долгий тап по кнопке убирает оверлей. - **Детект ходьбы в вело-треке** - устойчиво низкая скорость (по умолчанию < 8 км/ч дольше 60с; порог уточняется по передаче для односкоростного) в вело/другой сессии = ведёшь/идёшь пешком. Такие участки красятся отдельно (цвет ходьбы) на детали трека, в статах показывается «пешком: N мин / X км», и они НЕ попадают в «проезженные дороги» (не строятся по ним). Флаг хранится по точкам (`gps_points.walk`), пересчитывается на «Пересчитать»/«Пересчитать всё». - **Роутинг сначала по проезженному** - публичные данные BRouter часто не совпадают с землёй (роутит по несуществующим дорогам). Поэтому планировщик для каждой дорожной ноги сначала пробует проложить путь **по дорогам, где ты реально ездил** (граф из своих треков: узлы на сетке ~20м, исключая пешие куски), и только если проезженного рядом нет или путь выходит сильно кривой - обращается к BRouter. Ридден-нога рисуется тонкой линией (без высот), BRouter-нога - оковкой с раскраской. Граф строится из `gps_points` командой `get_ride_graph`, кэшируется. -- **Объединение поездок** - долгий тап по треку в истории включает выделение, тап отмечает ещё; когда выбрано 2+, кнопка **«Объединить»** склеивает их в одну тренировку (точки всех сессий переносятся в самую раннюю по времени, порядок по таймстампу, стата пересчитывается). Полезно, когда одна тренировка записалась несколькими кусками (например, отдельный заезд оказался в своей сессии - после склейки он попадёт в «Разбор повторов»). Разрыв между треками больше 5 минут - предупреждает, но не мешает. Обратимо: у каждой точки хранится исходная сессия (`gps_points.orig_session_id`) для будущего разделения. Пока только склейка (разрез/вырез - позже). +- **Объединение поездок** - долгий тап по треку в истории включает выделение, тап отмечает ещё; когда выбрано 2+, кнопка **«Объединить»** склеивает их в одну тренировку (точки всех сессий переносятся в самую раннюю по времени, порядок по таймстампу, стата пересчитывается). Полезно, когда одна тренировка записалась несколькими кусками (например, отдельный заезд оказался в своей сессии - после склейки он попадёт в «Разбор повторов»). Разрыв между треками больше 5 минут - предупреждает, но не мешает. **Разные велосипеды склеить нельзя** - показывает предупреждение (передача/вес отличаются, мощность и каденс посчитались бы неверно); если это один велосипед, задай его трекам одинаково и склеивай. Обратимо: у каждой точки хранится исходная сессия (`gps_points.orig_session_id`). +- **Разрезание трека** - в детали трека кнопка **«Разрезать трек»** включает режим: тап по точке на карте -> в её окошке кнопка **«✂ Разрезать здесь»** -> подтверждение -> второй кусок становится отдельной тренировкой (с тем же велосипедом; порядок точек по времени). Зачем: если часть трека проехана на другом велосипеде или другой передаче - разрежь в точке смены, назначь каждому куску свой велосипед (в детали: «Велосипед» + «Пересчитать»), и мощность/каденс посчитаются по правильной передаче на каждом куске. - **Разбор повторов (интервалы)** - катаешь один подъём несколько раз за поездку (один трек, без стоп/старт на каждом заезде) - деталь трека сама находит повторяемый участок по самоподобию (сетка ~10м, повторно посещённые ячейки, самый длинный эталонный проход) и в сворачиваемой секции «Разбор повторов» показывает таблицу заездов (номер, время, скорость, каденс, мощность) и главное число - **последний / первый**, с вердиктом: >=100% зелёный (можно завтра), 90-99% жёлтый (тяжёлая норма), <90% красный (два дня спокойно). Каждая точка проецируется на опорную линию (снос вбок в пределах ~30м, например при возне с седлом, не режет покрытие); время заезда - только секунды в движении (остановки вырезаются, скорость нормируется на длину участка). Найденный участок подсвечивается на карте. Только вело. `GPS.detectRepeats`; повторов нет (обычная поездка A-B) - так и пишет. - **Похвастаться (шэр-картинка)** - в детали трека кнопка **«Похвастаться»**: генерирует картинку в формате сторис (9:16) с формой трека и статами (Дистанция/Время/Средняя скорость крупно + набор/ккал/дата), **прозрачную** (PNG с альфой для наложения на своё фото) или **на фото-подложке** из настроек. В модалке предпросмотра галочками выбирается, что класть на картинку (форма трека, дистанция, время, средняя/макс. скорость, набор высоты, калории, дата; выбор запоминается), переключатель прозрачный/фото и кнопки **Поделиться** (системный шэр) и **Сохранить** (SAF). Подложка выбирается в Настройках. - **Сегменты - сворачиваемо** - в детали трека список сегментов высот (climb/descent, до десятков штук) свёрнут в аккордеон (`
`, свёрнут по умолчанию, счётчик в заголовке); тап разворачивает/сворачивает. Кнопки внизу (Похвастаться, Экспорт, Пересчитать, Удалить) остаются доступны без долгого скролла. diff --git a/docs/session-logs/2026-09-21_split-and-bike-guard.md b/docs/session-logs/2026-09-21_split-and-bike-guard.md new file mode 100644 index 0000000..92901f9 --- /dev/null +++ b/docs/session-logs/2026-09-21_split-and-bike-guard.md @@ -0,0 +1,43 @@ +# Сессия 2026-09-21 - разрезание трека + блокировка склейки разных великов + +## Контекст +Три вопроса пользователя (сначала разобрал по коду, потом делал): +1. «Пересчитать» берёт велик СЕССИИ (не активный) - пользователя устраивает, не трогаем. +2. Хочет разрезать трек в точке смены передачи, чтобы разным кускам назначать свой велик + (два велика = один и тот же, разные передачи) - для точных мощности/каденса. +3. Опасение: склейка разных великов слепит всё в один велик. Решение (по просьбе): при + склейке треков с РАЗНЫМИ великами - предупреждать и НЕ склеивать (не хранить велик по точкам). + +## Сделано +- **Блокировка склейки (D):** в обработчике `#gps-sel-merge` (main.js) - если у выбранных + сессий разные `bike_id` (Set по bike_id, null=none), `appAlert` «велосипеды разные, склеить + нельзя, задайте одинаковый и склеивайте» и return. +- **Разрезание (B):** `DB.splitGpsSession(sessionId, part2Ids, part1EndTime, part2StartTime)` + (db.js): вторая половина -> НОВАЯ сессия (id=Date.now(), копия date/activity/bike/source, + start=part2 время, end=старый end), точки part2 переносятся по id чанками (200), `seq=NULL`; + у старой end_time=part1. UI: кнопка `#sd-split` включает `#sd-map.split-mode`; в попапе точки + кнопка `.split-here` (видна только в split-mode) -> `confirmSplitAt(k)` -> подтверждение -> + splitGpsSession -> пересчёт обоих кусков (`recalculateSession noDelete`) -> назад в историю. +- Точку разреза выбирают ТАПОМ ПО ТОЧКЕ (хит-таргеты уже есть), а не кликом карты - маркеры + перехватывают клик, поэтому кнопка живёт в попапе. + +## Проверка (десктоп Xvfb, подсажен склеенный трек 16 сен 1789547934760, 3614 тчк, +2й велик) +- Разрез: «Разрезать трек» -> тост + скролл к карте -> тап точки #3547 -> попап с «✂ Разрезать + здесь» -> диалог (точка/время/км) -> «Разрезать» -> в истории ДВЕ сессии 16-го (part1 3548 + тчк 19.54км, part2 66 тчк 343м), обе bike, стата пересчитана. Скрины. +- Блокировка: part2 велик -> 2 (через SQL), выделил обе (долгий тап + тап), «Объединить» -> + предупреждение «велосипеды разные, склеить нельзя», склейки не произошло. Скрин. +- БД восстановлена из бэкапа (0 сессий 16 сен, 1 велик). + +## Замечания +- Инъекция `INSERT ... SELECT *` тихо даёт 0 строк, если id сессии больше нет в pulled-БД + (tools/track/baldav.db пере-пулена, старая 1789549492046 уже склеена на телефоне в + 1789547934760). Проверять существование id перед выводами. +- `Xvfb :99` снова умирал; `pgrep -f "Xvfb :99"` ловит собственную команду (ложно «жив») - + проверять сокет `/tmp/.X11-unix/X99`. +- part2 start_time берётся из ts точки (UTC), а поле сессии - локальное; мелкая косметическая + рассинхронизация времени (данные так хранятся), на функциональность не влияет. + +## Не сделано (по просьбе - позже/не нужно) +- Велик по точкам (вариант C) не делаем: пользователь просто не будет склеивать разные велики. +- Вырезание участка трека - позже. diff --git a/frontend/css/styles.css b/frontend/css/styles.css index 770d259..fe0ff97 100644 --- a/frontend/css/styles.css +++ b/frontend/css/styles.css @@ -1761,6 +1761,21 @@ body.dark-map .poi-badge { background: #111; border-color: #eee; } box-shadow: 0 -2px 8px rgba(0,0,0,0.3); } .gps-sel-bar[hidden] { display: none; } + +/* "Split here" button inside a point popup — visible only when the map is in split mode */ +.split-here { + display: inline-block; + margin-top: 6px; + padding: 4px 10px; + background: #ff9800; + color: #000; + border: none; + border-radius: 4px; + font-size: 12px; + font-weight: 600; + cursor: pointer; +} +#sd-map:not(.split-mode) .split-here { display: none; } .gps-sel-bar #gps-sel-count { flex: 1; font-size: 14px; } .gps-sel-bar button { padding: 8px 14px; } diff --git a/frontend/js/db.js b/frontend/js/db.js index 087f8c7..c94dc91 100644 --- a/frontend/js/db.js +++ b/frontend/js/db.js @@ -949,6 +949,31 @@ async function mergeGpsSessions(idStrs) { return targetId; } +// Split one session in two at a chosen point. The second part (points whose ids are in +// part2Ids) moves to a NEW session that copies the original's date / activity / bike / +// source; the first part keeps the original session. Bike stays the same on both halves - +// the rider then reassigns the bike on a half and recomputes if the gear differed. Times +// are "HH:MM:SS". Returns the new (second-part) session id_str. Caller recomputes both. +async function splitGpsSession(sessionId, part2Ids, part1EndTime, part2StartTime) { + if (!Array.isArray(part2Ids) || part2Ids.length === 0) return null; + const s = await getGpsSession(sessionId); + if (!s) return null; + const newId = Date.now(); + await execute( + `INSERT INTO gps_sessions (id, date, start_time, end_time, activity_type, source, bike_id) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)`, + [newId, s.date, part2StartTime, s.end_time, s.activity_type, s.source ?? "recorded", s.bike_id ?? null] + ); + let CHUNK = 200; + for (let i = 0; i < part2Ids.length; i += CHUNK) { + let ch = part2Ids.slice(i, i + CHUNK); + let ph = ch.map((_, k) => "?" + (k + 2)).join(","); + await execute(`UPDATE gps_points SET session_id = CAST(?1 AS INTEGER), seq = NULL WHERE id IN (${ph})`, [newId, ...ch]); + } + if (part1EndTime) await execute("UPDATE gps_sessions SET end_time = ?1 WHERE id = CAST(?2 AS INTEGER)", [part1EndTime, sessionId]); + return String(newId); +} + async function importGpxSession(session, points) { await execute( `INSERT INTO gps_sessions (date, start_time, end_time, activity_type, @@ -1325,6 +1350,7 @@ window.DB.getGpsSession = getGpsSession; window.DB.getGpsPoints = getGpsPoints; window.DB.deleteGpsSession = deleteGpsSession; window.DB.mergeGpsSessions = mergeGpsSessions; +window.DB.splitGpsSession = splitGpsSession; window.DB.importGpxSession = importGpxSession; window.DB.saveGapFill = saveGapFill; window.DB.revertGapFill = revertGapFill; diff --git a/frontend/js/main.js b/frontend/js/main.js index 38faa7a..1e45a6f 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -2834,6 +2834,13 @@ async function renderGpsScreen(container) { for (let id of ids) { let s = await DB.getGpsSession(id); if (s) sess.push(s); } if (sess.length < 2) return; sess.sort((a, b) => sessTs(a) - sessTs(b)); + // Different bikes must not be merged: gear/weight differ, so power and cadence would be + // computed wrong for the mixed track. Refuse and tell the rider how to proceed. + let bikeSet = new Set(sess.map(s => s.bike_id ?? "none")); + if (bikeSet.size > 1) { + await appAlert("Велосипеды у выбранных треков разные - склеить нельзя: передача и вес отличаются, мощность и каденс посчитались бы неверно.\n\nЕсли это на самом деле один велосипед, задайте его всем трекам одинаково (в детали трека: «Велосипед», затем «Пересчитать») и склеивайте снова."); + return; + } let gapWarn = false; for (let i = 1; i < sess.length; i++) { let gapMin = (sessTs(sess[i]) - sessTs(sess[i - 1], "end")) / 60000; @@ -5321,6 +5328,7 @@ async function showSessionDetail(sessionId) { + @@ -5705,10 +5713,40 @@ async function showSessionDetail(sessionId) { let bp = (p.bp_sys && p.bp_dia) ? `${p.bp_sys}/${p.bp_dia}` : ""; let bleInfo = [hr, spo2, bp].filter(Boolean).join(" "); let info = `#${i} ${time}${alt != null ? " | " + Math.round(alt) + "m" : ""}${spd ? " | " + spd : ""}${bleInfo ? " | " + bleInfo : ""}`; + // "Split here" button lives in every point popup but is shown only in split mode + // (mapEl.split-mode, toggled by the "Разрезать трек" button) — see CSS. + info += ``; L.circleMarker([p.lat, p.lon], { radius: 8, weight: 0, fillOpacity: 0, interactive: true, }).bindPopup(info, { closeButton: false, className: "point-popup" }).addTo(map); } + // Delegated: tap "Разрезать здесь" in a point popup → split the track at that point. + mapEl.addEventListener("click", (ev) => { + let b = ev.target.closest(".split-here"); + if (b) { ev.stopPropagation(); confirmSplitAt(parseInt(b.dataset.idx)); } + }); + async function confirmSplitAt(k) { + if (!(k >= 2 && k <= points.length - 3)) { showToast("Выберите точку не у самого края трека"); return; } + try { map.closePopup(); } catch {} + let tOf = (p) => (p.ts.split("T")[1] || "").slice(0, 8); + let distToHere = 0; + for (let i = 1; i <= k; i++) distToHere += GPS.distance(points[i - 1].lat, points[i - 1].lon, points[i].lat, points[i].lon); + if (!(await appConfirm("Разрезать трек после точки #" + k + " (время " + tOf(points[k]) + ", ~" + (distToHere / 1000).toFixed(2) + " км от старта)?\n\nВторой кусок станет отдельной тренировкой с тем же велосипедом. Потом можно сменить велосипед у неё и пересчитать.", "Разрезать"))) return; + let part2Ids = points.slice(k + 1).map(p => p.id).filter(id => id != null); + if (part2Ids.length < 2) { showToast("Во втором куске слишком мало точек"); return; } + try { + let newId = await DB.splitGpsSession(sessionId, part2Ids, tOf(points[k]), tOf(points[k + 1])); + if (newId) { + await GPS.recalculateSession(sessionId, { noDelete: true }); + await GPS.recalculateSession(newId, { noDelete: true }); + } + showToast("Трек разрезан на две тренировки"); + if (winStack.length > 0) winStack.pop().close(); + _skipPops = 1; + history.back(); + renderScreen("gps"); + } catch (e) { console.error("[split]", e); showToast("Ошибка разреза: " + (e.message || e)); } + } // Ensure Leaflet has correct container size, then fit map.invalidateSize(); @@ -6038,6 +6076,20 @@ async function showSessionDetail(sessionId) { let compareBtn = document.querySelector("#sd-compare"); if (compareBtn) compareBtn.onclick = () => showCompareModal(sessionId, session, points); + // Split-track button: toggles split mode on the map (shows a "Разрезать здесь" action + // inside each point popup). Hidden for tracks too short to split. + let splitBtn = document.querySelector("#sd-split"); + if (splitBtn) { + if (points.length < 5) splitBtn.style.display = "none"; + else splitBtn.onclick = () => { + let m = document.querySelector("#sd-map"); + if (!m) return; + let on = m.classList.toggle("split-mode"); + splitBtn.textContent = on ? "Готово (выберите точку на карте)" : "Разрезать трек"; + if (on) { showToast("Тапните точку на карте, затем «Разрезать здесь»"); m.scrollIntoView({ behavior: "smooth", block: "center" }); } + }; + } + // Recalculate button let recalcBtn = document.querySelector("#sd-recalc"); if (recalcBtn) recalcBtn.onclick = async () => { From db62d5265050c4b3bdf7131943cd349b3051e043 Mon Sep 17 00:00:00 2001 From: 4aiman Date: Mon, 21 Sep 2026 12:36:51 +0300 Subject: [PATCH 14/65] Bump version to 0.1.464 (build.sh output) --- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index d163a40..0f38ea0 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -139,7 +139,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "baldav" -version = "0.1.463" +version = "0.1.464" dependencies = [ "base64 0.22.1", "btleplug", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d2aff0a..5c08cfb 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "baldav" -version = "0.1.463" +version = "0.1.464" edition = "2021" [lib] diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 2e92a5b..d49e55d 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/nicegui/tauri-v2/refs/heads/main/tooling/cli/schema.json", "productName": "BalDav", - "version": "0.1.463", + "version": "0.1.464", "identifier": "dev.baldav.app", "build": { "frontendDist": "../frontend" From d6139a99f7527166c816cbe6c12326aa1b44dc41 Mon Sep 17 00:00:00 2001 From: 4aiman Date: Mon, 21 Sep 2026 12:42:57 +0300 Subject: [PATCH 15/65] Docs: repeat-detection findings and interval backlog notes --- docs/gps-model-findings.md | 296 +++++++++++++++++++++++++++++++++++++ 1 file changed, 296 insertions(+) diff --git a/docs/gps-model-findings.md b/docs/gps-model-findings.md index bf9f629..fce3197 100644 --- a/docs/gps-model-findings.md +++ b/docs/gps-model-findings.md @@ -351,3 +351,299 @@ Aeropod). Трубка, направленная вперёд, плюс датч Готовый код обоих методов варианта 1 -- в `tools/track/track.py` (функция `wind_by_passes` и секция «ветер» в `report`), выше описан алгоритмически. + +--- + +# БЭКЛОГ: автоматический разбор повторов внутри одной поездки + +Заказано пользователем 17 сентября 2026. + +## Задача + +Пользователь катает интервалы: приезжает к подъёму, проезжает его 5 раз, +скатываясь вниз между заездами, потом уезжает домой. Сейчас, чтобы разобрать +такую тренировку, приходится либо останавливать и заново стартовать трек +на каждом повторе (пользователь называет это бредом), либо разбирать +вручную задним числом. + +Нужно: **один трек на всю тренировку, повторы находятся сами.** Пользователь +готов на отдельный режим «велотренировка», если так проще. + +## Что должно получиться на выходе + +Таблица повторов: номер, время, средняя скорость, каденс, мощность. +И главное число, по которому принимается решение о завтрашнем дне: + + отношение последнего повтора к первому + +Порог, выведенный из данных (11 и 17 сентября, по 5 повторов): +- последний быстрее или равен первому -- не на пределе, можно повторить завтра +- последний медленнее на 5-10% -- нормальная тяжёлая тренировка +- последний медленнее больше чем на 10% -- два дня спокойно + +## Алгоритм, проверен вручную на этих данных + +Повторы ищутся по самоподобию трека, без опорных точек от пользователя. + +1. **Разложить точки поездки по ячейкам** ~10 м (сетка как в `heightmap.py`). +2. **Найти ячейки, посещённые больше одного раза**, и отбросить те, где + направление движения (`bearing_deg`) при посещениях различается больше + чем на 60 градусов: проезд вниз по той же дороге повтором не считается. + **Без проверки направления половина повторов теряется или считается + задом наперёд** -- проверено, это главная ловушка. +3. **Собрать самую длинную цепочку** таких ячеек, идущую подряд по ходу + движения. Это и есть повторяемый участок. Требовать длину не меньше + 300 м и не меньше 3 проходов, иначе это перекрёсток у дома, а не подъём. +4. **Разметить проходы:** для каждого прохода записать вход и выход + по цепочке, время, длину. +5. **Нормировать время на общую длину:** у пользователя GPS даёт от 1138 + до 1199 м на одном и том же подъёме, то есть 5% разброса. Без нормировки + сравнение повторов между собой врёт. + +## Чего делать НЕ надо + +**Не просить пользователя ставить метки.** Он специально сказал, что +останавливать и перезапускать трек не будет. + +**Не опираться на высоту отдельного проезда.** Профиль повторяемого участка +брать из карты высот по многим проездам (см. соседний раздел): у одиночного +проезда GPS даёт ступеньки вида 0, 0, 0, 0, 8.5 м на ровном месте. + +**Не считать мощность усреднением по точкам.** Усреднять по времени. +При усреднении по точкам отрезки чуть разной длины дают разный вес +медленным кускам, и на одинаковой скорости выходят разные ватты +(проверено: 17.9 км/ч -> 173 Вт и 18.1 км/ч -> 209 Вт на одном подъёме). + +## Готовый код + +Ручная версия детектора по двум опорным точкам -- в истории этой сессии, +в `tools/track/`. Она находит проходы правильно, но опорные точки задаются +руками. Автоматизировать надо шаги 1-3. + +--- + +# БЭКЛОГ: массовое уточнение высот по всем трекам + +Заказано пользователем 17 сентября 2026. Приоритет свой, не срочный. + +## Что нужно + +Пункт меню, который уточняет высоты **пакетно**: идёт от последнего трека +назад по времени и обрабатывает те, где `altitude_srtm_m` ещё не заполнена. +Пользователь запускает его вручную, когда сидит на проводном интернете. + +Требования к исполнению, из общих правил проекта: +- показывать прогресс числами: сколько треков обработано, сколько осталось +- прерываемость: в поле связь рвут РЭБ и РКН, процесс должен переживать + обрыв и продолжаться с того места, где встал, а не начинать заново +- уже уточнённые треки не трогать повторно + +## Зачем, с измерениями + +Аудит всех 83 велопоездок показал, что **высота за май и июнь непригодна**: + + май-июнь: набор в БД 0-23 м, расчётный по сглаженной высоте 50-207 м + июль-сентябрь: набор в БД и расчётный сходятся (168/138, 251/275, 227/235) + +До 17 мая запись шла раз в 3-4 секунды при шуме высотомера 0.8 м между +соседними точками. Пример 10 мая (session_id 3): сырая высота скачет +`36 20 23 20 20 36 36 35 35 35 34 34 33 20 23 20 19 32 32 32 31`, то есть +приёмник бегает между двумя уровнями с размахом 16 м. Медианный фильтр +и скользящее среднее это приглаживают, но остаток даёт уклоны до 12.8% +на треке, где реального набора 14 м на 13 км. + +Практическое следствие: **любой анализ по уклонам за май и июнь даёт +фальшивые результаты**, причём правдоподобные на вид. Построенная по этим +данным кривая «лучший удержанный момент на шатунах» показала май лучшим +месяцем сезона, хотя по мощности за 20 минут рост с мая по сентябрь +составил 1.12 -> 1.75 Вт/кг. + +Отдельно найдены отказы высотомера внутри годных треков: 2 сентября +(session_id 1788361994788) после точки 4194 значение падает на 10 м +и **замерзает** -- 26.8, 26.9, 26.9, 26.9 шестьдесят точек подряд, +а после перерыва записи прыгает на 64.8 м с точностью 22 м. Уклоны +на этом участке выдуманы целиком. + +## Признаки негодной высоты, годятся как автотест + +Все три считаются по уже записанным данным, без сети: + +1. `elevation_gain_m` в сессии и набор, пересчитанный по точкам, расходятся + больше чем вдвое в любую сторону +2. медиана модуля разности высоты между соседними точками выше 1.5 м +3. доля точек с шагом записи больше 8 секунд выше 15% + +По этим признакам из 83 поездок отсеиваются 31. После уточнения высот +через SRTM проверку надо прогнать повторно: она же покажет, помогло ли. + +## Объём работы, посчитан по базе + + всего точек 418 465 + уже уточнено 47 420 (11.3%, 12 сессий из 170) + осталось 371 045 + запросов по 100 3 711 + +Размер пачки 100 задан в `frontend/js/main.js` около строки 3085 и совпадает +с потолком OpenTopoData. + +## Что надо поправить ДО массового прогона + +**Откат по цепочке источников менять не надо.** Он сделан намеренно: +лучше получить хоть какую-то высоту, чем никакой. Проблема не в откате, +а в том, что после него не остаётся следа, кто именно ответил. + +**1. Нет колонки источника.** В `gps_points` есть `altitude_srtm_m`, но нет +поля, откуда это значение пришло. При работающем откате база неизбежно +становится смесью источников, и без этой колонки отличить их потом нельзя +никак. Нужна колонка (ALTER TABLE, без пересоздания), заполняемая при записи. +Это единственное обязательное изменение. + +**2. Откат должен быть виден пользователю.** Сейчас он сообщается только +строкой в консоли (`[gpxz] http 403`). Достаточно показать в конце итог +вида «уточнено 2314 точек: GPXZ 0, open-elevation 0, open-meteo 2314». + +**3. Обработка 429 и 402 отдельно от сетевой ошибки.** По лимиту +останавливаться до завтра, по обрыву повторять. Иначе при массовом прогоне +упёршийся лимит молча отправит весь остаток базы к запасному источнику. + +## Чего ждать и чего не ждать + +Copernicus GLO-30 это сетка 30 м. На подъём длиной 200-400 м приходится +7-13 отсчётов, этого хватает. На короткий рамп 60-100 м приходится 2-3 +отсчёта, форму по ним не восстановить. То есть массовое уточнение закроет +затяжные подъёмы и общий набор, но НЕ закроет короткие рампы, на которых +пользователь срывается. Для них нужен либо барометр, либо разовый замер +профиля конкретного подъёма угломером. + +## Лимиты GPXZ против объёма + +Тарифы с их сайта: Evaluation бесплатно и 100 запросов в сутки (помечен +«testing only»), Small 99 USD/мес и 2500 в сутки, Large 249 USD/мес и 7500. + + 3711 запросов при 100/сутки = 37 дней + 3711 запросов при 2500/сутки = 1.5 дня + +Отдельно: одна поездка это медиана 19 запросов, но максимум 133. То есть +**длинная поездка не влезает в бесплатный суточный лимит даже одна.** +Если массовый прогон делать на бесплатном ключе, он обязан быть +возобновляемым и считать свой суточный расход сам. + +## Вариант без лимитов: свой Open Topo Data + +GPXZ на своём сайте прямо пишет, что открытый форк их API-сервера +называется Open Topo Data. Размер пачки 100, уже зашитый в коде, +взят как раз из его потолка, то есть **протокол совместим и менять +в клиенте нужен только базовый URL** (сейчас `https://api.gpxz.io/v1/...` +захардкожен в `frontend/js/main.js` около строки 3091). + +Считано по базе: все треки пользователя укрываются **двумя градусными +тайлами 1x1**. Для GLO-30 это порядка сотни мегабайт на диске, разово +скачивается по проводу. + +Что это даёт против платного тарифа: +- лимита нет вообще, весь бэклог прогоняется за один вечер +- в поле сеть не нужна: у пользователя её глушат, и это постоянная + проблема всех сетевых зависимостей приложения +- тот же софт и тот же набор данных, значит результаты сопоставимы + с уже уточнёнными 12 сессиями, если там был GPXZ + +Минус: нужен хост, на котором это крутится, и разовая возня с датасетом. + +## Но своего хоста может и не понадобиться + +Автор Open Topo Data держит бесплатный публичный инстанс +`api.opentopodata.org`. Его ограничения: + + 100 точек на запрос -- ровно тот размер пачки, что уже в коде + 1 запрос в секунду -- нужен троттлинг, сейчас пауз между пачками нет + 1000 запросов в сутки -- в десять раз больше бесплатного GPXZ + + весь бэклог 3711 запросов -> 3.7 суток, 62 минуты чистого времени, + то есть ~15 минут работы в день + +Сравнение по срокам на тот же объём: + + GPXZ Evaluation (бесплатно) 37.1 дней + Open Topo Data публичный 3.7 дней + GPXZ Small (99 USD/мес) 1.5 дня + +**Ключ не нужен вообще**, значит отпадает и вопрос, укладывается ли +массовый прогон личного архива в пометку «testing only» бесплатного +тарифа GPXZ. + +### Выбор датасета, и почему это не мелочь + +На публичном инстансе **нет Copernicus GLO-30**, на котором работает GPXZ. +Из глобальных 30-метровых доступны `mapzen`, `aster30m`, `srtm30m`. +`eudem25m` (25 м, лучшая точность) покрывает только страны EEA, регион +пользователя в его extent не входит. + +Отсюда два следствия: + +1. Датасет будет **не тот**, которым уточнены нынешние 12 сессий. + Смешивать нельзя, и колонка источника из пункта 2 выше становится + не пожеланием, а обязательной: без неё база превратится в винегрет + из трёх рельефов. +2. Разумный выбор по умолчанию `srtm30m` (USGS): у `aster30m` известны + шумы и артефакты, `mapzen` это композит из нескольких источников, + то есть внутри одного датасета точность неоднородна. + +Хорошая проверка после прогона: 12 уже уточнённых сессий уточнить заново +новым датасетом в отдельную колонку и сравнить набор высоты. Разойдутся +сильно - значит выбор датасета важнее, чем кажется, и это надо знать +до того, как на этих высотах строить разбор поездок. + +## ПРОВЕРЕНО: в базе лежит не то, что все думали + +Проверка сделана, и она важнее всего остального в этом разделе. + +**Ключ GPXZ не работает и, судя по всему, не работал никогда.** Запрос +к `api.gpxz.io` возвращает HTTP 403 с телом `error code: 1010` +и заголовком `Server: cloudflare`. Ответ **одинаковый с ключом и без +ключа**, то есть запрос режет Cloudflare по подписи клиента, до всякой +проверки ключа. Ключ при этом валидный на вид: 28 символов, префикс `ak_`. + +**Те 12 «уточнённых» сессий уточнены open-meteo.** Это третий, последний +запасной источник в цепочке `GPXZ -> open-elevation -> open-meteo`. +Доказательство: опорные целые значения в `altitude_srtm_m` совпадают +с ответом open-meteo точка в точку и расходятся с `srtm30m` на 2-3 м. + +**Дробные значения в базе создаёт приложение, а не источник.** Записанная +последовательность `39.000, 38.857, 38.714, 38.571, 38.429, 38.286, +38.143, 38.000` это `39 - k/7`, линейная интерполяция между двумя целыми +отсчётами. Все внешние DEM (srtm30m, aster30m, mapzen, srtm90m, +open-elevation, open-meteo) отдают **целые метры**. То есть дроби +в колонке выглядят как высокая точность, а на деле это прямая, натянутая +между узлами сетки в 30-90 м. + +Практическое следствие для любого будущего разбора: **нельзя считать +уклон по `altitude_srtm_m` на коротких кусках.** Интерполяция даёт +гладкий правдоподобный профиль там, где данных нет вообще. + +### Что из этого следует для задачи + +1. Тихий откат (пункт 1 выше) не гипотетическая опасность, а уже + случившийся факт: база заполнена последним источником в цепочке, + и об этом никто не знал. +2. Колонка источника (пункт 2) обязательна. +3. Отказ надо показывать пользователю, а не писать в консоль. + +### Покупать ли GPXZ: нет + +С карты покрытия на их сайте: лидар 0.5-2 м у GPXZ есть только в США, +западной и северной Европе и Новой Зеландии. Регион пользователя +в жёлтой зоне, где по их же легенде 30 м, а по их тексту база +из Copernicus 30m. То есть платный GPXZ дал бы Copernicus 30m вместо +бесплатного SRTM 30m: другая вертикальная точность, **та же +горизонтальная сетка 30 м**. + +А упирается всё именно в горизонтальную: подъём длиной 60-100 м, на +котором пользователь срывается, это 2-3 отсчёта на любой тридцатиметровой +сетке. Ни один DEM его не разрешит, ни платный, ни бесплатный. + +Измерено на одном подъёме (1153 м, 229 точек, первый заезд 16 сентября): +источники расходятся между собой до 2-3% уклона на сотню метров, что +того же порядка, что шум GPS. `mapzen` при этом отдаёт числа, побитово +совпадающие с `srtm30m`. + +Вывод: брать бесплатный `srtm30m` с публичного Open Topo Data. From 75eca0e501bf083e3705430e965807d5ed56e17e Mon Sep 17 00:00:00 2001 From: 4aiman Date: Mon, 21 Sep 2026 12:43:49 +0300 Subject: [PATCH 16/65] Docs: current versions table in README; note router deploy of build 464 --- README.md | 17 +++++++++++++++++ .../2026-09-21_split-and-bike-guard.md | 9 +++++++++ 2 files changed, 26 insertions(+) diff --git a/README.md b/README.md index 24c3082..6335f06 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,23 @@ Персональный трекер здоровья и физической активности. +## Текущие версии (2026-09-21) + +| Что | Версия | +|-----|--------| +| Приложение (BalDav) | **0.1.464**, build **464** | +| Выкачено на роутер (OTA) | build **464** - `http://192.168.1.1:88/baldav/baldav.apk` | +| Резерв OTA (VPS) | `https://4aiman.top/baldav/baldav.apk` (обновляется флагом `-u` при сборке) | +| Встроенный движок BRouter | 1.6.3 | +| Сид продуктов (`products_seed.db`) | 11 | +| Сборка | Tauri v2, Android arm64, debug-подпись | + +Версия/номер сборки бампаются автоматически при `./build.sh android` (файлы `BUILD_NUMBER`, +`tauri.conf.json`, `Cargo.toml/lock`). Телефон сам проверяет обновление: сначала роутер +(LAN `192.168.1.1:88/baldav`), затем VPS - берёт `BUILD_NUMBER`, если больше локального, +предлагает скачать `baldav.apk`. Выкатка на роутер - существующим APK (без пересборки, чтобы +номер не уезжал) либо `./build.sh android -d -u` (соберёт новый номер и зальёт на роутер+VPS). + ## Возможности - **Дневник питания** — ~13000 продуктов с меткой источника (Магнит, 5ка, ИОН, KDV, Гриднев, свои). Поиск порядко-независимый (слова в любом порядке). Каталог чистится от неиспользованного импорта без метки (health-diet и пр.) кнопкой в настройках — съеденные и в рецептах сохраняются. 37 нутриентов (витамины, минералы, omega-3/6, вода, клетчатка). Рецепты с термообработкой. Формулы порций (4*30, 300+50). diff --git a/docs/session-logs/2026-09-21_split-and-bike-guard.md b/docs/session-logs/2026-09-21_split-and-bike-guard.md index 92901f9..24de5cc 100644 --- a/docs/session-logs/2026-09-21_split-and-bike-guard.md +++ b/docs/session-logs/2026-09-21_split-and-bike-guard.md @@ -38,6 +38,15 @@ - part2 start_time берётся из ts точки (UTC), а поле сессии - локальное; мелкая косметическая рассинхронизация времени (данные так хранятся), на функциональность не влияет. +## Выкатка на роутер (по просьбе) +- Залит СУЩЕСТВУЮЩИЙ `binaries/baldav-debug.apk` (0.1.464, build 464) на роутер keenetic + (LAN 192.168.1.1) в `/opt/share/www/baldav/baldav.apk` + `BUILD_NUMBER=464`, БЕЗ пересборки + (иначе номер уехал бы на 465). Проверено: файл 190101489 б, BUILD_NUMBER=464 на роутере. + Доступно: `http://192.168.1.1:88/baldav/baldav.apk`. Пароль роутера взят из build.sh, не + печатался. VPS-зеркало не трогал (просили только роутер). +- Текущие версии сведены в README (раздел «Текущие версии»): app 0.1.464/build 464, на роутере + 464, BRouter 1.6.3, сид продуктов 11. + ## Не сделано (по просьбе - позже/не нужно) - Велик по точкам (вариант C) не делаем: пользователь просто не будет склеивать разные велики. - Вырезание участка трека - позже. From 682bee6e9a319bc99429fc9cf5ef4e707c9858ef Mon Sep 17 00:00:00 2001 From: 4aiman Date: Mon, 21 Sep 2026 13:03:32 +0300 Subject: [PATCH 17/65] Full DB backup: export all data to a file and restore it (swap DB on next launch) --- PROJECT_MAP.md | 3 + README.md | 1 + docs/session-logs/2026-09-21_db-backup.md | 42 +++++++++++ frontend/js/main.js | 43 ++++++++++++ .../main/java/dev/baldav/app/MainActivity.kt | 48 +++++++++++++ src-tauri/src/lib.rs | 69 ++++++++++++++++++- 6 files changed, 205 insertions(+), 1 deletion(-) create mode 100644 docs/session-logs/2026-09-21_db-backup.md diff --git a/PROJECT_MAP.md b/PROJECT_MAP.md index 019b032..c0c32fb 100644 --- a/PROJECT_MAP.md +++ b/PROJECT_MAP.md @@ -182,6 +182,9 @@ | `ble_save_device` | Сохранение привязанного устройства | | `import_tiles` | Чтение тайлового SQLite батчами (base64) | | `download_file` | Скачивание бинарного файла по URL (без лимита размера) | +| `export_db` | Бэкап всей БД: `VACUUM INTO` чистой копии в app-root -> команда `exportdb:` -> Kotlin SAF-сохранение (`saveDbLauncher`, стрим файла) | +| `import_db` | Восстановление: валидирует `picked_import.db` (`quick_check` + таблицы BalDav), кладёт в `pending_restore.db`; Kotlin `onCreate` свопит на `baldav.db` до открытия БД | +| `restart_app` | Команда `restartapp` -> Kotlin перезапускает процесс (применить восстановление) | | `rebuild_roads` | Перестройка road_points из всех треков (2м дедуп; пеший point (`walk=1`) → atype 'walk') | | `get_road_points` | Чтение road_points батчами (обход лимита SQL plugin) | | `get_ride_graph` | Граф проезженных дорог для роутинга по своим трекам: узлы на сетке ~20м (исключая `walk=1`), рёбра между последовательными точками сессии (вес = между узлами). `{nodes,edges}` | diff --git a/README.md b/README.md index 6335f06..5923dea 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ - **Роутинг сначала по проезженному** - публичные данные BRouter часто не совпадают с землёй (роутит по несуществующим дорогам). Поэтому планировщик для каждой дорожной ноги сначала пробует проложить путь **по дорогам, где ты реально ездил** (граф из своих треков: узлы на сетке ~20м, исключая пешие куски), и только если проезженного рядом нет или путь выходит сильно кривой - обращается к BRouter. Ридден-нога рисуется тонкой линией (без высот), BRouter-нога - оковкой с раскраской. Граф строится из `gps_points` командой `get_ride_graph`, кэшируется. - **Объединение поездок** - долгий тап по треку в истории включает выделение, тап отмечает ещё; когда выбрано 2+, кнопка **«Объединить»** склеивает их в одну тренировку (точки всех сессий переносятся в самую раннюю по времени, порядок по таймстампу, стата пересчитывается). Полезно, когда одна тренировка записалась несколькими кусками (например, отдельный заезд оказался в своей сессии - после склейки он попадёт в «Разбор повторов»). Разрыв между треками больше 5 минут - предупреждает, но не мешает. **Разные велосипеды склеить нельзя** - показывает предупреждение (передача/вес отличаются, мощность и каденс посчитались бы неверно); если это один велосипед, задай его трекам одинаково и склеивай. Обратимо: у каждой точки хранится исходная сессия (`gps_points.orig_session_id`). - **Разрезание трека** - в детали трека кнопка **«Разрезать трек»** включает режим: тап по точке на карте -> в её окошке кнопка **«✂ Разрезать здесь»** -> подтверждение -> второй кусок становится отдельной тренировкой (с тем же велосипедом; порядок точек по времени). Зачем: если часть трека проехана на другом велосипеде или другой передаче - разрежь в точке смены, назначь каждому куску свой велосипед (в детали: «Велосипед» + «Пересчитать»), и мощность/каденс посчитаются по правильной передаче на каждом куске. +- **Резервная копия всех данных** - в Настройках **Экспорт** сливает всю базу (`baldav.db`) в файл через SAF (`export_db` -> `VACUUM INTO` чистой сжатой копии, ~на треть меньше), **Импорт** выбирает файл, проверяет его (`PRAGMA quick_check` + наличие таблиц BalDav) и восстанавливает с ЗАМЕНОЙ: файл кладётся в `pending_restore.db`, приложение перезапускается, и Kotlin в `onCreate` (до открытия БД) свопит его на `baldav.db`. Секретов в базе нет. Зачем: переносить наработки между сборками (**debug <-> release** - разные подписи, переустановка стирает данные) без сторонних бэкаперов. Внутриприложенческой авто-установки APK намеренно нет - только экспорт/импорт данных. - **Разбор повторов (интервалы)** - катаешь один подъём несколько раз за поездку (один трек, без стоп/старт на каждом заезде) - деталь трека сама находит повторяемый участок по самоподобию (сетка ~10м, повторно посещённые ячейки, самый длинный эталонный проход) и в сворачиваемой секции «Разбор повторов» показывает таблицу заездов (номер, время, скорость, каденс, мощность) и главное число - **последний / первый**, с вердиктом: >=100% зелёный (можно завтра), 90-99% жёлтый (тяжёлая норма), <90% красный (два дня спокойно). Каждая точка проецируется на опорную линию (снос вбок в пределах ~30м, например при возне с седлом, не режет покрытие); время заезда - только секунды в движении (остановки вырезаются, скорость нормируется на длину участка). Найденный участок подсвечивается на карте. Только вело. `GPS.detectRepeats`; повторов нет (обычная поездка A-B) - так и пишет. - **Похвастаться (шэр-картинка)** - в детали трека кнопка **«Похвастаться»**: генерирует картинку в формате сторис (9:16) с формой трека и статами (Дистанция/Время/Средняя скорость крупно + набор/ккал/дата), **прозрачную** (PNG с альфой для наложения на своё фото) или **на фото-подложке** из настроек. В модалке предпросмотра галочками выбирается, что класть на картинку (форма трека, дистанция, время, средняя/макс. скорость, набор высоты, калории, дата; выбор запоминается), переключатель прозрачный/фото и кнопки **Поделиться** (системный шэр) и **Сохранить** (SAF). Подложка выбирается в Настройках. - **Сегменты - сворачиваемо** - в детали трека список сегментов высот (climb/descent, до десятков штук) свёрнут в аккордеон (`
`, свёрнут по умолчанию, счётчик в заголовке); тап разворачивает/сворачивает. Кнопки внизу (Похвастаться, Экспорт, Пересчитать, Удалить) остаются доступны без долгого скролла. diff --git a/docs/session-logs/2026-09-21_db-backup.md b/docs/session-logs/2026-09-21_db-backup.md new file mode 100644 index 0000000..1388061 --- /dev/null +++ b/docs/session-logs/2026-09-21_db-backup.md @@ -0,0 +1,42 @@ +# Сессия 2026-09-21 (2) - экспорт/импорт всей БД (резервная копия) + +## Контекст +Смена сборки debug<->release стирает данные (разные подписи -> переустановка). Решили: +НЕ делать внутриприложенческую авто-установку APK, а сделать полный бэкап/восстановление БД. +Записано в память: `project_no_self_install`. Секретов в базе нет. + +## Сделано +- Rust (`lib.rs`): + - `db_and_root(app)` - резолв пути `baldav.db` + app-root (как в rebuild_roads). + - `export_db(filename)` - `VACUUM INTO` чистой копии в `app-root/export_db.tmp` (консистентно + при живом соединении, сжимает ~на треть), команда `exportdb:` в gps_command.txt. + - `import_db()` - валидирует `picked_import.db` (`PRAGMA quick_check`=='ok' + есть хоть одна + таблица из gps_sessions/products/profile), копирует в `pending_restore.db`, удаляет picked. + - `restart_app()` - команда `restartapp`. + - Все три в generate_handler. +- Kotlin (`MainActivity.kt`): + - `pendingDbFile` + `saveDbLauncher` (CreateDocument octet-stream, СТРИМ файла, не в память - + файл десятки МБ). + - Ветка `exportdb:` -> SAF-сохранение `export_db.tmp`. + - Ветка `restartapp` -> relaunch (getLaunchIntentForPackage + FLAG_ACTIVITY_NEW_TASK) + + `Runtime.exit(0)`. + - `onCreate` (сразу после appDataDir, ДО открытия БД): если есть `pending_restore.db` - + скопировать на `baldav.db`, удалить journal/wal/shm и pending. Тот же паттерн, что копия сида. +- JS (`main.js`): в Настройках блок «Резервная копия всех данных» с кнопками Экспорт/Импорт. + Экспорт -> `export_db`. Импорт -> confirm (заменит всё) -> `pickLocalFile` -> `import_db` -> + confirm перезапуска -> `restart_app`. Импорт только на Android. + +## Проверка +- JS синтаксис ок. Автономный тест `VACUUM INTO` на десктоп-БД: 53МБ -> 36МБ, quick_check ok, + сессии на месте (валидная сжатая копия). Ядро экспорта подтверждено. +- SAF-сохранение, своп при onCreate и рестарт - только на устройстве (Kotlin), десктоп не + воспроизводит. Проверка на телефоне: экспорт -> файл-бэкап валиден; импорт другого бэкапа -> + данные заменились после рестарта. (Сборка bump до 0.1.465.) + +## Замечания +- Сжатие gzip не делал: VACUUM INTO уже компактит; flate2 в зависимостях нет. Добавлю, если + нужно меньше. +- Схема-совместимость: миграции в db.js идемпотентны (ALTER ... IF NOT EXISTS), старый бэкап + подтянется вверх, новый в старое приложение - лишние колонки безвредны. + +## На роутере всё ещё 0.1.464 (build 464). Новый бэкап-билд (465) на роутер не выкатывал. diff --git a/frontend/js/main.js b/frontend/js/main.js index 1e45a6f..3c7e7cd 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -6861,6 +6861,16 @@ async function renderSettings(container) { +
+
+
Резервная копия всех данных
+
Экспорт всей базы в файл и восстановление из него - чтобы переносить наработки между версиями (debug/release) без потерь. Импорт заменяет ВСЁ и перезапускает приложение.
+
+
+ + +
+
Очистить продукты без магазина
@@ -7266,6 +7276,39 @@ async function renderSettings(container) { let sbgClear = container.querySelector("#s-share-bg-clear"); if (sbgClear) sbgClear.onclick = () => { localStorage.removeItem("baldav_share_bg"); showToast("Подложка сброшена"); }; + // Full-DB backup: export the whole database to a file (SAF save), and restore from one. + let expDbBtn = container.querySelector("#s-export-db"); + if (expDbBtn) expDbBtn.onclick = async () => { + let prev = expDbBtn.textContent; + expDbBtn.disabled = true; expDbBtn.textContent = "Готовлю..."; + try { + let filename = "baldav-backup-" + new Date().toISOString().slice(0, 10) + ".db"; + let res = await window.__TAURI__.core.invoke("export_db", { filename }); + let size = 0; try { size = JSON.parse(res).size || 0; } catch {} + showToast("Копия готова (" + (size / 1048576).toFixed(1) + " МБ) - выберите, куда сохранить"); + } catch (e) { appAlert("Экспорт не удался: " + (e.message || e)); } + expDbBtn.disabled = false; expDbBtn.textContent = prev; + }; + let impDbBtn = container.querySelector("#s-import-db"); + if (impDbBtn) impDbBtn.onclick = async () => { + if (!/Android/i.test(navigator.userAgent)) { appAlert("Импорт базы доступен только на устройстве."); return; } + if (!(await appConfirm("Импорт ЗАМЕНИТ все текущие данные (треки, питание, профиль, велосипеды) данными из файла. Несохранённое пропадёт.\n\nСделайте экспорт текущей базы, если она нужна. Продолжить?", "Выбрать файл"))) return; + let prev = impDbBtn.textContent; + impDbBtn.disabled = true; impDbBtn.textContent = "Выбор..."; + try { + let path = await pickLocalFile("*/*"); + if (!path) { showToast("Файл не выбран"); return; } + impDbBtn.textContent = "Проверка..."; + await window.__TAURI__.core.invoke("import_db"); // validates picked_import.db + stages it + if (await appConfirm("База проверена и готова. Приложение перезапустится, чтобы применить восстановление. Перезапустить сейчас?", "Перезапустить")) { + await window.__TAURI__.core.invoke("restart_app"); + } else { + showToast("Восстановление применится при следующем запуске"); + } + } catch (e) { appAlert("Импорт не удался: " + (e.message || e)); } + impDbBtn.disabled = false; impDbBtn.textContent = prev; + }; + // Download tiles from router — the list comes from the router's directory // listing, so a newly uploaded .db shows up without rebuilding the app. container.querySelector("#s-download-tiles").onclick = async () => { diff --git a/src-tauri/gen/android/app/src/main/java/dev/baldav/app/MainActivity.kt b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/MainActivity.kt index f69328f..e08170a 100644 --- a/src-tauri/gen/android/app/src/main/java/dev/baldav/app/MainActivity.kt +++ b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/MainActivity.kt @@ -25,6 +25,7 @@ class MainActivity : TauriActivity() { private var commandObserver: FileObserver? = null private var pendingSaveContent: String? = null private var pendingImageBytes: ByteArray? = null + private var pendingDbFile: File? = null private var backWebView: WebView? = null // Disable WryActivity's generated back handler: its mWebView.goBack() does NOT @@ -78,6 +79,22 @@ class MainActivity : TauriActivity() { pendingImageBytes = null } + // SAF "create document" for the full DB backup — streams the staged file (can be tens of MB) + // straight to the picked location instead of loading it into memory. + private val saveDbLauncher = registerForActivityResult( + ActivityResultContracts.CreateDocument("application/octet-stream") + ) { uri: Uri? -> + val src = pendingDbFile + if (uri != null && src != null && src.exists()) { + try { + contentResolver.openOutputStream(uri)?.use { out -> src.inputStream().use { it.copyTo(out, 1 shl 20) } } + Log.d("BalDav", "DB exported to $uri (${src.length()} bytes)") + src.delete() + } catch (e: Exception) { Log.e("BalDav", "Failed to export DB", e) } + } + pendingDbFile = null + } + // Share a PNG via the system chooser (write to cache, FileProvider content:// URI, ACTION_SEND). private fun shareImage(bytes: ByteArray, filename: String) { try { @@ -127,6 +144,22 @@ class MainActivity : TauriActivity() { val appDataDir = filesDir.parentFile ?: filesDir Log.d("BalDav", "App data dir: ${appDataDir.absolutePath}") + // Restore-on-launch: a full-DB import stages pending_restore.db; swap it onto baldav.db NOW, + // before the app opens the DB (tauri-plugin-sql opens it later, when db.js runs). Then clear + // any stale journal so the restored file is the single source of truth. + try { + val pending = File(appDataDir, "pending_restore.db") + if (pending.exists() && pending.length() > 0) { + val dbFile = File(appDataDir, "baldav.db") + pending.inputStream().use { i -> dbFile.outputStream().use { o -> i.copyTo(o, 1 shl 20) } } + File(appDataDir, "baldav.db-journal").delete() + File(appDataDir, "baldav.db-wal").delete() + File(appDataDir, "baldav.db-shm").delete() + pending.delete() + Log.d("BalDav", "Restored DB from pending_restore.db (${dbFile.length()} bytes)") + } + } catch (e: Exception) { Log.e("BalDav", "DB restore failed", e) } + // Create command files if not exist (FileObserver needs them) val cmdFile = File(appDataDir, "gps_command.txt") if (!cmdFile.exists()) cmdFile.createNewFile() @@ -515,6 +548,21 @@ class MainActivity : TauriActivity() { } } } + cmd.startsWith("exportdb:") -> { + // exportdb: — SAF-save the DB copy Rust wrote to export_db.tmp. + val filename = cmd.substringAfter("exportdb:").ifBlank { "baldav-backup.db" } + val f = File(appDataDir, "export_db.tmp") + if (f.exists()) { pendingDbFile = f; saveDbLauncher.launch(filename) } + else Log.e("BalDav", "exportdb: export_db.tmp missing") + } + cmd == "restartapp" -> { + // Relaunch the process so the staged DB (pending_restore.db) is swapped in onCreate. + Log.d("BalDav", "Restarting app (DB restore)") + val intent = packageManager.getLaunchIntentForPackage(packageName) + intent?.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK) + startActivity(intent) + Runtime.getRuntime().exit(0) + } } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9466747..a7441e7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -624,6 +624,73 @@ fn read_file_base64(path: String) -> Result { Ok(base64::engine::general_purpose::STANDARD.encode(&bytes)) } +// Resolve the main baldav.db path (same fallbacks as rebuild_roads/get_road_points) and the +// app root (where Kotlin's FileObserver watches). Root = data_dir/../.. on Android. +fn db_and_root(app: &tauri::AppHandle) -> Result<(std::path::PathBuf, std::path::PathBuf), String> { + let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + let p1 = data_dir.join("../../baldav.db"); + let p2 = data_dir.join("baldav.db"); + let p3 = std::path::PathBuf::from(data_dir.to_str().unwrap_or("").replace("/files/dev.baldav.app", "")).join("baldav.db"); + let db = if p1.exists() { p1 } else if p2.exists() { p2.clone() } else { p3 }; + let root = data_dir.parent().and_then(|p| p.parent()).map(|p| p.to_path_buf()).unwrap_or(data_dir.clone()); + Ok((db, root)) +} + +// Full backup: VACUUM the live DB into a clean copy in the app root, then tell Kotlin to +// SAF-save it as `filename`. VACUUM INTO is consistent even while the app holds the DB open. +#[tauri::command] +fn export_db(app: tauri::AppHandle, filename: String) -> Result { + let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + let (db_path, root) = db_and_root(&app)?; + if !db_path.exists() { return Err("baldav.db не найдена".into()); } + let out = root.join("export_db.tmp"); + let _ = std::fs::remove_file(&out); + { + let conn = rusqlite::Connection::open(&db_path).map_err(|e| format!("open: {e}"))?; + conn.execute("VACUUM INTO ?1", rusqlite::params![out.to_str().unwrap_or("")]).map_err(|e| format!("vacuum: {e}"))?; + } + let size = std::fs::metadata(&out).map(|m| m.len()).unwrap_or(0); + let cmd = format!("exportdb:{}", filename); + let _ = std::fs::write(data_dir.join("gps_command.txt"), &cmd); + let _ = std::fs::write(root.join("gps_command.txt"), &cmd); + Ok(format!("{{\"size\":{}}}", size)) +} + +// Full restore: validate the picked DB (picked_import.db) and stage it as pending_restore.db. +// Kotlin swaps it onto baldav.db on the next launch, BEFORE the app opens the DB. Returns ok; +// caller then restarts the app. +#[tauri::command] +fn import_db(app: tauri::AppHandle) -> Result { + let (_db, root) = db_and_root(&app)?; + let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + let picked = if root.join("picked_import.db").exists() { root.join("picked_import.db") } + else { data_dir.join("picked_import.db") }; + if !picked.exists() { return Err("файл не выбран".into()); } + { + let conn = rusqlite::Connection::open(&picked).map_err(|e| format!("не открыть файл: {e}"))?; + let ok: String = conn.query_row("PRAGMA quick_check", [], |r| r.get(0)).map_err(|e| format!("проверка: {e}"))?; + if ok != "ok" { return Err(format!("база повреждена: {ok}")); } + let has: i64 = conn.query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN ('gps_sessions','products','profile')", + [], |r| r.get(0)).map_err(|e| format!("схема: {e}"))?; + if has < 1 { return Err("это не похоже на базу BalDav".into()); } + } + let pending = root.join("pending_restore.db"); + std::fs::copy(&picked, &pending).map_err(|e| format!("подготовка: {e}"))?; + let _ = std::fs::remove_file(&picked); + Ok("staged".into()) +} + +// Ask Kotlin to restart the app process (used after import_db to load the restored DB). +#[tauri::command] +fn restart_app(app: tauri::AppHandle) -> Result { + let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + let (_db, root) = db_and_root(&app)?; + let _ = std::fs::write(data_dir.join("gps_command.txt"), "restartapp"); + let _ = std::fs::write(root.join("gps_command.txt"), "restartapp"); + Ok("ok".into()) +} + // Read tiles from a SQLite file in batches for import into IndexedDB #[tauri::command] fn import_tiles(path: String, offset: u32, batch_size: u32) -> Result { @@ -1088,7 +1155,7 @@ pub fn run() { .add_migrations("sqlite:baldav.db", migrations) .build(), ) - .invoke_handler(tauri::generate_handler![get_safe_insets, import_seed_products, import_tiles, download_file, get_points_filename, delete_points_file, gps_set_session_id, list_points_files, read_points_file, pick_file_open, get_picked_file, debug_native_paths, rebuild_roads, get_road_points, get_ride_graph, get_app_info, gps_service_command, gps_service_read_points, brouter_route, brouter_download_tile, brouter_download_status, brouter_list_tiles, save_file, export_image, read_file_base64, get_update_info, http_request, chzn_http, ble::ble_scan, ble::ble_scan_results, ble::ble_connect, ble::ble_disconnect, ble::ble_get_hr, ble::ble_is_connected, ble::ble_check_bluetooth, ble::ble_save_device, ble::ble_clear_hr]) + .invoke_handler(tauri::generate_handler![get_safe_insets, import_seed_products, import_tiles, download_file, get_points_filename, delete_points_file, gps_set_session_id, list_points_files, read_points_file, pick_file_open, get_picked_file, debug_native_paths, rebuild_roads, get_road_points, get_ride_graph, get_app_info, gps_service_command, gps_service_read_points, brouter_route, brouter_download_tile, brouter_download_status, brouter_list_tiles, save_file, export_image, read_file_base64, export_db, import_db, restart_app, get_update_info, http_request, chzn_http, ble::ble_scan, ble::ble_scan_results, ble::ble_connect, ble::ble_disconnect, ble::ble_get_hr, ble::ble_is_connected, ble::ble_check_bluetooth, ble::ble_save_device, ble::ble_clear_hr]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } From 263163cf52140c32786e81aec3af2fdefb2cecb2 Mon Sep 17 00:00:00 2001 From: 4aiman Date: Mon, 21 Sep 2026 13:05:34 +0300 Subject: [PATCH 18/65] Bump version to 0.1.465 (build.sh output) --- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 0f38ea0..7d5f09f 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -139,7 +139,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "baldav" -version = "0.1.464" +version = "0.1.465" dependencies = [ "base64 0.22.1", "btleplug", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 5c08cfb..0fc1ead 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "baldav" -version = "0.1.464" +version = "0.1.465" edition = "2021" [lib] diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index d49e55d..2e0decd 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/nicegui/tauri-v2/refs/heads/main/tooling/cli/schema.json", "productName": "BalDav", - "version": "0.1.464", + "version": "0.1.465", "identifier": "dev.baldav.app", "build": { "frontendDist": "../frontend" From bb8a739b7e4f88fc2d7e962da764cca0ac7e7aed Mon Sep 17 00:00:00 2001 From: 4aiman Date: Mon, 21 Sep 2026 13:19:18 +0300 Subject: [PATCH 19/65] DB backup: write export/staging next to baldav.db (app_data_dir can be the app root; ../.. hit unwritable /data/user) --- src-tauri/src/lib.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a7441e7..8eb1af7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -625,14 +625,17 @@ fn read_file_base64(path: String) -> Result { } // Resolve the main baldav.db path (same fallbacks as rebuild_roads/get_road_points) and the -// app root (where Kotlin's FileObserver watches). Root = data_dir/../.. on Android. +// app root = the DIRECTORY that holds baldav.db. Deriving root from data_dir/../.. is wrong: +// app_data_dir() on Android is sometimes already the app root, so ../.. lands in /data/user +// (not writable) — measured, VACUUM INTO failed there. The dir next to baldav.db is always +// writable and is exactly what Kotlin (filesDir.parentFile) watches/reads. fn db_and_root(app: &tauri::AppHandle) -> Result<(std::path::PathBuf, std::path::PathBuf), String> { let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; let p1 = data_dir.join("../../baldav.db"); let p2 = data_dir.join("baldav.db"); let p3 = std::path::PathBuf::from(data_dir.to_str().unwrap_or("").replace("/files/dev.baldav.app", "")).join("baldav.db"); - let db = if p1.exists() { p1 } else if p2.exists() { p2.clone() } else { p3 }; - let root = data_dir.parent().and_then(|p| p.parent()).map(|p| p.to_path_buf()).unwrap_or(data_dir.clone()); + let db = if p1.exists() { p1 } else if p2.exists() { p2 } else { p3 }; + let root = db.parent().map(|p| p.to_path_buf()).unwrap_or(data_dir.clone()); Ok((db, root)) } From 5b3239258f630a0ad268ceb0f192419a1c8ee721 Mon Sep 17 00:00:00 2001 From: 4aiman Date: Mon, 21 Sep 2026 13:20:10 +0300 Subject: [PATCH 20/65] Bump version to 0.1.466 (build.sh output) --- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 7d5f09f..6d12cbb 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -139,7 +139,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "baldav" -version = "0.1.465" +version = "0.1.466" dependencies = [ "base64 0.22.1", "btleplug", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0fc1ead..c0552c9 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "baldav" -version = "0.1.465" +version = "0.1.466" edition = "2021" [lib] diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 2e0decd..7b13025 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/nicegui/tauri-v2/refs/heads/main/tooling/cli/schema.json", "productName": "BalDav", - "version": "0.1.465", + "version": "0.1.466", "identifier": "dev.baldav.app", "build": { "frontendDist": "../frontend" From d0abb72abae0942a3e8fd7ba45b7ed51c359979b Mon Sep 17 00:00:00 2001 From: 4aiman Date: Mon, 21 Sep 2026 13:22:05 +0300 Subject: [PATCH 21/65] DB backup: use native_root() for the app root (not app_data_dir parent chain) - the fix we already made for point files --- src-tauri/src/lib.rs | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8eb1af7..d864428 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -624,18 +624,14 @@ fn read_file_base64(path: String) -> Result { Ok(base64::engine::general_purpose::STANDARD.encode(&bytes)) } -// Resolve the main baldav.db path (same fallbacks as rebuild_roads/get_road_points) and the -// app root = the DIRECTORY that holds baldav.db. Deriving root from data_dir/../.. is wrong: -// app_data_dir() on Android is sometimes already the app root, so ../.. lands in /data/user -// (not writable) — measured, VACUUM INTO failed there. The dir next to baldav.db is always -// writable and is exactly what Kotlin (filesDir.parentFile) watches/reads. +// App root (holds baldav.db + point files + the command files Kotlin watches) and the DB path. +// MUST use native_root(): app_data_dir() nesting on Android is unreliable, so any ad-hoc +// app_data_dir().parent().parent() / db.parent() is wrong (measured: it lands in the +// unwritable /data/user and VACUUM INTO fails). native_root() scans candidates and returns the +// real writable app root that Kotlin's filesDir.parentFile also uses. Same as save points do. fn db_and_root(app: &tauri::AppHandle) -> Result<(std::path::PathBuf, std::path::PathBuf), String> { - let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; - let p1 = data_dir.join("../../baldav.db"); - let p2 = data_dir.join("baldav.db"); - let p3 = std::path::PathBuf::from(data_dir.to_str().unwrap_or("").replace("/files/dev.baldav.app", "")).join("baldav.db"); - let db = if p1.exists() { p1 } else if p2.exists() { p2 } else { p3 }; - let root = db.parent().map(|p| p.to_path_buf()).unwrap_or(data_dir.clone()); + let root = native_root(app); + let db = root.join("baldav.db"); Ok((db, root)) } From d4faed200d24f95250a2e894420b795ba4eea840 Mon Sep 17 00:00:00 2001 From: 4aiman Date: Mon, 21 Sep 2026 13:22:50 +0300 Subject: [PATCH 22/65] Bump version to 0.1.467 (build.sh output) --- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 6d12cbb..252105c 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -139,7 +139,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "baldav" -version = "0.1.466" +version = "0.1.467" dependencies = [ "base64 0.22.1", "btleplug", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c0552c9..79b3571 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "baldav" -version = "0.1.466" +version = "0.1.467" edition = "2021" [lib] diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 7b13025..2ef0037 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/nicegui/tauri-v2/refs/heads/main/tooling/cli/schema.json", "productName": "BalDav", - "version": "0.1.466", + "version": "0.1.467", "identifier": "dev.baldav.app", "build": { "frontendDist": "../frontend" From 9e0910a35fea6223de725abb3dfd72f8243a89d8 Mon Sep 17 00:00:00 2001 From: 4aiman Date: Mon, 21 Sep 2026 13:36:17 +0300 Subject: [PATCH 23/65] DB backup: drop data_dir writes, use native_root only (match gps_service_read_points pattern) --- src-tauri/src/lib.rs | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d864428..3cc30ea 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -639,8 +639,7 @@ fn db_and_root(app: &tauri::AppHandle) -> Result<(std::path::PathBuf, std::path: // SAF-save it as `filename`. VACUUM INTO is consistent even while the app holds the DB open. #[tauri::command] fn export_db(app: tauri::AppHandle, filename: String) -> Result { - let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; - let (db_path, root) = db_and_root(&app)?; + let (db_path, root) = db_and_root(&app)?; // root = native_root: the writable app dir Kotlin watches if !db_path.exists() { return Err("baldav.db не найдена".into()); } let out = root.join("export_db.tmp"); let _ = std::fs::remove_file(&out); @@ -649,9 +648,7 @@ fn export_db(app: tauri::AppHandle, filename: String) -> Result conn.execute("VACUUM INTO ?1", rusqlite::params![out.to_str().unwrap_or("")]).map_err(|e| format!("vacuum: {e}"))?; } let size = std::fs::metadata(&out).map(|m| m.len()).unwrap_or(0); - let cmd = format!("exportdb:{}", filename); - let _ = std::fs::write(data_dir.join("gps_command.txt"), &cmd); - let _ = std::fs::write(root.join("gps_command.txt"), &cmd); + let _ = std::fs::write(root.join("gps_command.txt"), format!("exportdb:{}", filename)); Ok(format!("{{\"size\":{}}}", size)) } @@ -660,10 +657,8 @@ fn export_db(app: tauri::AppHandle, filename: String) -> Result // caller then restarts the app. #[tauri::command] fn import_db(app: tauri::AppHandle) -> Result { - let (_db, root) = db_and_root(&app)?; - let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; - let picked = if root.join("picked_import.db").exists() { root.join("picked_import.db") } - else { data_dir.join("picked_import.db") }; + let (_db, root) = db_and_root(&app)?; // root = native_root: Kotlin wrote picked_import.db here + let picked = root.join("picked_import.db"); if !picked.exists() { return Err("файл не выбран".into()); } { let conn = rusqlite::Connection::open(&picked).map_err(|e| format!("не открыть файл: {e}"))?; @@ -683,9 +678,7 @@ fn import_db(app: tauri::AppHandle) -> Result { // Ask Kotlin to restart the app process (used after import_db to load the restored DB). #[tauri::command] fn restart_app(app: tauri::AppHandle) -> Result { - let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; - let (_db, root) = db_and_root(&app)?; - let _ = std::fs::write(data_dir.join("gps_command.txt"), "restartapp"); + let (_db, root) = db_and_root(&app)?; // root = native_root, the dir Kotlin's FileObserver watches let _ = std::fs::write(root.join("gps_command.txt"), "restartapp"); Ok("ok".into()) } From ae90b4368b81061a6f74ebd1c3b79204409fcc53 Mon Sep 17 00:00:00 2001 From: 4aiman Date: Mon, 21 Sep 2026 13:36:43 +0300 Subject: [PATCH 24/65] Bump version (build.sh output) --- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 252105c..e8a092f 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -139,7 +139,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "baldav" -version = "0.1.467" +version = "0.1.468" dependencies = [ "base64 0.22.1", "btleplug", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 79b3571..12e00f7 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "baldav" -version = "0.1.467" +version = "0.1.468" edition = "2021" [lib] diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 2ef0037..74d864a 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/nicegui/tauri-v2/refs/heads/main/tooling/cli/schema.json", "productName": "BalDav", - "version": "0.1.467", + "version": "0.1.468", "identifier": "dev.baldav.app", "build": { "frontendDist": "../frontend" From 492c54990fa7d1f217de5cd313ba0a63e9e4bc58 Mon Sep 17 00:00:00 2001 From: 4aiman Date: Mon, 21 Sep 2026 13:44:54 +0300 Subject: [PATCH 25/65] DB restore: restart via AlarmManager (startActivity+exit raced, app just closed) --- docs/session-logs/2026-09-21_db-backup.md | 14 +++++++++++++- .../main/java/dev/baldav/app/MainActivity.kt | 18 +++++++++++++++--- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/docs/session-logs/2026-09-21_db-backup.md b/docs/session-logs/2026-09-21_db-backup.md index 1388061..6fc11b2 100644 --- a/docs/session-logs/2026-09-21_db-backup.md +++ b/docs/session-logs/2026-09-21_db-backup.md @@ -39,4 +39,16 @@ - Схема-совместимость: миграции в db.js идемпотентны (ALTER ... IF NOT EXISTS), старый бэкап подтянется вверх, новый в старое приложение - лишние колонки безвредны. -## На роутере всё ещё 0.1.464 (build 464). Новый бэкап-билд (465) на роутер не выкатывал. +## Проверка на теле + фиксы +- Экспорт сперва падал: `vacuum: unable to open database: /data/user/...`. Причина - я взял + корень как `app_data_dir().parent().parent()` (на Android app_data_dir САМ = корень -> ../.. + уводит в непишемый `/data/user`). ЭТО УЖЕ ЧИНИЛИ функцией `native_root()`. Исправил на + `native_root()` (как save-команды/rebuild_roads), убрал data_dir-дубли. Записано в память + `reference_android_native_root`. Подтверждено на теле: корень `/data/user/0/dev.baldav.app` + писабелен, там baldav.db + точки + командные файлы -> native_root резолвится туда. +- Экспорт + импорт (восстановление) пользователь проверил на теле - работают. +- Авто-рестарт после импорта не срабатывал: `startActivity()+exit(0)` - гонка (процесс умирает + до подъёма активити). Исправлено: relaunch через AlarmManager+PendingIntent ПОСЛЕ смерти + процесса (ProcessPhoenix-паттерн) + finishAffinity. + +## На роутере всё ещё 0.1.464 (build 464). Бэкап-билды на роутер не выкатывал. diff --git a/src-tauri/gen/android/app/src/main/java/dev/baldav/app/MainActivity.kt b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/MainActivity.kt index e08170a..93b0633 100644 --- a/src-tauri/gen/android/app/src/main/java/dev/baldav/app/MainActivity.kt +++ b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/MainActivity.kt @@ -557,10 +557,22 @@ class MainActivity : TauriActivity() { } cmd == "restartapp" -> { // Relaunch the process so the staged DB (pending_restore.db) is swapped in onCreate. + // startActivity()+exit() races (the new activity doesn't come up before the process + // dies — measured: app just closed, no restart). Schedule the relaunch via AlarmManager + // so it fires AFTER this process is gone (the ProcessPhoenix approach). Log.d("BalDav", "Restarting app (DB restore)") - val intent = packageManager.getLaunchIntentForPackage(packageName) - intent?.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK) - startActivity(intent) + try { + val intent = packageManager.getLaunchIntentForPackage(packageName) + if (intent != null) { + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK) + val piFlags = android.app.PendingIntent.FLAG_CANCEL_CURRENT or + (if (Build.VERSION.SDK_INT >= 23) android.app.PendingIntent.FLAG_IMMUTABLE else 0) + val pi = android.app.PendingIntent.getActivity(this, 0, intent, piFlags) + val am = getSystemService(ALARM_SERVICE) as android.app.AlarmManager + am.set(android.app.AlarmManager.RTC, System.currentTimeMillis() + 300, pi) + } + } catch (e: Exception) { Log.e("BalDav", "restart schedule failed", e) } + finishAffinity() Runtime.getRuntime().exit(0) } } From ea7c6e68a810879336b4774662ff41d48f940287 Mon Sep 17 00:00:00 2001 From: 4aiman Date: Mon, 21 Sep 2026 13:45:36 +0300 Subject: [PATCH 26/65] Bump version (build.sh output) --- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index e8a092f..9549443 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -139,7 +139,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "baldav" -version = "0.1.468" +version = "0.1.469" dependencies = [ "base64 0.22.1", "btleplug", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 12e00f7..3d933ec 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "baldav" -version = "0.1.468" +version = "0.1.469" edition = "2021" [lib] diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 74d864a..b46f6d3 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/nicegui/tauri-v2/refs/heads/main/tooling/cli/schema.json", "productName": "BalDav", - "version": "0.1.468", + "version": "0.1.469", "identifier": "dev.baldav.app", "build": { "frontendDist": "../frontend" From c913cc530ccc170131a3dd41d71f9b0c27ac0d7d Mon Sep 17 00:00:00 2001 From: 4aiman Date: Mon, 21 Sep 2026 13:48:47 +0300 Subject: [PATCH 27/65] DB restore: drop unreliable auto-restart, ask user to reopen the app (reliable, restore applies on next launch) --- frontend/js/main.js | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/frontend/js/main.js b/frontend/js/main.js index 3c7e7cd..f0edc3b 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -7300,11 +7300,10 @@ async function renderSettings(container) { if (!path) { showToast("Файл не выбран"); return; } impDbBtn.textContent = "Проверка..."; await window.__TAURI__.core.invoke("import_db"); // validates picked_import.db + stages it - if (await appConfirm("База проверена и готова. Приложение перезапустится, чтобы применить восстановление. Перезапустить сейчас?", "Перезапустить")) { - await window.__TAURI__.core.invoke("restart_app"); - } else { - showToast("Восстановление применится при следующем запуске"); - } + // No auto-restart: on a single-activity app the process-restart is unreliable (Android 12+ + // blocks background activity starts). The staged DB is swapped on the next launch, so ask + // the user to fully reopen the app — reliable, and they confirmed it works. + await appAlert("База проверена и готова. Чтобы применить восстановление:\n\n1. Полностью закройте приложение (смахните его из списка недавних).\n2. Откройте снова.\n\nПосле этого данные будут заменены восстановленными."); } catch (e) { appAlert("Импорт не удался: " + (e.message || e)); } impDbBtn.disabled = false; impDbBtn.textContent = prev; }; From b379160ff5afe1abbd56d022143c0cc66aa61556 Mon Sep 17 00:00:00 2001 From: 4aiman Date: Mon, 21 Sep 2026 13:49:27 +0300 Subject: [PATCH 28/65] Bump version (build.sh output) --- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 9549443..335736a 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -139,7 +139,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "baldav" -version = "0.1.469" +version = "0.1.470" dependencies = [ "base64 0.22.1", "btleplug", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 3d933ec..440b559 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "baldav" -version = "0.1.469" +version = "0.1.470" edition = "2021" [lib] diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index b46f6d3..91c4bc5 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/nicegui/tauri-v2/refs/heads/main/tooling/cli/schema.json", "productName": "BalDav", - "version": "0.1.469", + "version": "0.1.470", "identifier": "dev.baldav.app", "build": { "frontendDist": "../frontend" From c84767fc9e9501a626d4a8ad99c91a13446fd5ed Mon Sep 17 00:00:00 2001 From: 4aiman Date: Mon, 21 Sep 2026 20:11:10 +0300 Subject: [PATCH 29/65] Today: 7-day balance trend really covers 7 days (-7 days is an 8-day inclusive range) --- docs/session-logs/2026-09-21_ui-fixes.md | 16 ++++++++++++++++ frontend/js/main.js | 3 +++ 2 files changed, 19 insertions(+) create mode 100644 docs/session-logs/2026-09-21_ui-fixes.md diff --git a/docs/session-logs/2026-09-21_ui-fixes.md b/docs/session-logs/2026-09-21_ui-fixes.md new file mode 100644 index 0000000..b54994d --- /dev/null +++ b/docs/session-logs/2026-09-21_ui-fixes.md @@ -0,0 +1,16 @@ +# Сессия 2026-09-21 (3) - мелкие UI-фиксы + +## #1 balance-trend показывал 8 дней вместо 7 (исправлено) +- Симптом: вверху страницы «Среднее за 8 дн.», а график ниже - за 7. +- Причина: `getDailyFoodTotals(7)` -> SQL `WHERE date >= date('now','-7 days')` - диапазон + включает оба конца (today-7 ... today) = **8 календарных дней**. Balance-trend показывал + `balances.length` (сырое число строк) = 8. График строит свой ряд, поэтому у него 7. +- Фикс точечный (не трогая общий запрос и график): в balance-trend `food7 = food7.slice(-7)` + - берём последние 7 дней. Корневой off-by-one в `getDailyFoodTotals` не трогал, чтобы не + сдвинуть график 30/90. + +## #2 сегодняшние повторы не опознались (в работе - нужен телефон) +- Пользователь делал интервалы сегодня, «Разбор повторов» их не нашёл. +- Диагностика требует сегодняшнего трека (pull с телефона + прогон `detectRepeats`). + На момент правки #1 устройство было не подключено - жду телефон, тяну базу, смотрю, + почему не сработало (порог/длина/снос/структура). diff --git a/frontend/js/main.js b/frontend/js/main.js index f0edc3b..53dc36e 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -515,6 +515,9 @@ async function renderToday(container) { DB.getDailyFoodTotals(7), DB.getDailyActivityTotals(7), ]); + // `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); if (food7.length === 0) return; let foodMap = {}; food7.forEach(d => foodMap[d.date] = Math.round(d.kcal ?? 0)); From 685151b76b3ffb5dd32a4eab147ff0ff9e03bfd0 Mon Sep 17 00:00:00 2001 From: 4aiman Date: Mon, 21 Sep 2026 20:30:38 +0300 Subject: [PATCH 30/65] Repeat analysis: sweep the hot-cell threshold and keep the most passes (out-and-back reps were lost when a twice-ridden approach inflated the reference) --- docs/session-logs/2026-09-21_ui-fixes.md | 19 ++- frontend/js/gps.js | 202 +++++++++++------------ 2 files changed, 111 insertions(+), 110 deletions(-) diff --git a/docs/session-logs/2026-09-21_ui-fixes.md b/docs/session-logs/2026-09-21_ui-fixes.md index b54994d..bdcb02b 100644 --- a/docs/session-logs/2026-09-21_ui-fixes.md +++ b/docs/session-logs/2026-09-21_ui-fixes.md @@ -9,8 +9,17 @@ - берём последние 7 дней. Корневой off-by-one в `getDailyFoodTotals` не трогал, чтобы не сдвинуть график 30/90. -## #2 сегодняшние повторы не опознались (в работе - нужен телефон) -- Пользователь делал интервалы сегодня, «Разбор повторов» их не нашёл. -- Диагностика требует сегодняшнего трека (pull с телефона + прогон `detectRepeats`). - На момент правки #1 устройство было не подключено - жду телефон, тяну базу, смотрю, - почему не сработало (порог/длина/снос/структура). +## #2 сегодняшние повторы не опознались (исправлено) +- Трек 1790002321789 (out-and-back A(47.4915,40.0817)<->B(47.4917,40.1025), ~1.5км, 5 раз + туда-обратно). `detectRepeats` -> «не найдено». +- Диагностика: горячих ячеек полно (197 с >=3 входами, макс 10). Эталон (самый длинный проход) + при пороге горячей ячейки **2** раздувается до **2190м** - тянет разово/дважды накатанный + подъезд и развороты. Полный заезд 1585м покрывает лишь 72% этого эталона (<80%), поэтому + засчитывался лишь 1 проход. При пороге 3+ эталон сжимается до ядра **1585м** и находятся все 5. +- Причина глубже минусов порога: жадный порог 2 (нужен для «2 заездов») ломает эталон, когда + подъезд накатан 2 раза. +- Фикс: `detectRepeats` теперь ПЕРЕБИРАЕТ порог горячей ячейки от 2 до макс.входов и берёт тот, + что даёт БОЛЬШЕ проходов (при равенстве - длиннее участок). `minPasses` -> только финальный + порог числа проходов (`minReps`), развязан с порогом горячей ячейки. Вынесено в `detectAt(T)`. +- Проверка (харнесс): СЕГОДНЯ 5× 1590м [16.8,17.6,17.1,17.1,16.8]; 11сен 5×, 17сен 5×, + 16сен(склеен) 5×; 5 обычных поездок - 0 ложных. ~0.9с/сессия на десктопе (перебор порогов). diff --git a/frontend/js/gps.js b/frontend/js/gps.js index 60e97be..d580888 100644 --- a/frontend/js/gps.js +++ b/frontend/js/gps.js @@ -105,16 +105,12 @@ GPS.detectRepeats = function(points, opts) { opts = opts || {}; let cell = opts.cellM ?? 10; // grid cell ~10 m (as in tools/track heightmap) let minLenM = opts.minLenM ?? 300; // repeated segment must be at least this long - let minPasses = opts.minPasses ?? 2; // ridden at least this many times (2: a segment ridden - // twice is already a repeat; 3 missed the normal "there and - // back twice" case. Crossroads are rejected by minLenM, not - // by the count; same-direction monotonic passes reject a plain - // out-and-back commute - verified 0 false positives on rides) - let minFrac = opts.minFrac ?? 0.8; // a pass must cover this fraction of the segment (projection - // handles lateral drift, so a full rep reads as full coverage - // and this stays strict, as in tools/track pers.py) + let minReps = opts.minPasses ?? 2; // minimum passes to REPORT (final gate) + let minFrac = opts.minFrac ?? 0.8; // a pass must cover this fraction of the segment let bridgeSec = opts.bridgeSec ?? 90; // bridge an interruption inside a pass up to this long let bridgeM = opts.bridgeM ?? 60; // ...as long as the rider stays within this of the corridor + let projTolM = opts.projTolM ?? 30; // max lateral distance to count as on the corridor + let backM = opts.backM ?? 30; // progress may dip this far without ending the pass let n = points ? points.length : 0; if (n < 20) return { found: false }; @@ -123,115 +119,111 @@ GPS.detectRepeats = function(points, opts) { for (let i = 0; i < n; i++) { let la = points[i].lat, lo = points[i].lon; if (la == null || lo == null) { key[i] = null; continue; } - let ky = Math.floor(la * 111320 / cell); - let kx = Math.floor(lo * 111320 * Math.cos(la * Math.PI / 180) / cell); - key[i] = ky + "," + kx; + key[i] = Math.floor(la * 111320 / cell) + "," + Math.floor(lo * 111320 * Math.cos(la * Math.PI / 180) / cell); } // Entries per cell: count each time the track enters a cell (key changes into it). - // A corridor ridden 5x (up+down the same road) racks up ~10 entries; approach/return - // cells get 1. So >= minPasses entries marks the repeated corridor. let visits = new Map(); for (let i = 0; i < n; i++) { if (key[i] == null) continue; if (i === 0 || key[i] !== key[i - 1]) visits.set(key[i], (visits.get(key[i]) || 0) + 1); } - let isHot = (k) => k != null && (visits.get(k) || 0) >= minPasses; + let maxEntries = 0; for (let v of visits.values()) if (v > maxEntries) maxEntries = v; + if (maxEntries < 2) return { found: false }; - // Reference segment = the LONGEST clean traversal of the corridor. One traversal = - // start at a cell, add each NEW hot cell in order, STOP at the first revisit (turned - // back / next lap) or a gap out of the corridor. Gluing laps together inflates length - // and scrambles order (measured: absurd 9 s "passes" over 4.4 km); a single traversal - // fixes that. But picking the FIRST hot cell is also wrong — a ride can START inside a - // hot cell (its approach overlaps the corridor), giving a short gap-truncated seq - // (measured: 729 m of a 1572 m climb). So try a traversal from every hot-run start and - // keep the longest. - let buildTraversal = (start) => { - let seq = [], order = new Map(), end = start; - for (let k = start, gap = 0; k < n; k++) { - let c = key[k]; - if (c == null || !isHot(c)) { gap++; if (gap > 5) break; continue; } // left the corridor - gap = 0; - if (seq.length && c === seq[seq.length - 1]) { end = k; continue; } // still in the same cell - if (order.has(c)) break; // revisit of an earlier cell → traversal complete - order.set(c, seq.length); seq.push(c); end = k; - } - let len = 0; - for (let k = start + 1; k <= end; k++) - len += GPS.distance(points[k - 1].lat, points[k - 1].lon, points[k].lat, points[k].lon); - return { seq, len, start, end }; - }; - let best = null; - for (let k = 0; k < n; k++) { - if (!isHot(key[k]) || (k > 0 && isHot(key[k - 1]))) continue; // only at a hot-run start - let t = buildTraversal(k); - if (!best || t.len > best.len) best = t; - } - if (!best || best.seq.length < 3 || best.len < minLenM) return { found: false }; - - // Reference polyline = the best traversal's points, with cumulative distance = progress. - // Progress is measured by PROJECTING each ride point onto this line, not by exact cell - // match: so a lap that drifts a few metres off the common line (measured: fixing a - // dropping seat pulled the track sideways) still reads as full coverage instead of a - // truncated 74%. A cell→vertex index keeps the nearest-vertex search O(1) per point. - let projTolM = opts.projTolM ?? 30; // max lateral distance to count as on the corridor - let backM = opts.backM ?? 30; // progress may dip this far without ending the pass - let rLat = [], rLon = [], cum = [0]; - for (let k = best.start; k <= best.end; k++) { - rLat.push(points[k].lat); rLon.push(points[k].lon); - if (k > best.start) cum.push(cum[cum.length - 1] + GPS.distance(points[k - 1].lat, points[k - 1].lon, points[k].lat, points[k].lon)); - } - let refLen = cum[cum.length - 1]; - if (rLat.length < 3 || refLen < minLenM) return { found: false }; - let refCell = new Map(); - for (let v = 0; v < rLat.length; v++) { - let ck = Math.floor(rLat[v] * 111320 / cell) + "," + Math.floor(rLon[v] * 111320 * Math.cos(rLat[v] * Math.PI / 180) / cell); - (refCell.get(ck) || refCell.set(ck, []).get(ck)).push(v); - } - let rad = Math.ceil(projTolM / cell); - let prog = new Array(n).fill(null); - for (let i = 0; i < n; i++) { - if (points[i].lat == null) continue; - let cy = Math.floor(points[i].lat * 111320 / cell), cx = Math.floor(points[i].lon * 111320 * Math.cos(points[i].lat * Math.PI / 180) / cell); - let bv = -1, bd = projTolM; - for (let da = -rad; da <= rad; da++) for (let db = -rad; db <= rad; db++) { - let arr = refCell.get((cy + da) + "," + (cx + db)); if (!arr) continue; - for (let v of arr) { let d = GPS.distance(points[i].lat, points[i].lon, rLat[v], rLon[v]); if (d < bd) { bd = d; bv = v; } } - } - if (bv >= 0) prog[i] = cum[bv]; - } - - // Passes: a run where progress rises from near the bottom to >= minFrac of the segment. - // A bridged interruption (stop / GPS wobble) keeps it one pass; a descent (progress falls - // and the rider moves far) ends it. Pass time counts MOVING seconds only (>= 0.8 m/s by - // real displacement, not the fused speed field) - stops at the edges or middle of a rep - // are not climbing, so they are trimmed out. - let passes = []; - let p = 0; - while (p < n) { - if (prog[p] == null || prog[p] > refLen * 0.15) { p++; continue; } - let q = p, lo = prog[p], hi = prog[p], anchor = p, gap = 0; - while (q < n - 1) { - q++; - if (prog[q] != null && prog[q] >= hi - backM) { gap = 0; hi = Math.max(hi, prog[q]); anchor = q; continue; } - let dtStop = (new Date(points[q].ts) - new Date(points[anchor].ts)) / 1000; - let dispStop = GPS.distance(points[anchor].lat, points[anchor].lon, points[q].lat, points[q].lon); - if ((dtStop <= bridgeSec && dispStop <= bridgeM) || ++gap <= 25) continue; - break; - } - if ((hi - lo) >= refLen * minFrac) { - let mov = 0; - for (let k = p + 1; k <= anchor; k++) { - let dt = (new Date(points[k].ts) - new Date(points[k - 1].ts)) / 1000; - let disp = GPS.distance(points[k - 1].lat, points[k - 1].lon, points[k].lat, points[k].lon); - if (dt > 0 && disp / dt >= 0.8) mov += dt; // moving; a stop (dropped seat) is excluded + // Find passes for a given hot-cell threshold (cells entered >= hotThr times = the corridor). + // Returns { segLength, passes } or null. The reference is the LONGEST clean traversal of hot + // cells; each ride point is PROJECTED onto it (progress in metres); a pass is a run where + // progress rises from near the bottom to >= minFrac, tolerating drift/stops (bridge) and a + // small backstep. Pass time = moving seconds only (stops trimmed). + let detectAt = (hotThr) => { + let isHot = (k) => k != null && (visits.get(k) || 0) >= hotThr; + let buildTraversal = (start) => { + let seq = [], order = new Map(), end = start; + for (let k = start, gap = 0; k < n; k++) { + let c = key[k]; + if (c == null || !isHot(c)) { gap++; if (gap > 5) break; continue; } // left the corridor + gap = 0; + if (seq.length && c === seq[seq.length - 1]) { end = k; continue; } // still in the same cell + if (order.has(c)) break; // revisit of an earlier cell -> traversal complete + order.set(c, seq.length); seq.push(c); end = k; } - passes.push({ startIdx: p, endIdx: anchor, movingSec: Math.round(mov) }); + let len = 0; + for (let k = start + 1; k <= end; k++) + len += GPS.distance(points[k - 1].lat, points[k - 1].lon, points[k].lat, points[k].lon); + return { seq, len, start, end }; + }; + let best = null; + for (let k = 0; k < n; k++) { + if (!isHot(key[k]) || (k > 0 && isHot(key[k - 1]))) continue; // only at a hot-run start + let t = buildTraversal(k); + if (!best || t.len > best.len) best = t; } - p = Math.max(q, anchor) + 1; + if (!best || best.seq.length < 3 || best.len < minLenM) return null; + let rLat = [], rLon = [], cum = [0]; + for (let k = best.start; k <= best.end; k++) { + rLat.push(points[k].lat); rLon.push(points[k].lon); + if (k > best.start) cum.push(cum[cum.length - 1] + GPS.distance(points[k - 1].lat, points[k - 1].lon, points[k].lat, points[k].lon)); + } + let refLen = cum[cum.length - 1]; + if (rLat.length < 3 || refLen < minLenM) return null; + let refCell = new Map(); + for (let v = 0; v < rLat.length; v++) { + let ck = Math.floor(rLat[v] * 111320 / cell) + "," + Math.floor(rLon[v] * 111320 * Math.cos(rLat[v] * Math.PI / 180) / cell); + (refCell.get(ck) || refCell.set(ck, []).get(ck)).push(v); + } + let rad = Math.ceil(projTolM / cell); + let prog = new Array(n).fill(null); + for (let i = 0; i < n; i++) { + if (points[i].lat == null) continue; + let cy = Math.floor(points[i].lat * 111320 / cell), cx = Math.floor(points[i].lon * 111320 * Math.cos(points[i].lat * Math.PI / 180) / cell); + let bv = -1, bd = projTolM; + for (let da = -rad; da <= rad; da++) for (let db = -rad; db <= rad; db++) { + let arr = refCell.get((cy + da) + "," + (cx + db)); if (!arr) continue; + for (let v of arr) { let d = GPS.distance(points[i].lat, points[i].lon, rLat[v], rLon[v]); if (d < bd) { bd = d; bv = v; } } + } + if (bv >= 0) prog[i] = cum[bv]; + } + let passes = []; + let p = 0; + while (p < n) { + if (prog[p] == null || prog[p] > refLen * 0.15) { p++; continue; } + let q = p, lo = prog[p], hi = prog[p], anchor = p, gap = 0; + while (q < n - 1) { + q++; + if (prog[q] != null && prog[q] >= hi - backM) { gap = 0; hi = Math.max(hi, prog[q]); anchor = q; continue; } + let dtStop = (new Date(points[q].ts) - new Date(points[anchor].ts)) / 1000; + let dispStop = GPS.distance(points[anchor].lat, points[anchor].lon, points[q].lat, points[q].lon); + if ((dtStop <= bridgeSec && dispStop <= bridgeM) || ++gap <= 25) continue; + break; + } + if ((hi - lo) >= refLen * minFrac) { + let mov = 0; + for (let k = p + 1; k <= anchor; k++) { + let dt = (new Date(points[k].ts) - new Date(points[k - 1].ts)) / 1000; + let disp = GPS.distance(points[k - 1].lat, points[k - 1].lon, points[k].lat, points[k].lon); + if (dt > 0 && disp / dt >= 0.8) mov += dt; // moving; a stop (dropped seat) is excluded + } + passes.push({ startIdx: p, endIdx: anchor, movingSec: Math.round(mov) }); + } + p = Math.max(q, anchor) + 1; + } + return { segLength: Math.round(refLen), passes }; + }; + + // A too-low hot threshold pulls the once/twice-ridden approach and turnaround into the + // reference, inflating it so a full rep no longer covers minFrac (measured on a real + // out-and-back: 1 pass at threshold 2, 5 at threshold 3+). Try every threshold from 2 up to + // the busiest cell and keep the one yielding the MOST passes (tie -> the longest segment). + let bestRes = null; + for (let T = 2; T <= Math.min(maxEntries, 20); T++) { + let r = detectAt(T); + if (!r || r.passes.length < minReps) continue; + if (!bestRes || r.passes.length > bestRes.passes.length || + (r.passes.length === bestRes.passes.length && r.segLength > bestRes.segLength)) bestRes = r; } - if (passes.length < minPasses) return { found: false }; - return { found: true, segLength: Math.round(refLen), passCount: passes.length, passes }; + if (!bestRes) return { found: false }; + return { found: true, segLength: bestRes.segLength, passCount: bestRes.passes.length, passes: bestRes.passes }; }; // One-time migration: accuracy gate is now a HARD reject (coarse cell/wifi fixes From 3ba8751664b9a23e9583330e39a9fe39b10a5bab Mon Sep 17 00:00:00 2001 From: 4aiman Date: Mon, 21 Sep 2026 20:31:34 +0300 Subject: [PATCH 31/65] Bump version (build.sh output) --- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 335736a..97c5139 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -139,7 +139,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "baldav" -version = "0.1.470" +version = "0.1.471" dependencies = [ "base64 0.22.1", "btleplug", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 440b559..d253f8f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "baldav" -version = "0.1.470" +version = "0.1.471" edition = "2021" [lib] diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 91c4bc5..b6098f1 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/nicegui/tauri-v2/refs/heads/main/tooling/cli/schema.json", "productName": "BalDav", - "version": "0.1.470", + "version": "0.1.471", "identifier": "dev.baldav.app", "build": { "frontendDist": "../frontend" From 6534d6601c8f66d4bc19951c3e974389726494ea Mon Sep 17 00:00:00 2001 From: 4aiman Date: Mon, 21 Sep 2026 20:40:49 +0300 Subject: [PATCH 32/65] Docs: bump versions table to build 471 (deployed to router + VPS) --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 5923dea..10843ea 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,13 @@ Персональный трекер здоровья и физической активности. -## Текущие версии (2026-09-21) +## Текущие версии (2026-09-21, build 471) | Что | Версия | |-----|--------| -| Приложение (BalDav) | **0.1.464**, build **464** | -| Выкачено на роутер (OTA) | build **464** - `http://192.168.1.1:88/baldav/baldav.apk` | -| Резерв OTA (VPS) | `https://4aiman.top/baldav/baldav.apk` (обновляется флагом `-u` при сборке) | +| Приложение (BalDav) | **0.1.471**, build **471** | +| Выкачено на роутер (OTA) | build **471** - `http://192.168.1.1:88/baldav/baldav.apk` | +| Резерв OTA (VPS) | build **471** - `https://4aiman.top/baldav/baldav.apk` | | Встроенный движок BRouter | 1.6.3 | | Сид продуктов (`products_seed.db`) | 11 | | Сборка | Tauri v2, Android arm64, debug-подпись | From dff9342c0dc7e70bf9abee5df46ffb180e162505 Mon Sep 17 00:00:00 2001 From: 4aiman Date: Tue, 22 Sep 2026 11:12:24 +0300 Subject: [PATCH 33/65] Balance/trend/charts follow the selected day; kcal weight is per-ride (stored) and settings weight auto-syncs to the latest weigh-in --- README.md | 2 +- ...6-09-22_balance-selected-day-and-weight.md | 35 +++++++++++++++++ frontend/js/db.js | 38 ++++++++++++++----- frontend/js/gps.js | 11 ++++-- frontend/js/main.js | 38 ++++++++++--------- 5 files changed, 92 insertions(+), 32 deletions(-) create mode 100644 docs/session-logs/2026-09-22_balance-selected-day-and-weight.md 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, From bb9f03131d46bef2173225dfb3e3895bf4094fa1 Mon Sep 17 00:00:00 2001 From: 4aiman Date: Tue, 22 Sep 2026 11:19:37 +0300 Subject: [PATCH 34/65] Bump version (build.sh output) --- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 97c5139..9c9d413 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -139,7 +139,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "baldav" -version = "0.1.471" +version = "0.1.472" dependencies = [ "base64 0.22.1", "btleplug", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d253f8f..edd7498 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "baldav" -version = "0.1.471" +version = "0.1.472" edition = "2021" [lib] diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index b6098f1..85b888b 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/nicegui/tauri-v2/refs/heads/main/tooling/cli/schema.json", "productName": "BalDav", - "version": "0.1.471", + "version": "0.1.472", "identifier": "dev.baldav.app", "build": { "frontendDist": "../frontend" From 5d24f8c3a39b57e54280ef89469f0cad5e4ba4d9 Mon Sep 17 00:00:00 2001 From: 4aiman Date: Tue, 22 Sep 2026 11:31:37 +0300 Subject: [PATCH 35/65] Keep scroll position across same-screen re-renders (day nav, add food) instead of jumping to top --- frontend/js/main.js | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/frontend/js/main.js b/frontend/js/main.js index 46f5256..abae94d 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -220,6 +220,32 @@ async function init() { // Router // --------------------------------------------------------------------------- +// Keep the vertical scroll position across a re-render of the SAME screen (day nav, adding +// food, deletes) instead of jumping to the top. Anchor on the topmost visible top-level +// section + its offset (robust to height changes, e.g. one more food row or async charts). +function captureScrollAnchor(el) { + let top = el.scrollTop, kids = el.children, idx = 0, delta = top; + for (let i = 0; i < kids.length; i++) { + if (kids[i].offsetTop <= top + 4) { idx = i; delta = top - kids[i].offsetTop; } else break; + } + return { idx, delta }; +} +function applyScrollAnchor(el, a) { + let kid = el.children[a.idx]; + if (kid) el.scrollTop = Math.max(0, kid.offsetTop + a.delta); +} +// The screen fills asynchronously (charts, nutrient strips), so re-apply for a short window as +// content settles; stop early if the user scrolls themselves. +function scheduleScrollRestore(el, a) { + let stop = false, off = () => { stop = true; }; + el.addEventListener("wheel", off, { passive: true, once: true }); + el.addEventListener("touchmove", off, { passive: true, once: true }); + let apply = () => { if (!stop) applyScrollAnchor(el, a); }; + apply(); + requestAnimationFrame(apply); + [60, 180, 400].forEach(ms => setTimeout(apply, ms)); +} + function renderScreen(name, fromPopstate) { // Release screen wake lock and compass listener when leaving recording screen if (window._screenLock) { try { window._screenLock.release(); } catch {} window._screenLock = null; } @@ -243,6 +269,12 @@ function renderScreen(name, fromPopstate) { supplements: renderSupplements, settings: renderSettings, }; + // Re-render of the same screen: remember where we were scrolled so we can stay put. + let scrollAnchor = null; + if (prevScreen === name) { + let oldContent = app.querySelector(".screen-content"); + if (oldContent && oldContent.scrollTop > 0) scrollAnchor = captureScrollAnchor(oldContent); + } app.innerHTML = ""; // Floating recording bar (visible on all screens except recording) @@ -271,6 +303,7 @@ function renderScreen(name, fromPopstate) { content.className = "screen-content"; app.appendChild(content); screens[name](content); + if (scrollAnchor) scheduleScrollRestore(content, scrollAnchor); } function buildNav(active) { From 862b1ffc969669e12d8b0f4896fb2b2c815208bf Mon Sep 17 00:00:00 2001 From: 4aiman Date: Tue, 22 Sep 2026 11:32:19 +0300 Subject: [PATCH 36/65] Bump version (build.sh output) --- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 9c9d413..895946f 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -139,7 +139,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "baldav" -version = "0.1.472" +version = "0.1.473" dependencies = [ "base64 0.22.1", "btleplug", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index edd7498..503cd98 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "baldav" -version = "0.1.472" +version = "0.1.473" edition = "2021" [lib] diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 85b888b..6954c30 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/nicegui/tauri-v2/refs/heads/main/tooling/cli/schema.json", "productName": "BalDav", - "version": "0.1.472", + "version": "0.1.473", "identifier": "dev.baldav.app", "build": { "frontendDist": "../frontend" From 514ff4edb3b8db1a3ecf177cecebb727e48194bd Mon Sep 17 00:00:00 2001 From: 4aiman Date: Tue, 22 Sep 2026 11:49:48 +0300 Subject: [PATCH 37/65] Docs: versions table to build 473 (deployed to router + VPS) --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 000059c..4b14e8b 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,13 @@ Персональный трекер здоровья и физической активности. -## Текущие версии (2026-09-21, build 471) +## Текущие версии (2026-09-22, build 473) | Что | Версия | |-----|--------| -| Приложение (BalDav) | **0.1.471**, build **471** | -| Выкачено на роутер (OTA) | build **471** - `http://192.168.1.1:88/baldav/baldav.apk` | -| Резерв OTA (VPS) | build **471** - `https://4aiman.top/baldav/baldav.apk` | +| Приложение (BalDav) | **0.1.473**, build **473** | +| Выкачено на роутер (OTA) | build **473** - `http://192.168.1.1:88/baldav/baldav.apk` | +| Резерв OTA (VPS) | build **473** - `https://4aiman.top/baldav/baldav.apk` | | Встроенный движок BRouter | 1.6.3 | | Сид продуктов (`products_seed.db`) | 11 | | Сборка | Tauri v2, Android arm64, debug-подпись | From 5f58eabcc5ae54fed5f82ab5ac0b0727a4056bb5 Mon Sep 17 00:00:00 2001 From: 4aiman Date: Tue, 22 Sep 2026 20:58:06 +0300 Subject: [PATCH 38/65] Gap-fill: re-sequence the session by ts on save (filled points landed at the front on merged/split tracks with NULL seq) --- .../2026-09-22_gapfill-order-fix.md | 27 +++++++++++++++++++ frontend/js/db.js | 18 +++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 docs/session-logs/2026-09-22_gapfill-order-fix.md diff --git a/docs/session-logs/2026-09-22_gapfill-order-fix.md b/docs/session-logs/2026-09-22_gapfill-order-fix.md new file mode 100644 index 0000000..7c9f6aa --- /dev/null +++ b/docs/session-logs/2026-09-22_gapfill-order-fix.md @@ -0,0 +1,27 @@ +# Сессия 2026-09-22 (2) - достройка пропусков уходила «в начало» + +По утверждённому плану `~/.claude/plans/merry-discovering-dream.md`. + +## Симптом +Достройка GPS-пропусков (🧩) показывала превью нормально, но сохранённый трек тянул прямую +линию от старта к месту пропуска («возврат в начало»). На любом (недавнем) треке. + +## Корень +`saveGapFill` (db.js) ставит достроенным точкам `seq = lastSeq` (seq последней РЕАЛЬНОЙ точки +перед пропуском). У склеенных/разрезанных треков реальные точки имеют `seq = NULL` (склейка/ +разрез специально обнуляют seq -> порядок по ts). Тогда `lastSeq` остаётся 0, все достроенные +точки получают seq=0, а `getGpsPoints` (`ORDER BY COALESCE(seq,999999), ts`) ставит их ПЕРЕД +реальными (NULL→999999) -> прямая к началу. Подтверждено: единственный NULL-seq трек в БД - +склеенный 16.09; недавние треки пользователя склеены. + +## Фикс +`saveGapFill`: после вставки достроенных точек - пере-нумерация seq ВСЕЙ сессии по ts (читаю +id `ORDER BY ts,id`, seq=порядковый номер, пакетный `CASE id WHEN..` чанками по 300, паттерн +из `saveSmoothedTrack`). Теперь getGpsPoints всегда в порядке ts независимо от исходного seq +(плотный/NULL/смешанный). Для плотных треков - no-op, для склеенных - чинит. + +## Проверка +- SQL: у склеенного 16.09 реальные точки seq=NULL (seq=0 достроенной встала бы перед ними) - + корень подтверждён. +- Десктоп (Xvfb): подсадка склеенного трека -> 🧩 -> Сохранить -> переоткрыть: без прямой к + началу. Затем сборка + телефон, вытянуть сегодняшний трек. diff --git a/frontend/js/db.js b/frontend/js/db.js index 00e9198..babdf09 100644 --- a/frontend/js/db.js +++ b/frontend/js/db.js @@ -810,6 +810,24 @@ async function saveGapFill(sessionId, points) { } await execute(`INSERT INTO gps_points (session_id, ts, lat, lon, seq, estimated) VALUES ${rowSql.join(",")}`, vals); } + // Re-sequence the WHOLE session by ts so getGpsPoints (ORDER BY seq,ts) returns the true + // order. Without this, on merged/split tracks the real points have seq=NULL, the inserted + // gap-fill points get seq=0, and they sort to the FRONT — a straight line from the start to + // the gap ("returns to the beginning"). Re-numbering by ts is a no-op for dense tracks and + // fixes NULL/mixed ones. Batched CASE update (like saveSmoothedTrack) to beat the IPC/bind cost. + let ordered = await select("SELECT id FROM gps_points WHERE session_id=?1 ORDER BY ts ASC, id ASC", [sessionId]); + let SEQCHUNK = 300; // 2 params/row (id, seq) → under SQLite's 999 bind-var limit + for (let i = 0; i < ordered.length; i += SEQCHUNK) { + let chunk = ordered.slice(i, i + SEQCHUNK); + let when = [], ids = [], params = []; + for (let k = 0; k < chunk.length; k++) { + params.push(chunk[k].id); let idi = params.length; + params.push(i + k + 1); let sqi = params.length; + when.push(`WHEN ?${idi} THEN ?${sqi}`); + ids.push(`?${idi}`); + } + await execute(`UPDATE gps_points SET seq=CASE id ${when.join(" ")} END WHERE id IN (${ids.join(",")})`, params); + } await execute("UPDATE gps_sessions SET gap_filled=1 WHERE id=?1", [sessionId]); return toInsert.length; } From 749064ce5912c65682f9ca80b5ea2576ce0eac08 Mon Sep 17 00:00:00 2001 From: 4aiman Date: Tue, 22 Sep 2026 21:09:13 +0300 Subject: [PATCH 39/65] Bump version (build.sh output) --- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 895946f..ac224f4 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -139,7 +139,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "baldav" -version = "0.1.473" +version = "0.1.474" dependencies = [ "base64 0.22.1", "btleplug", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 503cd98..1884275 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "baldav" -version = "0.1.473" +version = "0.1.474" edition = "2021" [lib] diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 6954c30..19cca75 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/nicegui/tauri-v2/refs/heads/main/tooling/cli/schema.json", "productName": "BalDav", - "version": "0.1.473", + "version": "0.1.474", "identifier": "dev.baldav.app", "build": { "frontendDist": "../frontend" From 5da02c598af46c97ce420e4d3ac87c0f4321e3b9 Mon Sep 17 00:00:00 2001 From: 4aiman Date: Tue, 22 Sep 2026 22:11:38 +0300 Subject: [PATCH 40/65] Docs: versions table to build 474 (deployed to router + VPS) --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 4b14e8b..4751c44 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,13 @@ Персональный трекер здоровья и физической активности. -## Текущие версии (2026-09-22, build 473) +## Текущие версии (2026-09-22, build 474) | Что | Версия | |-----|--------| -| Приложение (BalDav) | **0.1.473**, build **473** | -| Выкачено на роутер (OTA) | build **473** - `http://192.168.1.1:88/baldav/baldav.apk` | -| Резерв OTA (VPS) | build **473** - `https://4aiman.top/baldav/baldav.apk` | +| Приложение (BalDav) | **0.1.474**, build **474** | +| Выкачено на роутер (OTA) | build **474** - `http://192.168.1.1:88/baldav/baldav.apk` | +| Резерв OTA (VPS) | build **474** - `https://4aiman.top/baldav/baldav.apk` | | Встроенный движок BRouter | 1.6.3 | | Сид продуктов (`products_seed.db`) | 11 | | Сборка | Tauri v2, Android arm64, debug-подпись | From d6fe47a9245f78558809efe8aa952f8f7942e881 Mon Sep 17 00:00:00 2001 From: 4aiman Date: Sat, 26 Sep 2026 23:44:18 +0300 Subject: [PATCH 41/65] Recording map: follow toggle to free-pan while riding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New ⊕ button on the recording screen toggles map auto-follow. When off, the map stays put so you can look around when unsure where you are (off the track, or the track leads where there is no road); new GPS points no longer recentre it. Tapping it back on recentres on the current position and resumes following. Default on per screen entry (not persisted). Guards followMode around panTo/setView in onPoint/onBatchUpdate/ onStateChange. Independent of the compass freeze/rotate controls. --- PROJECT_MAP.md | 2 +- README.md | 1 + docs/plans/2026-09-26-map-follow-toggle.md | 76 +++++++++++++++++++ .../2026-09-26_map-follow-toggle.md | 47 ++++++++++++ frontend/css/styles.css | 1 + frontend/js/main.js | 29 ++++++- 6 files changed, 151 insertions(+), 5 deletions(-) create mode 100644 docs/plans/2026-09-26-map-follow-toggle.md create mode 100644 docs/session-logs/2026-09-26_map-follow-toggle.md diff --git a/PROJECT_MAP.md b/PROJECT_MAP.md index c0c32fb..7395159 100644 --- a/PROJECT_MAP.md +++ b/PROJECT_MAP.md @@ -142,7 +142,7 @@ ### Экраны - **Сегодня** — баланс ккал, еда (продукты + блюда), GPS тренировки, активность, вес, сон, добавки, прогресс-бар жидкости, нутриенты (приход + запасы) - **Продукты** — подтабы [Продукты | Рецепты], поиск, карточки с КБЖУ, добавление в дневник -- **Трек** — GPS тренировки (запись с HR), карта с компасом (leaflet-rotate). Навигационный «пак» позиции (SVG divIcon): круг цвета трека (на 20% темнее) с белой + 1px чёрной обводкой + треугольник, указывающий ВВЕРХ (карта и так доворачивается активным источником — курс/компас — вверх, поэтому без покадрового поворота: не трясёт). Мини-компас только в режиме компаса. Z-order панелями: тайлы < дороги(`roadsPane` 240) < маршрут(`routePane` 250) < трек(overlay) < пак(marker). course-up по GPS-курсу (карта и панель доворачиваются в ландшафт вместе, `bearing = h`), ландшафтный режим (CSS rotation), графики скорости/высоты/ЧСС, импорт GPX/FTB, dead reckoning, placeholder до GPS, карта дорог (дедуп 2м, цвета по вело/пешие/бег), сохранённые маршруты (оверлей-навигация, кнопка-тоггл «М», предпросмотр с профилем высоты, топо/уклон-обводка, тоггл «∡/⛰», высоты по цепочке GPXZ→open-elevation→open-meteo→OpenTopoData), де-джиттер трека (Савицкий-Голей, кнопка «≈» на детали) +- **Трек** — GPS тренировки (запись с HR), карта с компасом (leaflet-rotate). Навигационный «пак» позиции (SVG divIcon): круг цвета трека (на 20% темнее) с белой + 1px чёрной обводкой + треугольник, указывающий ВВЕРХ (карта и так доворачивается активным источником — курс/компас — вверх, поэтому без покадрового поворота: не трясёт). Мини-компас только в режиме компаса. Z-order панелями: тайлы < дороги(`roadsPane` 240) < маршрут(`routePane` 250) < трек(overlay) < пак(marker). course-up по GPS-курсу (карта и панель доворачиваются в ландшафт вместе, `bearing = h`), ландшафтный режим (CSS rotation), графики скорости/высоты/ЧСС, импорт GPX/FTB, dead reckoning, placeholder до GPS, карта дорог (дедуп 2м, цвета по вело/пешие/бег), сохранённые маршруты (оверлей-навигация, кнопка-тоггл «М», предпросмотр с профилем высоты, топо/уклон-обводка, тоггл «∡/⛰», высоты по цепочке GPXZ→open-elevation→open-meteo→OpenTopoData), де-джиттер трека (Савицкий-Голей, кнопка «≈» на детали), тоггл следования карты за позицией (`followMode`, кнопка `⊕` в `rec-map-btn` стеке; guard вокруг `panTo`/`setView` в `onPoint`/`onBatchUpdate`/`onStateChange`; выкл = свободный просмотр карты на ходу, повторный тап центрирует; не запоминается) - **Добавки** — 41 нутриент (витамины, минералы, жирные кислоты, женьшень, ресвератрол, CoQ10) - **Настройки** — профиль, образ жизни, нормы ЧСС/давления, BLE пульсометр, велосипеды, GPS конфиг (в т.ч. цвет трека `trackColor`, цвета дорог, GPXZ-ключ), OTA обновления, импорт тайлов, карта дорог diff --git a/README.md b/README.md index 4751c44..f9ab8f9 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,7 @@ - **POI на карте** — точки интереса (кафе, магазины, вода, туалеты, банки/ATM, остановки, укрытия, смотровые и т.д.) круглыми контрастными значками (эмодзи в кружке, читаемо на светлой и тёмной карте). Кнопка `📍` с панелью-фильтром категорий; рендер по видимой области от зума 13. Данные оффлайн (`data/pois.json`, ~100 км вокруг опорной точки, 5662 объекта из OSM/Overpass). На картах записи, деталей трека и дорог. - **Тёмная карта** — тумблер `☾` на экранах записи, деталей трека и дорог: инвертирует ТОЛЬКО слой тайлов (`invert + hue-rotate 180° + brightness`, трек/маршрут/дороги остаются цветными), выбор запоминается. Работает одинаково на офлайн-тайлах и онлайне, без ключей/квот/перепаковки. - **Метки ветра** — кнопка «Ветер» на экране записи: тап открывает интервал встречного ветра, тап закрывает. Метки хранятся с треком (`wind_marks`) как эталон для проверки оценки ветра по треку; на расчёт калорий/мощности не влияют. Голосовые метки — на будущее. +- **Слежение карты (тумблер `⊕`)** — на экране записи карта по умолчанию центрируется на текущей позиции при каждой новой точке. Кнопка `⊕` (подсвечена = слежу) выключает автоцентрирование: карту можно свободно подвигать и осмотреться, когда «где я» непонятно (отклонился от трека или трек ведёт туда, где дороги нет), а новые точки её не возвращают. Повторный тап центрирует на текущей позиции и включает слежение снова. По умолчанию включено при каждом заходе (не запоминается). - **Экспорт/импорт** — свои продукты, рецепты и добавки одним файлом (JSON). - **Вес, сон, добавки** — логирование с историей. diff --git a/docs/plans/2026-09-26-map-follow-toggle.md b/docs/plans/2026-09-26-map-follow-toggle.md new file mode 100644 index 0000000..b97cc14 --- /dev/null +++ b/docs/plans/2026-09-26-map-follow-toggle.md @@ -0,0 +1,76 @@ +# План: кнопка тоггла "следование карты за позицией" на экране записи + +## Context (зачем) +Во время записи карта на экране трекинга постоянно снэпится (`panTo`) на текущую GPS-позицию +при каждой новой точке. Кейс пользователя: отклонился от трека или трек ведёт туда, где дороги +нет, надо осмотреться по карте вокруг - но карта тут же прыгает обратно на позицию, подвигать +её невозможно. Нужна кнопка-тумблер: нажал - карта перестаёт следовать за позицией (можно +свободно двигать/смотреть), нажал снова - карта центрируется на текущей позиции и следование +возобновляется. + +Решения по UX (согласованы с пользователем): +- Кнопка - плитка 32x32 в общем правом ряду кнопок карты, с символом (не словом), активное + состояние подсвечивается (как freeze/roads). Активна = следование включено. +- Только кнопка-тумблер. Драг карты пальцем следование НЕ выключает (строго как в запросе). +- Повторный тап (включение следования) сразу центрирует карту на текущей позиции. +- Следование по умолчанию включено при каждом заходе на экран (в localStorage не храним). + +## Где сейчас следование (frontend/js/main.js, функция renderRecordingInline) +Автоследование = центрирование вида карты на текущей точке. Происходит в: +- `GPS.recording.onPoint`, ~4840: `map.panTo(ll)` - основное следование при записи. +- `GPS.recording.onPoint` состояние waiting_gps, ~4857: `map.setView([p.lat,p.lon],15)`. +- `GPS.recording.onStateChange` "ready", ~4814: `map.setView([p.lat,p.lon],16)` - разовый locate. +- `GPS.recording.onBatchUpdate`, ~5005: `map.panTo(last)` - следование при пакетном апдейте. + +Маркер позиции (`updateMe`/`refreshMe`) вид карты НЕ трогает - при выключенном следовании +точка продолжит обновляться на своём месте, просто карта не будет прыгать. Компас/поворот +(`compassFrozen`, `applyBearing`) ортогональны следованию - `panTo` на bearing не влияет. + +## Фикс (frontend/js/main.js, только renderRecordingInline) +1. Флаг: рядом с `compassFrozen`/`rotOffset` (~4470-4477) добавить `let followMode = true;`. +2. Кнопка: по образцу `freezeBtn`/`roadsBtn` (~4624-4676) создать `followBtn` + (`class="rec-map-btn rec-follow-btn"`, символ-цель, `title="Следить за позицией"`), + добавить в `btnWrap`, сразу выставить `followBtn.classList.add("active")` (followMode=true). + onclick: + - `followMode = !followMode;` + - `followBtn.classList.toggle("active", followMode);` + - при включении - центрировать на текущей точке: взять последнюю точку + `GPS.recording.points` (как в onBatchUpdate: сортировка по ts, последняя) и `map.panTo`. +3. Загардить все 4 места следования условием `if (followMode)`: + - ~4840 `map.panTo(ll)` -> `if (followMode) map.panTo(ll);` + - ~4857 waiting_gps `map.setView(...)` -> обернуть в `if (followMode)` (return оставить). + - ~4814 "ready" `map.setView(...)` -> обернуть в `if (followMode)`. + - ~5005 `map.panTo(last)` -> `if (followMode) map.panTo(last);` + +## CSS (frontend/css/styles.css) +После `.rec-dark-toggle { right:8px; bottom:238px; }` (~1248) добавить позицию для новой кнопки +в следующем свободном слоте стека: `.rec-follow-btn { right: 8px; bottom: 276px; }`. +Базовый вид и `.active` уже есть у `.rec-map-btn` / `.rec-map-btn.active` - переиспользуем. + +## Символ кнопки +Плитка-цель, читается как "центрировать на мне": glyph круг-с-точкой/крест-в-круге +(например "⊕" плюс-в-круге или "◎" bullseye) - выбрать широко отрисовываемый. +Смысл несёт `title` (текст). Активная подсветка = следование включено. + +## Регрессии (проверить рядом) +- Freeze-кнопка компаса (`compassFrozen`) и поворот карты - не задеты (следование = только + центр вида, поворот = bearing). Убедиться, что обе кнопки работают независимо. +- Маркер позиции `updateMe` при выключенном следовании продолжает двигаться (карта стоит). +- Второй, отдельный экран `setupLiveUpdates` (мини-карта gpsMap, ~5030, свой panTo 5039/5055) + НЕ трогаем - это превью в списке, у него нет ряда кнопок. Пользователь просил "экран + трекинга" = renderRecordingInline. + +## Верификация +- Десктоп (Xvfb :99, `DISPLAY=:99 ./build.sh`): открыть экран записи, начать запись + (или подсадить точки), проверить: + 1) следование по умолчанию включено, кнопка подсвечена, карта идёт за точкой; + 2) тап -> подсветка гаснет, карту можно свободно двигать, новые точки её не возвращают; + 3) тап -> карта центрируется на текущей позиции, подсветка загорается, следование снова идёт. + Скрин каждого состояния. +- Сборка APK + телефон (по явной просьбе): в реальной записи проехать/постоять, отжать кнопку, + подвигать карту, вернуть кнопкой. Коммит ДО теста на телефоне, доработки - отдельным коммитом. +- README/PROJECT_MAP + лог сессии обновить вместе с коммитом. + +## Не в этой задаче +Авто-выключение следования при драге карты (пользователь выбрал "только кнопка"). +Хранение состояния следования между сессиями (по умолчанию всегда включено). diff --git a/docs/session-logs/2026-09-26_map-follow-toggle.md b/docs/session-logs/2026-09-26_map-follow-toggle.md new file mode 100644 index 0000000..89e144d --- /dev/null +++ b/docs/session-logs/2026-09-26_map-follow-toggle.md @@ -0,0 +1,47 @@ +# Сессия 2026-09-26 - тумблер "следование карты за позицией" на экране записи + +Работали по утверждённому плану: `docs/plans/2026-09-26-map-follow-toggle.md`. + +## Контекст +Во время записи карта на экране трекинга при каждой новой GPS-точке центрируется на текущей +позиции (`map.panTo`). Кейс: отклонился от трека или трек ведёт туда, где дороги нет, надо +осмотреться по карте - но она тут же прыгает обратно, подвигать её нельзя. Нужен тумблер: +нажал - карта перестаёт следовать (можно двигать), нажал снова - центрируется на позиции и +следование возобновляется. + +Согласовано с пользователем (AskUserQuestion): кнопка - символ-плитка в общем ряду кнопок +карты (не слово), активная подсветка = слежу; только кнопка-тумблер (драг карты следование НЕ +выключает); повторный тап центрирует; состояние не запоминается между сессиями. + +## Действия +- `frontend/js/main.js`, `renderRecordingInline`: + - добавлен флаг `let followMode = true;` рядом с `compassFrozen`/`rotOffset`; + - создана кнопка `followBtn` (`class="rec-map-btn rec-follow-btn active"`, символ `⊕`, + `title="Следить за позицией"`) по образцу `freezeBtn`; onclick тогглит `followMode` и класс + `active`, при включении центрирует `map.panTo` на последней точке `GPS.recording.points` + (сортировка по ts); + - 4 места автоследования загардены `if (followMode)`: `onPoint` `panTo` (~4840), `onPoint` + waiting_gps `setView` (~4857), `onStateChange` "ready" `setView` (~4814), `onBatchUpdate` + `panTo` (~5005). +- `frontend/css/styles.css`: `.rec-follow-btn { right: 8px; bottom: 276px; font-size: 18px; }` + (следующий слот стека после `.rec-dark-toggle` 238px). Базовый вид и `.active` - + переиспользован `.rec-map-btn` / `.rec-map-btn.active`. +- README: добавлен буллет "Слежение карты (тумблер ⊕)". + +## Проверка +- `node -c frontend/js/main.js` - OK. +- Десктоп (Xvfb :99, `DISPLAY=:99 ./build.sh` = tauri dev, без бампа версии): открыт экран + записи (Старт), кнопка `⊕` в правом стеке вверху, по умолчанию подсвечена (слежу). Тап -> + подсветка гаснет (слежу выкл). Тап -> подсветка возвращается (слежу вкл). JS-ошибок в логе + нет. Скрины: s_rec.png / s_rec_off.png / s_rec_on2.png в scratchpad. +- НЕ проверено на десктопе: живое поведение "карта не прыгает на новую точку при выкл / прыгает + при вкл" - под Xvfb настоящего GPS нет, экран в waiting_gps, поток точек не идёт. Логика - + тривиальный guard вокруг уже работающих `panTo`/`setView`. Реальная проверка потока - на + телефоне. + +## Следующие шаги +- Сборка APK + установка на телефон (по явной просьбе пользователя): проверить в живой записи - + проехать/постоять, отжать `⊕`, подвигать карту (новые точки не возвращают), вернуть кнопкой + (центрируется). Доработки - отдельным коммитом. +- Компас (freeze `X`) и поворот карты не задеты (следование = только центр вида, поворот = + bearing) - убедиться на телефоне, что обе кнопки работают независимо. diff --git a/frontend/css/styles.css b/frontend/css/styles.css index fe0ff97..ff5e03c 100644 --- a/frontend/css/styles.css +++ b/frontend/css/styles.css @@ -1246,6 +1246,7 @@ select { .rec-mode-btn { right: 8px; bottom: 162px; font-size: 11px; } .rec-route-toggle { right: 8px; bottom: 200px; } .rec-dark-toggle { right: 8px; bottom: 238px; } +.rec-follow-btn { right: 8px; bottom: 276px; font-size: 18px; } /* Dark map: invert ONLY the tile layer (overlays — track, route, roads, puck — keep their colours). User-tuned filter on OSM standard tiles. Applies to every Leaflet map. */ body.dark-map .leaflet-tile-pane { filter: invert(1) hue-rotate(180deg) brightness(1.5); } diff --git a/frontend/js/main.js b/frontend/js/main.js index abae94d..8746172 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -4469,6 +4469,7 @@ async function renderRecordingInline(container) { // BOTH directions (orange = compass, green = GPS course) for visual comparison. let rotOffset = 0; // 0/90/180/270 — whole-screen rotation let compassFrozen = false; // X button: freeze current heading + let followMode = true; // ⊕ button: keep the map centred on the current position let frozenHeading = 0; // heading captured at the moment of freezing let smoothedHeading = null; // EMA of calibrated compass heading (deg, 0=N, CW) let courseHeading = null; // EMA of GPS course over ground (deg, 0=N, CW) @@ -4634,6 +4635,26 @@ async function renderRecordingInline(container) { }; btnWrap.appendChild(freezeBtn); + // Follow button — ⊕: toggle whether the map keeps re-centring on the current position. + // Off = free pan (look around when lost / off-route); tapping it back on recentres now. + let followBtn = document.createElement("button"); + followBtn.className = "rec-map-btn rec-follow-btn active"; + followBtn.textContent = "⊕"; // ⊕ + followBtn.title = "Следить за позицией"; + followBtn.onclick = () => { + followMode = !followMode; + followBtn.classList.toggle("active", followMode); + if (followMode) { + // Re-enabling: jump straight back to where we are now. + let pts = GPS.recording.points; + if (pts && pts.length) { + let last = pts.slice().sort((a, b) => new Date(a.ts) - new Date(b.ts))[pts.length - 1]; + if (last) map.panTo([last.lat, last.lon]); + } + } + }; + btnWrap.appendChild(followBtn); + // Roads toggle button let roadsBtn = document.createElement("button"); roadsBtn.className = "rec-map-btn rec-roads-toggle"; @@ -4811,7 +4832,7 @@ async function renderRecordingInline(container) { if (ph) ph.remove(); statusEl.textContent = "GPS OK. Начните движение..."; statusEl.style.color = "#03dac6"; - map.setView([p.lat, p.lon], 16); + if (followMode) map.setView([p.lat, p.lon], 16); } else if (state === "recording") { statusEl.textContent = ""; statusEl.style.color = ""; @@ -4837,7 +4858,7 @@ async function renderRecordingInline(container) { let ll = [p.lat, p.lon]; borderLine.addLatLng(ll); trailLine.addLatLng(ll); - map.panTo(ll); + if (followMode) map.panTo(ll); // Save position for next session start (avoid black screen) DB.setProfileValue("last_lat", String(p.lat)); DB.setProfileValue("last_lon", String(p.lon)); @@ -4854,7 +4875,7 @@ async function renderRecordingInline(container) { if (recState === "waiting_gps") { statusEl.textContent = "Поиск GPS..."; statusEl.style.color = "#ffb74d"; - map.setView([p.lat, p.lon], 15); + if (followMode) map.setView([p.lat, p.lon], 15); return; } if (recState === "ready") { @@ -5002,7 +5023,7 @@ async function renderRecordingInline(container) { trailLine.setLatLngs(coords); if (coords.length) { let last = coords[coords.length - 1]; - map.panTo(last); + if (followMode) map.panTo(last); updateCourse(sorted[sorted.length - 1]); // course from the last point object (has bearing) updateMe(last); } From 0123d3562b2dd80d7e6524c70c80eee453e5786d Mon Sep 17 00:00:00 2001 From: 4aiman Date: Sun, 27 Sep 2026 00:04:52 +0300 Subject: [PATCH 42/65] Bump version (build.sh output) --- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 1884275..c5271e3 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "baldav" -version = "0.1.474" +version = "0.1.475" edition = "2021" [lib] diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 19cca75..71ac2a1 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/nicegui/tauri-v2/refs/heads/main/tooling/cli/schema.json", "productName": "BalDav", - "version": "0.1.474", + "version": "0.1.475", "identifier": "dev.baldav.app", "build": { "frontendDist": "../frontend" From dbb6c2ea9f4f3f7fabbbc56e21c2d9a66208fc57 Mon Sep 17 00:00:00 2001 From: 4aiman Date: Sun, 27 Sep 2026 00:04:52 +0300 Subject: [PATCH 43/65] Build: auto-clean stale Android bundles per build + clean target Each android build now removes the regenerable AAB bundles (intermediary_bundle, outputs/bundle) and gen APK copies once the final APK is in binaries/; a manual 'clean' target does the same on demand. Frees ~0.5G/cycle without touching the Rust incremental cache or binaries/. (build.sh stays untracked - it holds a deploy secret; this documents the change.) --- PROJECT_MAP.md | 4 +++- .../2026-09-26_map-follow-toggle.md | 23 +++++++++++++++---- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/PROJECT_MAP.md b/PROJECT_MAP.md index 7395159..f300e52 100644 --- a/PROJECT_MAP.md +++ b/PROJECT_MAP.md @@ -81,7 +81,7 @@ | Файл | Назначение | |------|------------| -| `build.sh` | Сборка: android/debug/release, sync frontend, версионирование, -u upload на VPS | +| `build.sh` | Сборка: android/debug/release, sync frontend, версионирование, -u upload на VPS, `clean`/авто-чистка старых бандлов (`clean_bundles`) | | `BUILD_NUMBER` | Автоинкремент номера сборки | | `ion_products.json` | 1111 продуктов ION (сырые данные скрейпера) | | `5ka_products_fresh.json` | 3103 продукта 5ka (сырые данные) | @@ -260,8 +260,10 @@ kcal = (dt/60) * (MET * 3.5 * weight_kg) / 200 ./build.sh android # release APK (подпись debug.keystore) ./build.sh -u -d android # debug + install + заливка на роутер/VPS (OTA) ./build.sh install -d # поставить уже собранный APK без пересборки +./build.sh clean # убрать старые AAB-бандлы и APK-выхлопы gen/ (Rust-кэш и binaries/ не трогает) ``` Без таргета `./build.sh` = dev-сервер (десктоп), НЕ сборка APK. +`clean_bundles` также вызывается автоматически в конце каждой android-сборки (после копии APK в binaries/): удаляет `gen/.../intermediary_bundle` + `outputs/bundle` (AAB, мы раздаём APK) и `outputs/apk/*.apk`. Регенерируются Gradle при следующей сборке. ## Отладка WebView (debug-сборки) diff --git a/docs/session-logs/2026-09-26_map-follow-toggle.md b/docs/session-logs/2026-09-26_map-follow-toggle.md index 89e144d..61b6421 100644 --- a/docs/session-logs/2026-09-26_map-follow-toggle.md +++ b/docs/session-logs/2026-09-26_map-follow-toggle.md @@ -39,9 +39,24 @@ тривиальный guard вокруг уже работающих `panTo`/`setView`. Реальная проверка потока - на телефоне. +## Деплой build 475 +`./build.sh -u -i -d android`: собран debug APK (0.1.475, 182М), залит на роутер +(http://192.168.1.1:88/baldav/baldav.apk, build 475) и VPS +(https://4aiman.top/baldav/baldav.apk, build 475), установлен на 320245779368 через +`adb install -r --user 0` - Success (подпись совпала, данные сохранены). Живую проверку тоггла +следования в реальной записи делает пользователь на телефоне. + +## Чистка старых бандлов (build.sh) +Место забивалось регенерируемыми выхлопами Android-сборки. Добавлена `clean_bundles()` в +build.sh: удаляет `gen/android/app/build/intermediates/intermediary_bundle` + +`outputs/bundle` (AAB - мы раздаём APK) и `outputs/apk/*.apk`. НЕ трогает `src-tauri/target` +(14Г Rust-инкрементал, иначе полная пересборка) и `binaries/` (последние APK). Вызывается +авто в конце каждой android-сборки (после копии APK в binaries/) и вручную `./build.sh clean`. +Согласовано с пользователем: объём "только бандлы/APK", запуск "авто + ручная". +Разовый прогон: gen build 1.6G -> 1.0G, освобождено ~557 МБ. + ## Следующие шаги -- Сборка APK + установка на телефон (по явной просьбе пользователя): проверить в живой записи - - проехать/постоять, отжать `⊕`, подвигать карту (новые точки не возвращают), вернуть кнопкой - (центрируется). Доработки - отдельным коммитом. +- Живая проверка тоггла `⊕` на телефоне: проехать/постоять, отжать, подвигать карту (новые + точки не возвращают), вернуть кнопкой (центрируется). Доработки - отдельным коммитом. - Компас (freeze `X`) и поворот карты не задеты (следование = только центр вида, поворот = - bearing) - убедиться на телефоне, что обе кнопки работают независимо. + bearing) - убедиться, что обе кнопки работают независимо. From 2d91c062901222307a298892187e44b9be901e45 Mon Sep 17 00:00:00 2001 From: 4aiman Date: Tue, 29 Sep 2026 12:19:15 +0300 Subject: [PATCH 44/65] gitignore build.sh (holds deploy secret) and BUILD_NUMBER (local state) --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index e4b6398..baa8599 100644 --- a/.gitignore +++ b/.gitignore @@ -30,5 +30,9 @@ chzn_session.json binaries/ test.exe +# Build script holds a deploy secret (router password); build counter is local state +build.sh +BUILD_NUMBER + # Reference material kept locally, not in the repo *.pdf From 74f3ec4d6ee990e0b1909a5c94638232019be98c Mon Sep 17 00:00:00 2001 From: 4aiman Date: Tue, 29 Sep 2026 12:37:15 +0300 Subject: [PATCH 45/65] Startup: batch the seq repair (per-point loop made cold start ~15s, every boot) The startup seq repair (runMigrations) assigned seq to NULL-seq sessions one UPDATE per point. Merge/split leave seq=NULL by design, so recent merged tracks (10865 + 3614 points) meant ~15000 sequential IPC writes on every launch - and for the big sessions the writes did not persist before the next launch re-ran the whole thing. That was the slow start. Replace it with the batched CASE re-sequence (chunks of 300, like saveGapFill/saveSmoothedTrack): ~50 UPDATEs instead of ~15000. Verified on device: repair of all 3 sessions dropped from ~15s to ~1.1s, seq now persists so it is one-time (boot 2 does no repair, migrations 357ms), 0 NULL seq left, seq matches ts order (0 inversions). --- PROJECT_MAP.md | 2 +- .../2026-09-29_startup-slowness.md | 49 +++++++++++++++++++ frontend/js/db.js | 23 +++++++-- 3 files changed, 68 insertions(+), 6 deletions(-) create mode 100644 docs/session-logs/2026-09-29_startup-slowness.md diff --git a/PROJECT_MAP.md b/PROJECT_MAP.md index f300e52..8b3b15d 100644 --- a/PROJECT_MAP.md +++ b/PROJECT_MAP.md @@ -22,7 +22,7 @@ | Файл | Строк | Назначение | |------|-------|------------| | `js/main.js` | ~5900 | Экраны, навигация, модалки, графики, нутриенты, HR UI, компас (EMA + gimbal lock), ландшафт (3 режима), дороги. `appConfirm`/`appPrompt` (in-app диалоги вместо нативных, в стеке окон). Страница «Сохранённые маршруты», оверлей маршрута на записи и просмотре (`whenMapReady` — await готовности карты, не таймаут). Панель де-джиттера на детали трека (ползунки окно/итерации, живое превью, сохранить/откат, `reopenDetail` — переоткрытие детали без наложения оверлеев). `showRouteDetail` — предпросмотр маршрута (карта+профиль высоты+тап-высоты+фуллскрин), `showRouteEditor` — создание/правка маршрута (тап карты=точка в конец, тап линии=вставка в сегмент, тащить=двигать, тап точки=удалить; `makeMarker` возвращает маркер для push/splice). `appConfirm`/`appPrompt`/`appAlert` — in-app диалоги (нативных `alert/prompt/confirm` не осталось; ` `; + showModal(html, () => {}, (overlay) => { + let ctx = overlay.querySelector("#wk-hr-chart"); + if (ctx && samples.length) { + new Chart(ctx, { + type: "line", + data: { labels: samples.map(s => Math.round(s[0] / 60) + "м"), + datasets: [{ data: hrs, borderWidth: 2, pointRadius: 0, fill: false, tension: 0.3, + segment: { borderColor: c => hrZoneColor(c.p1.parsed.y) } }] }, + options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { x: { display: false } } }, + }); + } + let del = overlay.querySelector("#wk-del"); + if (del) del.onclick = async () => { + if (!(await appConfirm("Удалить эту зарядку?", "Удалить"))) return; + await DB.deleteGpsSession(session.id); + if (winStack.length > 0) winStack.pop().close(); + _skipPops = 1; history.back(); + renderScreen("gps"); + }; + }); +} + +// HR-only "workout" recording screen (зарядка): no map/GPS, live heart rate + timer + kcal. +function renderWorkoutInline(container) { + container.innerHTML = ` +
+
--
+
уд/мин
+
+
0:00Время
+
0ккал
+
--средний
+
--макс
+
+
Подключите пульсометр
+ + +
`; + + let hrEl = container.querySelector("#wk-hr"), timeEl = container.querySelector("#wk-time"); + let kcalEl = container.querySelector("#wk-kcal"), avgEl = container.querySelector("#wk-avg"); + let maxEl = container.querySelector("#wk-max"), statusEl = container.querySelector("#wk-status"); + + GPS.recording.onHrUpdate = (reading) => { + if (hrEl) hrEl.textContent = reading.hr; + if (statusEl) statusEl.textContent = "Пульсометр на связи"; + }; + + let tick = () => { + if (!GPS.recording.active) { clearInterval(window._wkTimer); return; } + let sec = GPS.recording.startTime ? Math.round((Date.now() - GPS.recording.startTime.getTime()) / 1000) : 0; + if (timeEl) timeEl.textContent = GPS.formatDuration(sec); + let samples = GPS.recording.hrSamples || [], hrs = samples.map(s => s[1]); + if (kcalEl) kcalEl.textContent = GPS.hrKcalNet(samples, GPS.recording.hrProfile); + if (avgEl) avgEl.textContent = hrs.length ? Math.round(hrs.reduce((a, b) => a + b, 0) / hrs.length) : "--"; + if (maxEl) maxEl.textContent = hrs.length ? Math.max(...hrs) : "--"; + if (GPS.recording.currentHr && hrEl) hrEl.textContent = GPS.recording.currentHr; + }; + clearInterval(window._wkTimer); + window._wkTimer = setInterval(tick, 1000); + tick(); + + container.querySelector("#wk-connect").onclick = async () => { + let addr = state.profile?.ble_device_addr; + if (!addr) { showToast("Сначала привяжите датчик: Настройки -> Пульсометр -> Поиск"); return; } + let bt = "on"; try { bt = await window.__TAURI__.core.invoke("ble_check_bluetooth"); } catch {} + if (bt === "off") { try { await window.__TAURI__.core.invoke("ble_request_enable"); } catch {} showToast("Включите Bluetooth"); return; } + if (statusEl) statusEl.textContent = "Подключение..."; + try { await window.__TAURI__.core.invoke("ble_connect", { address: addr, deviceType: state.profile?.ble_device_type || "hr_standard" }); } catch (e) { showToast("Ошибка: " + (e.message || e)); } + }; + + container.querySelector("#wk-stop").onclick = async () => { + clearInterval(window._wkTimer); + let res = await GPS.stopRecording(); + if (res && res.sessionId) { + showToast(`Зарядка: ${res.kcal} ккал, ${GPS.formatDuration(res.durationSec)}, пульс ${res.avgHr ?? "--"}/${res.maxHr ?? "--"}`); + showSessionDetail(res.sessionId); + } else { renderScreen("gps"); } + }; +} + async function renderRecordingInline(container) { if (!GPS.recording.active) { renderScreen("gps"); return; } @@ -4435,6 +4548,9 @@ async function renderRecordingInline(container) { }).catch(() => {}); } + // HR-only workout (зарядка): no map, no GPS — just heart rate, timer, live kcal. + if (GPS.recording.activityType === "workout") { renderWorkoutInline(container); return; } + container.innerHTML = `
@@ -5242,6 +5358,8 @@ async function showSessionDetail(sessionId) { console.log("[SD] loading session", sessionId); let session = await DB.getGpsSession(sessionId); if (!session) { console.error("[SD] session not found"); return; } + // HR-only workout: no GPS/map — its own detail (HR chart + load), reuse this modal path. + if (session.activity_type === "workout") { renderWorkoutDetail(session); return; } console.log("[SD] loading points"); let rawPoints = await DB.getGpsPoints(sessionId); console.log("[SD] got", rawPoints.length, "raw points"); diff --git a/tools/hr_log.py b/tools/hr_log.py new file mode 100644 index 0000000..d810183 --- /dev/null +++ b/tools/hr_log.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""Фоновый логгер ЧСС с BLE HR-датчика (стандарт 0x180D / 0x2A37, напр. Coospo H808S). +Пишет строки 'epoch,bpm' в указанный файл, пока процесс не убьют. Авто-переподключение +при обрыве. Спаривание не требуется. + +Использование: python3 tools/hr_log.py +""" +import asyncio, sys, time +from bleak import BleakClient, BleakScanner + +ADDR = sys.argv[1] +LOG = sys.argv[2] +HR_CHR = "00002a37-0000-1000-8000-00805f9b34fb" + + +def make_cb(logf): + def cb(_, data: bytearray): + flags = data[0] + bpm = int.from_bytes(data[1:3], "little") if flags & 1 else data[1] + logf.write(f"{time.time():.1f},{bpm}\n") + logf.flush() + return cb + + +async def main(): + logf = open(LOG, "a") + logf.write(f"# {time.time():.1f} start addr={ADDR}\n") + logf.flush() + while True: + try: + dev = await BleakScanner.find_device_by_address(ADDR, timeout=15.0) + if dev is None: + logf.write(f"# {time.time():.1f} not_found, retry\n") + logf.flush() + await asyncio.sleep(3) + continue + async with BleakClient(dev) as cli: + logf.write(f"# {time.time():.1f} connected\n") + logf.flush() + await cli.start_notify(HR_CHR, make_cb(logf)) + while cli.is_connected: + await asyncio.sleep(1) + logf.write(f"# {time.time():.1f} disconnected, reconnect\n") + logf.flush() + except Exception as e: + logf.write(f"# {time.time():.1f} err {e}\n") + logf.flush() + await asyncio.sleep(3) + + +asyncio.run(main()) From 62434d61553816fbd46e9bc60f66b321e77d2cab Mon Sep 17 00:00:00 2001 From: 4aiman Date: Sat, 17 Oct 2026 00:58:31 +0300 Subject: [PATCH 53/65] HR strap battery + low-charge voice/beep alerts at 10% and 5% - BleHrService reads the standard Battery service (0x180F/0x2A19) 2.5s after the HR subscribe and every 60s; writes 'bat' into ble_hr_data.json. - HrReading gains battery: Option (serde alias 'bat'). - gps.js: the HR poll captures reading.battery; _checkBatteryAlert speaks (speechSynthesis ru-RU) and beeps once at 10% and 5% (beep is the guaranteed cue where TTS is absent), resetting above 15%. - Workout screen shows the strap charge in its status line. --- .../2026-10-17_hr-workout-mode.md | 14 ++++++- frontend/js/gps.js | 42 +++++++++++++++++++ frontend/js/main.js | 4 ++ .../main/java/dev/baldav/app/BleHrService.kt | 37 +++++++++++++++- src-tauri/src/ble.rs | 2 + 5 files changed, 95 insertions(+), 4 deletions(-) diff --git a/docs/session-logs/2026-10-17_hr-workout-mode.md b/docs/session-logs/2026-10-17_hr-workout-mode.md index 130e3b1..324b4a9 100644 --- a/docs/session-logs/2026-10-17_hr-workout-mode.md +++ b/docs/session-logs/2026-10-17_hr-workout-mode.md @@ -31,8 +31,18 @@ project-hr-sensor. `gps_sessions.total_kcal`). `recalculateSession` для workout - no-op (точек нет, возвращает null), так что "Пересчитать всё" не затирает HR-ккал. -## Осталось (отдельным коммитом) -Заряд ремня ЧСС (0x180F/0x2A19) + голос/писк на 10% и 5%. +## Заряд ремня ЧСС + голос/писк на 10% и 5% (сделано) +- Kotlin `BleHrService`: читает 0x180F/0x2A19 (readBattery) через 2.5с после подписки и раз в + 60с; `lastBattery` пишется в `ble_hr_data.json` как `"bat":N`. onCharacteristicRead (две + перегрузки под API 33+/старый). +- Rust `HrReading`: поле `battery: Option` (alias "bat"). +- JS `gps.js`: поллинг ловит `reading.battery` -> `GPS.recording.currentBattery` + + `GPS._checkBatteryAlert`: на 10% и 5% ОДИН раз - голос (`speechSynthesis` ru-RU) + писк + (Web Audio, гарантированный сигнал, т.к. TTS на de-Googled ROM может не быть). Сброс флагов + при заряде > 15% (перезарядили/заменили). +- Экран зарядки показывает "заряд N%" в статусе. +- Заметка: аудио в WebView может требовать user-gesture; старт записи - это жест, должно + играть. Проверить на устройстве. ## Проверка Синтаксис JS ок. На телефоне: выбрать "Зарядка", Старт (без браслета покажет "Подключите diff --git a/frontend/js/gps.js b/frontend/js/gps.js index 50f2784..599270a 100644 --- a/frontend/js/gps.js +++ b/frontend/js/gps.js @@ -840,6 +840,7 @@ GPS._startHrMonitor = async function() { GPS.recording.hrPollInterval = setInterval(async () => { try { const reading = await window.__TAURI__.core.invoke("ble_get_hr"); + if (reading && reading.battery != null) { GPS.recording.currentBattery = reading.battery; GPS._checkBatteryAlert(reading.battery); } if (reading && reading.hr > 0) { GPS.recording.currentHr = reading.hr; GPS.recording.currentBleData = reading; @@ -867,6 +868,47 @@ GPS._stopHrMonitor = function() { try { window.__TAURI__.core.invoke("ble_clear_hr"); } catch {} }; +// Low-battery warning for the HR strap: announce (voice) and beep ONCE at 10% and 5%. +// Resets when the strap reads healthy again (recharged / battery replaced). +GPS._batAlerted = {}; +GPS._checkBatteryAlert = function(bat) { + if (bat == null || bat < 0 || bat > 100) return; + if (bat > 15) { GPS._batAlerted = {}; return; } + for (let thr of [10, 5]) { + if (bat <= thr && !GPS._batAlerted[thr]) { + GPS._batAlerted[thr] = true; + GPS._speakOrBeep(`Заряд пульсометра ${thr} процентов`); + } + } +}; +GPS._speakOrBeep = function(text) { + let spoke = false; + try { + if (window.speechSynthesis && typeof SpeechSynthesisUtterance !== "undefined") { + let u = new SpeechSynthesisUtterance(text); + u.lang = "ru-RU"; u.rate = 0.95; + window.speechSynthesis.speak(u); + spoke = true; + } + } catch {} + // Always beep too (TTS may be absent on a de-Googled ROM; the beep is the guaranteed cue). + try { GPS._beep(spoke ? 1 : 3); } catch {} +}; +GPS._beep = function(times) { + times = times || 1; + let ctx = new (window.AudioContext || window.webkitAudioContext)(); + let one = (i) => { + let o = ctx.createOscillator(), g = ctx.createGain(); + o.frequency.value = 880; o.type = "sine"; + o.connect(g); g.connect(ctx.destination); + g.gain.value = 0.25; + let t0 = ctx.currentTime + i * 0.35; + o.start(t0); o.stop(t0 + 0.2); + }; + for (let i = 0; i < times; i++) one(i); + setTimeout(() => { try { ctx.close(); } catch {} }, times * 350 + 400); +}; + // Read points from native service file // Parse raw JSONL (one native point per line) into sorted point objects. GPS._parseNativePoints = function(raw) { diff --git a/frontend/js/main.js b/frontend/js/main.js index 467a10f..3c8a407 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -4514,6 +4514,10 @@ function renderWorkoutInline(container) { if (avgEl) avgEl.textContent = hrs.length ? Math.round(hrs.reduce((a, b) => a + b, 0) / hrs.length) : "--"; if (maxEl) maxEl.textContent = hrs.length ? Math.max(...hrs) : "--"; if (GPS.recording.currentHr && hrEl) hrEl.textContent = GPS.recording.currentHr; + if (statusEl && GPS.recording.currentHr) { + let b = GPS.recording.currentBattery; + statusEl.textContent = "Пульсометр на связи" + (b != null ? ` · заряд ${b}%` : ""); + } }; clearInterval(window._wkTimer); window._wkTimer = setInterval(tick, 1000); diff --git a/src-tauri/gen/android/app/src/main/java/dev/baldav/app/BleHrService.kt b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/BleHrService.kt index 70eb9f4..46b1506 100644 --- a/src-tauri/gen/android/app/src/main/java/dev/baldav/app/BleHrService.kt +++ b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/BleHrService.kt @@ -31,6 +31,9 @@ class BleHrService : Service() { val HR_SERVICE_UUID: UUID = UUID.fromString("0000180d-0000-1000-8000-00805f9b34fb") val HR_MEASUREMENT_UUID: UUID = UUID.fromString("00002a37-0000-1000-8000-00805f9b34fb") val CCCD_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") + // Standard Battery Service (strap charge) + val BATTERY_SERVICE: UUID = UUID.fromString("0000180f-0000-1000-8000-00805f9b34fb") + val BATTERY_LEVEL: UUID = UUID.fromString("00002a19-0000-1000-8000-00805f9b34fb") // FitPro Nordic UART val FITPRO_SERVICE: UUID = UUID.fromString("6e400001-b5a3-f393-e0a9-e50e24dcca9d") @@ -51,6 +54,7 @@ class BleHrService : Service() { private var gatt: BluetoothGatt? = null private var deviceType = "hr_standard" private var dataFile: File? = null + private var lastBattery: Int = -1 // strap charge %, -1 = unknown private var scanResultsFile: File? = null private var fitproWriteChar: BluetoothGattCharacteristic? = null private var fitproGatt: BluetoothGatt? = null @@ -260,6 +264,15 @@ class BleHrService : Service() { override fun onCharacteristicChanged(g: BluetoothGatt, char: BluetoothGattCharacteristic) { onCharacteristicChanged(g, char, char.value) } + // Battery level read (0x2A19). Two overloads: API 33+ passes the value, older reads char.value. + override fun onCharacteristicRead(g: BluetoothGatt, char: BluetoothGattCharacteristic, value: ByteArray, status: Int) { + if (char.uuid == BATTERY_LEVEL && value.isNotEmpty()) { lastBattery = value[0].toInt() and 0xFF; Log.d(TAG, "Battery: $lastBattery%") } + } + @Deprecated("Deprecated in API 33") + override fun onCharacteristicRead(g: BluetoothGatt, char: BluetoothGattCharacteristic, status: Int) { + val v = char.value + if (char.uuid == BATTERY_LEVEL && v != null && v.isNotEmpty()) { lastBattery = v[0].toInt() and 0xFF; Log.d(TAG, "Battery: $lastBattery%") } + } override fun onDescriptorWrite(g: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) { Log.d(TAG, "onDescriptorWrite status=$status") @@ -284,6 +297,25 @@ class BleHrService : Service() { g.writeDescriptor(desc) } Log.d(TAG, "Standard HR setup complete") + // Strap battery (0x180F/0x2A19): read once after the HR subscribe settles, then every 60s. + android.os.Handler(mainLooper).also { h -> + val readBat = object : Runnable { + override fun run() { + if (!isRunning) return + readBattery(g) + h.postDelayed(this, 60000) + } + } + h.postDelayed(readBat, 2500) + } + } + + private fun readBattery(g: BluetoothGatt) { + try { + val bs = g.getService(BATTERY_SERVICE) ?: return + val bc = bs.getCharacteristic(BATTERY_LEVEL) ?: return + g.readCharacteristic(bc) + } catch (e: Exception) { Log.e(TAG, "readBattery: ${e.message}") } } private val gattQueue = mutableListOf<() -> Unit>() @@ -382,8 +414,9 @@ class BleHrService : Service() { } if (hr <= 0) return val ts = System.currentTimeMillis() / 1000 - writeData("""{"hr":$hr,"ts":$ts}""") - Log.d(TAG, "HR: $hr") + val batJson = if (lastBattery in 0..100) ""","bat":$lastBattery""" else "" + writeData("""{"hr":$hr,"ts":$ts$batJson}""") + Log.d(TAG, "HR: $hr bat: $lastBattery") } private fun parseFitProData(data: ByteArray) { diff --git a/src-tauri/src/ble.rs b/src-tauri/src/ble.rs index 89e33d2..d20d531 100644 --- a/src-tauri/src/ble.rs +++ b/src-tauri/src/ble.rs @@ -25,6 +25,8 @@ pub struct HrReading { pub timestamp: u64, #[serde(default)] pub device: String, + #[serde(alias = "bat", default)] + pub battery: Option, } // Shared state for BLE operations From f7ba059628db46486104031807b4d8835b7ebdc0 Mon Sep 17 00:00:00 2001 From: 4aiman Date: Sat, 17 Oct 2026 01:45:02 +0300 Subject: [PATCH 54/65] HR: auto-connect at recording start if a strap is paired; HR on/off toggle replaces the wind button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Earlier I over-corrected: 'don't scan when the strap is absent' became 'never auto-connect'. Restore conditional auto: at recording start (ride or workout) connect the paired strap and poll; absent strap is safe now (BleHrService scans briefly and stops - no drain). No paired strap -> HR off. - gps.js: GPS.setHrOn(on) - connect+poll / disconnect+stop; start auto-ons it. - Record screen: the inconvenient 'Ветер' button is gone; in its place an HR on/off toggle (square, heart+pulse icon; grey off, red on) -> setHrOn. (Also corrected a stale assumption: the phone DOES have Google services.) --- .../2026-10-17_hr-workout-mode.md | 16 ++++++++ frontend/css/styles.css | 5 +++ frontend/js/gps.js | 37 +++++++++++++++++-- frontend/js/main.js | 15 +++++--- 4 files changed, 64 insertions(+), 9 deletions(-) diff --git a/docs/session-logs/2026-10-17_hr-workout-mode.md b/docs/session-logs/2026-10-17_hr-workout-mode.md index 324b4a9..cdc2f22 100644 --- a/docs/session-logs/2026-10-17_hr-workout-mode.md +++ b/docs/session-logs/2026-10-17_hr-workout-mode.md @@ -44,6 +44,22 @@ project-hr-sensor. - Заметка: аудио в WebView может требовать user-gesture; старт записи - это жест, должно играть. Проверить на устройстве. +## Правка после теста: авто-ЧСС на старте + тоггл ВКЛ/ВЫКЛ вместо "Ветер" +Я перестарался: по просьбе "не искать, если браслета нет" убрал автоконнект ВООБЩЕ. Нужно было +оставить авто с проверкой наличия. Исправлено: +- `GPS.setHrOn(on)` (gps.js): ON - проверяет BT (off -> запрос включения), коннектит привязанный + датчик, запускает поллинг; OFF - стоп поллинг + disconnect. Безопасно: BleHrService при + отсутствии браслета коротко сканит и `stopSelf` (драйна нет). +- Старт записи (и GPS, и зарядки): если датчик привязан - авто `setHrOn(true)`. Так "надел + датчик -> начал -> подключился сам", без ручного коннекта каждый раз. Нет привязанного - ЧСС off. +- Экран записи: кнопка **"Ветер" убрана** (неудобна), на её месте **тоггл ЧСС** (квадрат с + сердечком+линией пульса; серый = выкл, красный = вкл), дёргает `GPS.setHrOn`. +- Ручная кнопка "Подключить" в настройках остаётся. + +Факт-чек (я ошибся ранее): на телефоне Google-сервисы ЕСТЬ (`com.google.android.gms`, Play +Store) - значит системный диалог включения гео через Play Services доступен. Авто-вкл BT/гео по +запросу (попап + 2 настройки "не спрашивать") - отдельная задача, теперь подтверждено выполнимо. + ## Проверка Синтаксис JS ок. На телефоне: выбрать "Зарядка", Старт (без браслета покажет "Подключите пульсометр"), подключить датчик -> пульс крупно + ккал растут; Стоп -> деталь с графиком. diff --git a/frontend/css/styles.css b/frontend/css/styles.css index 1a69380..28d8214 100644 --- a/frontend/css/styles.css +++ b/frontend/css/styles.css @@ -725,6 +725,11 @@ select { .wk-status { color: var(--muted); font-size: 13px; text-align: center; min-height: 18px; margin-bottom: 6px; } .workout-screen .btn-primary, .workout-screen .btn-secondary { margin-top: 8px; width: 100%; } +/* HR on/off toggle on the record screen (replaces the wind button): grey = off, coloured = on. */ +.rec-hr-toggle { color: #888; display: inline-flex; align-items: center; justify-content: center; min-width: 46px; padding: 6px 10px; } +.rec-hr-toggle svg { display: block; } +.rec-hr-toggle.active { color: #ef5350; background: rgba(239, 83, 80, 0.18); } + /* Progress fill for long-running action buttons: the background fills left-to-right by --pct (0-100%, set from JS). The label stays on top and readable; the unfilled part keeps the button's own background. Used by recompute / elevation-refine / roads-rebuild. */ diff --git a/frontend/js/gps.js b/frontend/js/gps.js index 599270a..81641f6 100644 --- a/frontend/js/gps.js +++ b/frontend/js/gps.js @@ -868,6 +868,28 @@ GPS._stopHrMonitor = function() { try { window.__TAURI__.core.invoke("ble_clear_hr"); } catch {} }; +// Turn HR on/off during a recording (the toggle on the record screen). ON connects to the +// paired strap (Kotlin scans ~5s and stops cleanly if it's absent — so no constant scanning / +// drain when the strap isn't there) and polls; OFF stops the poll and disconnects. +GPS.setHrOn = async function(on) { + GPS.recording.hrOn = !!on; + if (!on) { + GPS._stopHrMonitor(); + try { await window.__TAURI__.core.invoke("ble_disconnect"); } catch {} + return false; + } + const addr = await DB.getProfileValue("ble_device_addr"); + const type = await DB.getProfileValue("ble_device_type"); + if (!addr) { GPS.recording.hrOn = false; return false; } + try { + const bt = await window.__TAURI__.core.invoke("ble_check_bluetooth"); + if (bt === "off") { try { await window.__TAURI__.core.invoke("ble_request_enable"); } catch {} } + else if (bt !== "no_adapter") { try { await window.__TAURI__.core.invoke("ble_connect", { address: addr, deviceType: type || "hr_standard" }); } catch {} } + } catch {} + try { await GPS._startHrMonitor(); } catch {} + return true; +}; + // Low-battery warning for the HR strap: announce (voice) and beep ONCE at 10% and 5%. // Resets when the strap reads healthy again (recharged / battery replaced). GPS._batAlerted = {}; @@ -1386,7 +1408,11 @@ GPS.startRecording = async function(activityType, weightKg, profile) { GPS.recording.sessionId = await DB.createGpsSession( new Date().toISOString().slice(0, 10), new Date().toTimeString().slice(0, 5), "workout"); } catch (e) { console.error("[workout] createGpsSession failed:", e); } - try { await GPS._startHrMonitor(); } catch (e) { console.error("[HR] start error:", e); } + // Auto-connect the paired strap on start (workout = you want HR). Absent strap -> stops clean. + try { + const hrAddr = await DB.getProfileValue("ble_device_addr"); + if (hrAddr) await GPS.setHrOn(true); else GPS.recording.hrOn = false; + } catch (e) { console.error("[HR] start error:", e); } return; } @@ -1409,8 +1435,13 @@ GPS.startRecording = async function(activityType, weightKg, profile) { // Start native foreground service (Android) await GPS._startNativeService(); - // Start BLE HR monitor (auto-connect to paired device) - try { await GPS._startHrMonitor(); } catch (e) { console.error("[HR] start error:", e); } + // HR: default ON if a strap is paired — auto-connect + poll at start (the record screen has an + // on/off toggle). Safe: BleHrService scans briefly and stops if the strap isn't present, so no + // constant scanning/drain when it's absent. No paired strap -> HR stays off. + try { + const hrAddr = await DB.getProfileValue("ble_device_addr"); + if (hrAddr) await GPS.setHrOn(true); else GPS.recording.hrOn = false; + } catch (e) { console.error("[HR] start error:", e); } // Poll native service output file if (GPS.recording.nativeService) { diff --git a/frontend/js/main.js b/frontend/js/main.js index 3c8a407..caca390 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -4578,7 +4578,7 @@ async function renderRecordingInline(container) {
${GPS.recording.state === "waiting_gps" ? "Поиск GPS..." : GPS.recording.state === "ready" ? "Начните движение..." : ""}
- +
@@ -5118,11 +5118,14 @@ async function renderRecordingInline(container) { // Headwind label toggle: tap to open a "wind in the face" interval, tap to close. // Marks are ground truth for validating the from-track wind estimate — not kcal. - let windBtn = container.querySelector("#rec-wind"); - if (windBtn) windBtn.onclick = () => { - let active = GPS.toggleWindMark(); - windBtn.style.background = active ? "#ffb74d" : ""; - windBtn.style.color = active ? "#1a1a1a" : ""; + // HR on/off toggle (replaces the old "Ветер" button). ON connects the paired strap + polls; + // OFF disconnects. Grey = off, coloured = on. + let hrToggle = container.querySelector("#rec-hr-toggle"); + if (hrToggle) hrToggle.onclick = async () => { + hrToggle.disabled = true; + await GPS.setHrOn(!GPS.recording.hrOn); + hrToggle.classList.toggle("active", !!GPS.recording.hrOn); + hrToggle.disabled = false; }; // Pause button From 2d0e51bd8d3750e3c718a8692208985b569711ec Mon Sep 17 00:00:00 2001 From: 4aiman Date: Sat, 17 Oct 2026 02:07:44 +0300 Subject: [PATCH 55/65] Prompt to enable Bluetooth/location at recording start + two 'don't ask' settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User forgets to turn BT/GPS on. Modern Android won't let an app toggle them silently, so prompt and act: BT via the system enable dialog, location via the location-settings screen. - ensureSystemReady(needBt, needGps) runs before startRecording: BT off -> confirm -> ble_request_enable; location off -> confirm -> open settings. Gated by two settings (baldav_dontask_bt / _gps); declining proceeds. - GPS.isLocationEnabled polls a new Kotlin check (check_location writes location_state.json via LocationManager; read_state_file reads it); open_location_settings opens ACTION_LOCATION_SOURCE_SETTINGS. - Settings: 'Не спрашивать про Bluetooth' / '... геолокацию' checkboxes. --- .../session-logs/2026-10-17_bt-gps-prompts.md | 30 ++++++++++++++ frontend/js/gps.js | 13 +++++++ frontend/js/main.js | 39 +++++++++++++++++++ .../main/java/dev/baldav/app/MainActivity.kt | 13 +++++++ src-tauri/src/lib.rs | 10 ++++- 5 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 docs/session-logs/2026-10-17_bt-gps-prompts.md diff --git a/docs/session-logs/2026-10-17_bt-gps-prompts.md b/docs/session-logs/2026-10-17_bt-gps-prompts.md new file mode 100644 index 0000000..6167196 --- /dev/null +++ b/docs/session-logs/2026-10-17_bt-gps-prompts.md @@ -0,0 +1,30 @@ +# Сессия 2026-10-17 (2) - попап "включи BT/гео" при старте записи + 2 настройки "не спрашивать" + +## Задача +Пользователь забывает включать Bluetooth и GPS. Нужно: при старте записи, если выключено - +предложить включить; дать отказаться; 2 отдельные настройки "не спрашивать". + +Факт-чек: телефон С Google-сервисами (ранее ошибочно решил, что без). Тихо включать BT/гео +приложение на target 36 не может (ОС), поэтому - попап + действие: BT через системный диалог +включения, гео через экран настроек геолокации. + +## Сделано +- Rust `read_state_file(name)` (allowlist "location_state.json") - читает ответ Kotlin из + native_root. Зарегистрирован. +- Kotlin MainActivity: команды `check_location` (пишет `location_state.json` {enabled} через + LocationManager.isLocationEnabled) и `open_location_settings` (ACTION_LOCATION_SOURCE_SETTINGS). +- `gps.js` `GPS.isLocationEnabled()` - дёргает check_location, читает state-файл; true/false или + null (неизвестно - тогда не спрашивать). +- `main.js` `ensureSystemReady(needBt, needGps)` - перед startRecording: BT off -> + appConfirm "Включить?" -> `ble_request_enable`; гео off -> appConfirm "Открыть настройки?" -> + `open_location_settings`. Гейтится настройками `baldav_dontask_bt` / `baldav_dontask_gps`. + Вызов в кнопке записи: needBt = есть привязанный датчик, needGps = не зарядка. +- Настройки (раздел Пульсометр): 2 чекбокса "Не спрашивать про Bluetooth" / "...геолокацию". + +Ограничение (честно): авто-ВЫКЛ BT/гео после тренировки приложение сделать не может (ОС). +Тихое включение - тоже; поэтому попап + один тап. + +## Проверка +Синтаксис JS ок. На телефоне: выключить BT и гео, старт записи трека -> два попапа по очереди +(Включить BT / Открыть настройки гео); отказ -> запись идёт как есть; включить чекбоксы "не +спрашивать" -> попапы не показываются. diff --git a/frontend/js/gps.js b/frontend/js/gps.js index 81641f6..e86c11e 100644 --- a/frontend/js/gps.js +++ b/frontend/js/gps.js @@ -822,6 +822,19 @@ GPS.requestBackgroundLocation = async function() { } catch (e) { console.log("[GPS] reqbgloc:", e); } }; +// Is the OS location toggle ON (not just the permission)? Kotlin checks LocationManager and +// writes location_state.json; we read it back. Returns true/false, or null if undeterminable +// (desktop / no answer) — callers must not prompt on null. +GPS.isLocationEnabled = async function() { + try { + await window.__TAURI__.core.invoke("gps_service_command", { cmd: "check_location" }); + await new Promise(r => setTimeout(r, 300)); + let raw = await window.__TAURI__.core.invoke("read_state_file", { name: "location_state.json" }); + if (!raw) return null; + return !!JSON.parse(raw).enabled; + } catch { return null; } +}; + // BLE HR monitor — tied to GPS session (start→stop), but NOT paused when GPS pauses. // HR keeps measuring even when rider is stationary. GPS._startHrMonitor = async function() { diff --git a/frontend/js/main.js b/frontend/js/main.js index caca390..2e0d2b8 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -1182,6 +1182,26 @@ function setBtnProgress(el, pct) { el.style.setProperty("--pct", Math.max(0, Math.min(100, Math.round(pct))) + "%"); } +// Before a recording, nudge the user to turn on what it needs: Bluetooth (for the HR strap) and +// location (for the track). Can't silently toggle them on modern Android, so prompt + act: BT via +// the system enable dialog, location via the location-settings screen. Declining is fine, and the +// two "Не спрашивать" settings suppress each prompt. Never prompts when the state is unknown. +async function ensureSystemReady(needBt, needGps) { + let inv = window.__TAURI__.core.invoke; + if (needBt && localStorage.getItem("baldav_dontask_bt") !== "1") { + let bt = "on"; try { bt = await inv("ble_check_bluetooth"); } catch {} + if (bt === "off" && await appConfirm("Для пульсометра нужен Bluetooth. Включить?", "Включить")) { + try { await inv("ble_request_enable"); } catch {} + } + } + if (needGps && localStorage.getItem("baldav_dontask_gps") !== "1") { + let locOn = await GPS.isLocationEnabled(); + if (locOn === false && await appConfirm("Для записи трека нужна геолокация. Открыть настройки, чтобы включить?", "Открыть")) { + try { await inv("gps_service_command", { cmd: "open_location_settings" }); } catch {} + } + } +} + async function reopenDetail(sessionId) { let count = 0; while (winStack.length > 0) { @@ -2846,6 +2866,10 @@ async function renderGpsScreen(container) { GPS.recording.gearCount = bike.gear_count; } } + // Nudge BT (for the strap) / location (for the track) on if off. Non-blocking to the user's + // choice: they can decline, and the two "Не спрашивать" settings suppress each prompt. + await ensureSystemReady(!!state.profile?.ble_device_addr, type !== "workout"); + // Workout (HR-only): pass the profile so finish can compute kcal from HR (Keytel). let hrProfile = type === "workout" ? { weightKg: wkg, @@ -7330,6 +7354,16 @@ async function renderSettings(container) {
+ +
@@ -7764,6 +7798,11 @@ async function renderSettings(container) { bleConnectBtn.disabled = false; bleConnectBtn.textContent = "Подключить"; }; + let dontAskBt = container.querySelector("#s-dontask-bt"); + if (dontAskBt) dontAskBt.onchange = () => { try { localStorage.setItem("baldav_dontask_bt", dontAskBt.checked ? "1" : "0"); } catch {} }; + let dontAskGps = container.querySelector("#s-dontask-gps"); + if (dontAskGps) dontAskGps.onchange = () => { try { localStorage.setItem("baldav_dontask_gps", dontAskGps.checked ? "1" : "0"); } catch {} }; + let bleScanBtn = container.querySelector("#s-ble-scan"); let bleResults = container.querySelector("#s-ble-results"); if (bleScanBtn) { diff --git a/src-tauri/gen/android/app/src/main/java/dev/baldav/app/MainActivity.kt b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/MainActivity.kt index d22b344..caf488c 100644 --- a/src-tauri/gen/android/app/src/main/java/dev/baldav/app/MainActivity.kt +++ b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/MainActivity.kt @@ -514,6 +514,19 @@ class MainActivity : TauriActivity() { catch (e: Exception) { Log.e("BalDav", "BT enable request failed: ${e.message}") } } } + cmd == "check_location" -> { + val lm = getSystemService(LOCATION_SERVICE) as? LocationManager + val enabled = when { + lm == null -> false + Build.VERSION.SDK_INT >= Build.VERSION_CODES.P -> lm.isLocationEnabled + else -> lm.isProviderEnabled(LocationManager.GPS_PROVIDER) || lm.isProviderEnabled(LocationManager.NETWORK_PROVIDER) + } + File(appDataDir, "location_state.json").writeText("""{"enabled":$enabled}""") + } + cmd == "open_location_settings" -> { + try { startActivity(Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS)) } + catch (e: Exception) { Log.e("BalDav", "open location settings: ${e.message}") } + } cmd == "check_update" -> { checkForUpdate(appDataDir) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index be7794b..7a8be8c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -228,6 +228,14 @@ fn read_points_file(app: tauri::AppHandle, filename: String) -> Result Result { + if name != "location_state.json" { return Err("bad name".to_string()); } + let p = native_root(&app).join(&name); + if p.exists() { std::fs::read_to_string(&p).map_err(|e| e.to_string()) } else { Ok(String::new()) } +} + // Download binary file from URL to local path (for tile DB import from router) // read_to_vec() has a 10MB limit, so use as_reader() for unlimited binary reads #[tauri::command] @@ -1154,7 +1162,7 @@ pub fn run() { .add_migrations("sqlite:baldav.db", migrations) .build(), ) - .invoke_handler(tauri::generate_handler![get_safe_insets, import_seed_products, import_tiles, download_file, get_points_filename, delete_points_file, gps_set_session_id, list_points_files, read_points_file, pick_file_open, get_picked_file, debug_native_paths, rebuild_roads, get_road_points, get_ride_graph, get_app_info, gps_service_command, gps_service_read_points, brouter_route, brouter_download_tile, brouter_download_status, brouter_list_tiles, save_file, export_image, read_file_base64, export_db, import_db, restart_app, get_update_info, http_request, chzn_http, ble::ble_scan, ble::ble_scan_results, ble::ble_connect, ble::ble_disconnect, ble::ble_get_hr, ble::ble_is_connected, ble::ble_check_bluetooth, ble::ble_save_device, ble::ble_clear_hr, ble::ble_request_enable]) + .invoke_handler(tauri::generate_handler![get_safe_insets, import_seed_products, import_tiles, download_file, get_points_filename, delete_points_file, gps_set_session_id, list_points_files, read_points_file, pick_file_open, get_picked_file, debug_native_paths, rebuild_roads, get_road_points, get_ride_graph, get_app_info, gps_service_command, gps_service_read_points, brouter_route, brouter_download_tile, brouter_download_status, brouter_list_tiles, save_file, export_image, read_file_base64, export_db, import_db, restart_app, get_update_info, http_request, chzn_http, ble::ble_scan, ble::ble_scan_results, ble::ble_connect, ble::ble_disconnect, ble::ble_get_hr, ble::ble_is_connected, ble::ble_check_bluetooth, ble::ble_save_device, ble::ble_clear_hr, ble::ble_request_enable, read_state_file]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } From 553a4fcf80efc85c2b3dccfe53fcddb05e03fd3b Mon Sep 17 00:00:00 2001 From: 4aiman Date: Sat, 17 Oct 2026 10:57:08 +0300 Subject: [PATCH 56/65] Record screen: show the live HR number on the HR toggle button The HR toggle (ex-wind button) showed only the heart+pulse icon. Now the current bpm renders on it (onHrUpdate writes #rec-hr-num); it clears when HR is toggled off, leaving just the grey icon. --- frontend/css/styles.css | 4 +++- frontend/js/main.js | 5 ++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/frontend/css/styles.css b/frontend/css/styles.css index 28d8214..f580eaa 100644 --- a/frontend/css/styles.css +++ b/frontend/css/styles.css @@ -726,9 +726,11 @@ select { .workout-screen .btn-primary, .workout-screen .btn-secondary { margin-top: 8px; width: 100%; } /* HR on/off toggle on the record screen (replaces the wind button): grey = off, coloured = on. */ -.rec-hr-toggle { color: #888; display: inline-flex; align-items: center; justify-content: center; min-width: 46px; padding: 6px 10px; } +.rec-hr-toggle { color: #888; display: inline-flex; align-items: center; justify-content: center; gap: 3px; padding: 6px 10px; } .rec-hr-toggle svg { display: block; } .rec-hr-toggle.active { color: #ef5350; background: rgba(239, 83, 80, 0.18); } +.rec-hr-num { font-size: 14px; font-weight: 700; min-width: 0; } +.rec-hr-num:empty { display: none; } /* Progress fill for long-running action buttons: the background fills left-to-right by --pct (0-100%, set from JS). The label stays on top and readable; the unfilled part keeps diff --git a/frontend/js/main.js b/frontend/js/main.js index 2e0d2b8..615aa17 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -4602,7 +4602,7 @@ async function renderRecordingInline(container) {
${GPS.recording.state === "waiting_gps" ? "Поиск GPS..." : GPS.recording.state === "ready" ? "Начните движение..." : ""}
- +
@@ -5089,6 +5089,8 @@ async function renderRecordingInline(container) { hrEl.textContent = reading.hr; hrEl.className = "rec-hr-val " + hrZoneClass(reading.hr); } + let hrNum = document.querySelector("#rec-hr-num"); // live HR on the toggle button itself + if (hrNum) hrNum.textContent = reading.hr; let spo2El = document.querySelector("#rec-spo2"); if (spo2El) spo2El.textContent = reading.spo2 ? `SpO2: ${reading.spo2}%` : ""; let bpEl = document.querySelector("#rec-bp"); @@ -5149,6 +5151,7 @@ async function renderRecordingInline(container) { hrToggle.disabled = true; await GPS.setHrOn(!GPS.recording.hrOn); hrToggle.classList.toggle("active", !!GPS.recording.hrOn); + if (!GPS.recording.hrOn) { let n = container.querySelector("#rec-hr-num"); if (n) n.textContent = ""; } // off -> icon only hrToggle.disabled = false; }; From 0a4ffcb49dec289f446bcc34f666caacc54a8d9d Mon Sep 17 00:00:00 2001 From: 4aiman Date: Sat, 17 Oct 2026 10:59:31 +0300 Subject: [PATCH 57/65] HR toggle: white icon+bpm on solid red when active (contrast) Active state was red text on faint-red; use white on solid red. --- frontend/css/styles.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/css/styles.css b/frontend/css/styles.css index f580eaa..fe716b3 100644 --- a/frontend/css/styles.css +++ b/frontend/css/styles.css @@ -728,7 +728,7 @@ select { /* HR on/off toggle on the record screen (replaces the wind button): grey = off, coloured = on. */ .rec-hr-toggle { color: #888; display: inline-flex; align-items: center; justify-content: center; gap: 3px; padding: 6px 10px; } .rec-hr-toggle svg { display: block; } -.rec-hr-toggle.active { color: #ef5350; background: rgba(239, 83, 80, 0.18); } +.rec-hr-toggle.active { color: #fff; background: #ef5350; } /* on = white icon+bpm on solid red */ .rec-hr-num { font-size: 14px; font-weight: 700; min-width: 0; } .rec-hr-num:empty { display: none; } From dffadf3cd81ee8e85908b5d66d8fa74bfb4856c9 Mon Sep 17 00:00:00 2001 From: 4aiman Date: Sat, 17 Oct 2026 12:29:14 +0300 Subject: [PATCH 58/65] Record screen: regroup stats (top strip time/temp/clock, bottom HR/dist/speed/alt), equal-width buttons; reset stale HR per session 1) Layout: a top strip shows training time | temperature | clock ('external' things); the bottom panel row is now HR, distance, speed, altitude (bigger); the separate HR bar is gone; the three controls (HR toggle, pause, stop) share the width equally instead of clustering right. 2) Clear currentHr/currentBleData at recording start (+ ble_clear_hr) so a stale bpm from the previous session no longer hangs on the display or gets written onto this session's points; show -- until a fresh reading. --- frontend/css/styles.css | 29 ++++++++++++++++++----------- frontend/js/gps.js | 6 ++++++ frontend/js/main.js | 31 +++++++++---------------------- 3 files changed, 33 insertions(+), 33 deletions(-) diff --git a/frontend/css/styles.css b/frontend/css/styles.css index fe716b3..3e5be5c 100644 --- a/frontend/css/styles.css +++ b/frontend/css/styles.css @@ -1406,11 +1406,22 @@ body.dark-map .poi-badge { background: #111; border-color: #eee; } } .rec-val { - font-size: 22px; + font-size: 27px; font-weight: 700; color: var(--on-surface); } +/* Top strip (external info): training time (left), temperature (centre), clock (right). */ +.rec-topbar { + display: flex; justify-content: space-between; align-items: center; + padding: 6px 16px; padding-top: calc(6px + var(--safe-area-top)); + background: var(--surface); border-bottom: 1px solid var(--border); + font-size: 15px; font-weight: 600; color: var(--on-surface); +} +.rec-topbar .rec-top-mid { color: var(--muted); font-weight: 500; } +/* Status line now stands alone under the stats (no longer stealing the buttons' width). */ +.rec-panel > .rec-status { font-size: 12px; color: var(--muted); text-align: center; padding: 0 12px 4px; min-height: 14px; } + .rec-lbl { font-size: 10px; color: var(--muted); @@ -1428,23 +1439,19 @@ body.dark-map .poi-badge { background: #111; border-color: #eee; } .rec-bottom { display: flex; - align-items: center; - flex-wrap: wrap; - gap: 12px; + align-items: stretch; + gap: 10px; padding: 8px 12px; padding-bottom: calc(8px + var(--safe-area-bottom)); background: var(--surface); } -.rec-bottom .rec-status { - font-size: 12px; - color: var(--muted); - flex: 1; -} +/* All three controls (HR toggle, pause, stop) share the width equally — no clustering right. */ +.rec-bottom > button { flex: 1 1 0; } .rec-bottom .gps-rec { - padding: 12px 32px; - flex-shrink: 0; + padding: 12px 0; + flex-shrink: 1; } .gps-header { diff --git a/frontend/js/gps.js b/frontend/js/gps.js index e86c11e..41fc6cf 100644 --- a/frontend/js/gps.js +++ b/frontend/js/gps.js @@ -1407,6 +1407,12 @@ GPS.startRecording = async function(activityType, weightKg, profile) { GPS.recording.points = []; GPS.recording.hrSamples = []; GPS.recording.hrProfile = profile || null; + // Clear any HR left over from the PREVIOUS session (else a stale bpm hangs on the display and + // gets written onto this session's points). Only a fresh reading this session re-fills it. + GPS.recording.currentHr = null; + GPS.recording.currentBleData = null; + GPS.recording.currentBattery = null; + try { window.__TAURI__.core.invoke("ble_clear_hr"); } catch {} GPS.recording.startTime = null; GPS.recording.weightKg = parseFloat(weightKg) || 80; diff --git a/frontend/js/main.js b/frontend/js/main.js index 615aa17..618d861 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -4581,27 +4581,24 @@ async function renderRecordingInline(container) { container.innerHTML = `
+
+ 0:00 + — + ${new Date().toTimeString().slice(0,5)} +
${GPS.recording.state === "waiting_gps" ? '
Поиск GPS...
' : ''}
-
+
--Пульс
0 мДистанция
0км/ч
-
0:00Время
—Высота
-
${new Date().toTimeString().slice(0,5)}Часы
-
—t°C
+
${GPS.recording.state === "waiting_gps" ? "Поиск GPS..." : GPS.recording.state === "ready" ? "Начните движение..." : ""}
-
${GPS.recording.state === "waiting_gps" ? "Поиск GPS..." : GPS.recording.state === "ready" ? "Начните движение..." : ""}
@@ -5081,20 +5078,10 @@ async function renderRecordingInline(container) { // HR update from BLE GPS.recording.onHrUpdate = (reading) => { - let row = document.querySelector("#rec-hr-row"); - if (!row) return; - row.style.display = "flex"; - let hrEl = document.querySelector("#rec-hr-val"); - if (hrEl) { - hrEl.textContent = reading.hr; - hrEl.className = "rec-hr-val " + hrZoneClass(reading.hr); - } + let hrEl = document.querySelector("#rec-hr-val"); // HR stat in the bottom row + if (hrEl) { hrEl.textContent = reading.hr; hrEl.className = "rec-val " + hrZoneClass(reading.hr); } let hrNum = document.querySelector("#rec-hr-num"); // live HR on the toggle button itself if (hrNum) hrNum.textContent = reading.hr; - let spo2El = document.querySelector("#rec-spo2"); - if (spo2El) spo2El.textContent = reading.spo2 ? `SpO2: ${reading.spo2}%` : ""; - let bpEl = document.querySelector("#rec-bp"); - if (bpEl) bpEl.textContent = (reading.bp_sys && reading.bp_dia) ? `${reading.bp_sys}/${reading.bp_dia}` : ""; }; // Timer — excludes pauses, starts only when recording From e88c2756f7b0e23cf69c22b34fbed940626bf9e7 Mon Sep 17 00:00:00 2001 From: 4aiman Date: Sat, 17 Oct 2026 14:30:42 +0300 Subject: [PATCH 59/65] Record screen: big digits in the top strip (match bottom), drop the black status-bar backdrop band and the extra control offset Top strip time/temp/clock now use the same 27px bold as the bottom stats. The old .rec-map-wrap::before status-bar backdrop (and the leaflet-top safe-area margin) are gone - the top strip already backs the status bar, so that strip showed as a black band between the bar and the map. --- frontend/css/styles.css | 30 ++++++++++-------------------- 1 file changed, 10 insertions(+), 20 deletions(-) diff --git a/frontend/css/styles.css b/frontend/css/styles.css index 3e5be5c..3ab245f 100644 --- a/frontend/css/styles.css +++ b/frontend/css/styles.css @@ -1310,20 +1310,9 @@ body.dark-map .poi-badge { background: #111; border-color: #eee; } cursor: pointer; } /* Keep Leaflet zoom controls below the status bar on the recording map. */ -.rec-map-wrap .leaflet-top { margin-top: var(--safe-area-top, 0px); } -/* Status-bar backdrop. The recording map is edge-to-edge (draws behind the system - status bar), so the bar had no backing and its icons were unreadable over the map - tiles. Paint a solid strip the height of the top inset — like the header on every - other screen — so the status bar stays visible. Controls already start below it. */ -.rec-map-wrap::before { - content: ""; - position: absolute; - top: 0; left: 0; right: 0; - height: var(--safe-area-top, 0px); - background: var(--surface); - z-index: 1100; - pointer-events: none; -} +/* The top strip (.rec-topbar) now sits above the map and already backs the status bar, so the + map no longer draws under the status bar — no backdrop strip, and controls start at the map top. */ +.rec-map-wrap .leaflet-top { margin-top: 0; } .rec-screen.rot-90 .rec-map-wrap::before, .rec-screen.rot-270 .rec-map-wrap::before { display: none; } .rec-compass-arrow { position: absolute; inset: 0; transition: transform 0.12s linear; } @@ -1411,14 +1400,15 @@ body.dark-map .poi-badge { background: #111; border-color: #eee; } color: var(--on-surface); } -/* Top strip (external info): training time (left), temperature (centre), clock (right). */ +/* Top strip (external info): training time (left), temperature (centre), clock (right). + Same big digits as the bottom stats (.rec-val). */ .rec-topbar { - display: flex; justify-content: space-between; align-items: center; - padding: 6px 16px; padding-top: calc(6px + var(--safe-area-top)); - background: var(--surface); border-bottom: 1px solid var(--border); - font-size: 15px; font-weight: 600; color: var(--on-surface); + display: flex; justify-content: space-between; align-items: baseline; + padding: 4px 16px; padding-top: calc(4px + var(--safe-area-top)); + background: var(--surface); } -.rec-topbar .rec-top-mid { color: var(--muted); font-weight: 500; } +.rec-topbar .rec-top { font-size: 27px; font-weight: 700; color: var(--on-surface); } +.rec-topbar .rec-top-mid { color: var(--muted); } /* Status line now stands alone under the stats (no longer stealing the buttons' width). */ .rec-panel > .rec-status { font-size: 12px; color: var(--muted); text-align: center; padding: 0 12px 4px; min-height: 14px; } From 69b36be59124d10ff14c5ef727f280b37bf2b86b Mon Sep 17 00:00:00 2001 From: 4aiman Date: Sat, 17 Oct 2026 14:35:54 +0300 Subject: [PATCH 60/65] Record buttons equal width (border-box + uniform padding); HR strap charge as a background fill on the HR toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Стоп was narrower: buttons had different horizontal padding and weren't border-box, so flex-basis:0 didn't equalize them. Force box-sizing + one padding on all three .rec-bottom buttons. - HR strap battery now paints the HR toggle's background bottom-up 0-100% (--bat), coloured green/amber/red by level (setHrBatteryFill), instead of a number; cleared when HR is toggled off. --- frontend/css/styles.css | 18 ++++++++---------- frontend/js/main.js | 12 +++++++++++- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/frontend/css/styles.css b/frontend/css/styles.css index 3ab245f..8bdfea5 100644 --- a/frontend/css/styles.css +++ b/frontend/css/styles.css @@ -726,10 +726,12 @@ select { .workout-screen .btn-primary, .workout-screen .btn-secondary { margin-top: 8px; width: 100%; } /* HR on/off toggle on the record screen (replaces the wind button): grey = off, coloured = on. */ -.rec-hr-toggle { color: #888; display: inline-flex; align-items: center; justify-content: center; gap: 3px; padding: 6px 10px; } +.rec-hr-toggle { color: #888; display: inline-flex; align-items: center; justify-content: center; gap: 4px; + /* Strap battery as a background fill, bottom-up, 0-100% (--bat), coloured by level (--bat-color). */ + background-image: linear-gradient(to top, var(--bat-color, transparent) var(--bat, 0%), transparent var(--bat, 0%)); } .rec-hr-toggle svg { display: block; } -.rec-hr-toggle.active { color: #fff; background: #ef5350; } /* on = white icon+bpm on solid red */ -.rec-hr-num { font-size: 14px; font-weight: 700; min-width: 0; } +.rec-hr-toggle.active { color: #fff; } /* HR on = white icon + bpm (over the battery fill) */ +.rec-hr-num { font-size: 15px; font-weight: 700; min-width: 0; } .rec-hr-num:empty { display: none; } /* Progress fill for long-running action buttons: the background fills left-to-right by @@ -1436,13 +1438,9 @@ body.dark-map .poi-badge { background: #111; border-color: #eee; } background: var(--surface); } -/* All three controls (HR toggle, pause, stop) share the width equally — no clustering right. */ -.rec-bottom > button { flex: 1 1 0; } - -.rec-bottom .gps-rec { - padding: 12px 0; - flex-shrink: 1; -} +/* All three controls (HR toggle, pause, stop) share the width equally — no clustering right. + border-box + one padding so the different per-button paddings don't make them unequal. */ +.rec-bottom > button { flex: 1 1 0; box-sizing: border-box; min-width: 0; padding: 12px 8px; } .gps-header { padding: 10px 12px; diff --git a/frontend/js/main.js b/frontend/js/main.js index 618d861..a49c154 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -1182,6 +1182,14 @@ function setBtnProgress(el, pct) { el.style.setProperty("--pct", Math.max(0, Math.min(100, Math.round(pct))) + "%"); } +// Show the HR strap charge as a background fill on the HR toggle (0-100%, coloured by level). +function setHrBatteryFill(el, bat) { + if (!el) return; + if (bat == null || bat < 0 || bat > 100) { el.style.removeProperty("--bat"); el.style.removeProperty("--bat-color"); return; } + el.style.setProperty("--bat", bat + "%"); + el.style.setProperty("--bat-color", bat > 40 ? "rgba(76,175,80,0.5)" : bat > 15 ? "rgba(255,183,77,0.55)" : "rgba(239,83,80,0.6)"); +} + // Before a recording, nudge the user to turn on what it needs: Bluetooth (for the HR strap) and // location (for the track). Can't silently toggle them on modern Android, so prompt + act: BT via // the system enable dialog, location via the location-settings screen. Declining is fine, and the @@ -5082,7 +5090,9 @@ async function renderRecordingInline(container) { if (hrEl) { hrEl.textContent = reading.hr; hrEl.className = "rec-val " + hrZoneClass(reading.hr); } let hrNum = document.querySelector("#rec-hr-num"); // live HR on the toggle button itself if (hrNum) hrNum.textContent = reading.hr; + setHrBatteryFill(document.querySelector("#rec-hr-toggle"), GPS.recording.currentBattery); // strap charge }; + setHrBatteryFill(container.querySelector("#rec-hr-toggle"), GPS.recording.currentBattery); // on render, if already known // Timer — excludes pauses, starts only when recording let timer = setInterval(() => { @@ -5138,7 +5148,7 @@ async function renderRecordingInline(container) { hrToggle.disabled = true; await GPS.setHrOn(!GPS.recording.hrOn); hrToggle.classList.toggle("active", !!GPS.recording.hrOn); - if (!GPS.recording.hrOn) { let n = container.querySelector("#rec-hr-num"); if (n) n.textContent = ""; } // off -> icon only + if (!GPS.recording.hrOn) { let n = container.querySelector("#rec-hr-num"); if (n) n.textContent = ""; setHrBatteryFill(hrToggle, null); } // off -> icon only, no fill hrToggle.disabled = false; }; From 87a7fccfcc8c922685a94b799e5ba20553a2d8cf Mon Sep 17 00:00:00 2001 From: 4aiman Date: Sat, 17 Oct 2026 14:43:57 +0300 Subject: [PATCH 61/65] =?UTF-8?q?Record=20screen:=20remove=20the=20status?= =?UTF-8?q?=20line=20+=20its=20black=20bar,=20tighten=20button=20padding,?= =?UTF-8?q?=20move=20=E2=9B=94/=E2=86=AA=20off=20the=20zoom=20controls?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop the status line (and any text it showed) from the record screen; keep a no-op statusEl sink so existing writes don't crash. Removes the empty black bar above the buttons. Tighter .rec-bottom padding. - The nogo (⛔) and reroute (↪) buttons had no position class, so they piled at the map top-left over the zoom controls. Give them stack slots. --- frontend/css/styles.css | 12 +++++++----- frontend/js/main.js | 7 +++---- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/frontend/css/styles.css b/frontend/css/styles.css index 8bdfea5..fbc4569 100644 --- a/frontend/css/styles.css +++ b/frontend/css/styles.css @@ -1275,6 +1275,10 @@ select { .rec-route-toggle { right: 8px; bottom: 200px; } .rec-dark-toggle { right: 8px; bottom: 238px; } .rec-follow-btn { right: 8px; bottom: 276px; font-size: 18px; } +/* nogo (⛔) and reroute (↪) had no position class — they piled at the map's top-left over the + zoom controls. Give them slots in the right-hand stack. */ +.rec-nogo-btn { right: 8px; bottom: 314px; } +.rec-reroute-btn { right: 8px; bottom: 352px; } /* Dark map: invert ONLY the tile layer (overlays — track, route, roads, puck — keep their colours). User-tuned filter on OSM standard tiles. Applies to every Leaflet map. */ body.dark-map .leaflet-tile-pane { filter: invert(1) hue-rotate(180deg) brightness(1.5); } @@ -1411,8 +1415,6 @@ body.dark-map .poi-badge { background: #111; border-color: #eee; } } .rec-topbar .rec-top { font-size: 27px; font-weight: 700; color: var(--on-surface); } .rec-topbar .rec-top-mid { color: var(--muted); } -/* Status line now stands alone under the stats (no longer stealing the buttons' width). */ -.rec-panel > .rec-status { font-size: 12px; color: var(--muted); text-align: center; padding: 0 12px 4px; min-height: 14px; } .rec-lbl { font-size: 10px; @@ -1432,9 +1434,9 @@ body.dark-map .poi-badge { background: #111; border-color: #eee; } .rec-bottom { display: flex; align-items: stretch; - gap: 10px; - padding: 8px 12px; - padding-bottom: calc(8px + var(--safe-area-bottom)); + gap: 8px; + padding: 6px 8px; + padding-bottom: calc(6px + var(--safe-area-bottom)); background: var(--surface); } diff --git a/frontend/js/main.js b/frontend/js/main.js index a49c154..51a7bd6 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -4605,7 +4605,6 @@ async function renderRecordingInline(container) {
0км/ч
—Высота
-
${GPS.recording.state === "waiting_gps" ? "Поиск GPS..." : GPS.recording.state === "ready" ? "Начните движение..." : ""}
@@ -4908,7 +4907,7 @@ async function renderRecordingInline(container) { async function refreshRecNogos() { recNogoLayer = await drawNogoAreas(map, recNogoLayer, refreshRecNogos); } refreshRecNogos(); let recNogoBtn = document.createElement("button"); - recNogoBtn.className = "rec-map-btn"; + recNogoBtn.className = "rec-map-btn rec-nogo-btn"; recNogoBtn.textContent = "⛔"; recNogoBtn.title = "Здесь нет проезда (отметить текущее место)"; recNogoBtn.onclick = async () => { @@ -4925,7 +4924,7 @@ async function renderRecordingInline(container) { // route or the recording. Tap again to rebuild from the new position; long-press to clear. let rerouteLayer = null; let rerouteBtn = document.createElement("button"); - rerouteBtn.className = "rec-map-btn"; + rerouteBtn.className = "rec-map-btn rec-reroute-btn"; rerouteBtn.textContent = "↪"; rerouteBtn.title = "Объехать: вернуться на маршрут за непроезжим местом"; rerouteBtn.onclick = async () => { @@ -5023,7 +5022,7 @@ async function renderRecordingInline(container) { let spdEl = document.querySelector("#rec-speed"); let timeEl = document.querySelector("#rec-time"); let elevEl = document.querySelector("#rec-elev"); - let statusEl = document.querySelector("#rec-status"); + let statusEl = { style: {} }; // status line removed from the UI; keep a no-op sink so the existing statusEl.* writes don't crash // Speed buffer for median let speedBuf = []; From 1df24000a3751fc56e139bb244d6d22365cff762 Mon Sep 17 00:00:00 2001 From: 4aiman Date: Sat, 17 Oct 2026 14:53:21 +0300 Subject: [PATCH 62/65] Track detail: HR chart as its own collapsible section + 'what if by HR' kcal comparison MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Speed/Elevation/Пульс are now collapsible
sections; the HR chart moved out of the Elevation block into its own 'Пульс' section (avg/max + chart), shown when the track has HR. - Калории now also shows the Keytel HR-based kcal as a COMPARISON when HR is present ('По ЧСС (сравнение)'). The activity balance keeps the physics/MET number unchanged - this is only to see the difference. --- frontend/js/main.js | 42 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/frontend/js/main.js b/frontend/js/main.js index 51a7bd6..8a971b3 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -5468,6 +5468,21 @@ async function showSessionDetail(sessionId) { : "—"; let kcalMin = stats.duration > 0 ? (stats.kcal / (stats.duration / 60)).toFixed(1) : "—"; + // HR-based kcal "what if" (Keytel) — comparison ONLY. The activity balance keeps the physics/MET + // number (don't disturb the established diet); here we just show what HR would give, if present. + let _t0hr = points.length ? new Date(points[0].ts).getTime() : 0; + let hrSamples = points.filter(p => p.heart_rate > 0).map(p => [(new Date(p.ts).getTime() - _t0hr) / 1000, p.heart_rate]); + let hasHr = hrSamples.length > 2; + let hrProfile = { + weightKg: session.rider_weight_kg ?? state.latestWeight?.weight_kg ?? state.profile?.weight_kg ?? 75, + age: BMR.getAge(parseInt(state.profile?.birth_year) || 1988), + sex: state.profile?.sex || "m", + heightCm: parseFloat(state.profile?.height_cm) || 178, + }; + let hrKcal = hasHr ? GPS.hrKcalNet(hrSamples, hrProfile) : null; + let hrAvg = hasHr ? Math.round(hrSamples.reduce((a, s) => a + s[1], 0) / hrSamples.length) : null; + let hrMax = hasHr ? Math.max(...hrSamples.map(s => s[1])) : null; + showModal(`

${actName}

ID: ${session.id}
@@ -5493,27 +5508,29 @@ async function showSessionDetail(sessionId) {
-
-
Скорость
+
+ Скорость
${stats.avgSpeed} км/чСредняя
${stats.maxSpeed} км/чМакс.
-
+
Калории
-
${session.total_kcal ?? stats.kcal} ккалВсего
+
${session.total_kcal ?? stats.kcal} ккал${hasHr ? "Физика (в баланс)" : "Всего"}
${kcalMin} ккал/минРасход
+ ${hasHr ? `
${hrKcal} ккалПо ЧСС (сравнение)
+
В баланс идёт «физика»; цифра по ЧСС (Keytel) - для сравнения.
` : ""}
${elevations.length > 0 ? ` -
-
Высота
+
+ Высота
${minElev} мМин.
${maxElev} мМакс.
@@ -5523,8 +5540,17 @@ async function showSessionDetail(sessionId) {
${stats.elevLoss} мСпуск
- -
` : ""} +
` : ""} + + ${hasHr ? ` +
+ Пульс +
+
${hrAvg}Средний
+
${hrMax}Макс.
+
+
+
` : ""} ${(stats.segments && stats.segments.length > 1) ? `
From 13c61f506a5afbaa69c5b1357a878278910692e9 Mon Sep 17 00:00:00 2001 From: 4aiman Date: Mon, 19 Oct 2026 13:43:55 +0300 Subject: [PATCH 63/65] BleHrService: survive Bluetooth being turned off (no native crash) Single Handler for all postDelayed work, cancelled on teardown; a BT ACTION_STATE_CHANGED receiver that cleanly stops the service on STATE_OFF; try/catch around every native scan/connect/GATT call. Prevents the libc destroyed-mutex abort when the adapter is disabled mid-connection. --- PROJECT_MAP.md | 2 +- .../main/java/dev/baldav/app/BleHrService.kt | 170 +++++++++++------- 2 files changed, 105 insertions(+), 67 deletions(-) diff --git a/PROJECT_MAP.md b/PROJECT_MAP.md index fe71580..006451f 100644 --- a/PROJECT_MAP.md +++ b/PROJECT_MAP.md @@ -53,7 +53,7 @@ |------|-------|------------| | `MainActivity.kt` | ~300 | FileObserver IPC, seed DB, safe insets, SAF, BLE commands, OTA check | | `GpsRecorderService.kt` | ~530 | Foreground service GPS, WakeLock, JSONL (один файл на сессию до Стоп, идемпотентный onStartCommand при START_STICKY-перезапуске ОС; `bindSessionIfPending()` дописывает id сессии в имя файла `_s` по `gps_session.txt`), 50Hz акселерометр/гироскоп, dead reckoning, raw sensor files | -| `BleHrService.kt` | ~340 | Foreground service BLE HR, FitPro protocol, GATT queue, scan/connect. START_NOT_STICKY; скан не нашёл адрес -> stopSelf (не держит радио). Подключение инициируется ТОЛЬКО вручную (кнопка в настройках), не при старте записи | +| `BleHrService.kt` | ~390 | Foreground service BLE HR, FitPro protocol, GATT queue, scan/connect. START_NOT_STICKY; скан не нашёл адрес -> stopSelf (не держит радио). Подключение инициируется ТОЛЬКО вручную (кнопка в настройках), не при старте записи. Выключение BT в любой момент не роняет app: единый `handler` (все postDelayed, снимаются разом), `BroadcastReceiver` на `ACTION_STATE_CHANGED` -> на STATE_OFF чистая остановка (disconnect+stopSelf), все нативные BLE-вызовы (scan/stopScan/connectGatt/writeCharacteristic/discoverServices) в try/catch | | `generated/*.kt` | ~1100 | Tauri-сгенерированные: WebView, IPC, permissions | ### Инструменты (`tools/`) diff --git a/src-tauri/gen/android/app/src/main/java/dev/baldav/app/BleHrService.kt b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/BleHrService.kt index 46b1506..fb9ec27 100644 --- a/src-tauri/gen/android/app/src/main/java/dev/baldav/app/BleHrService.kt +++ b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/BleHrService.kt @@ -7,10 +7,15 @@ import android.app.PendingIntent import android.app.Service import android.bluetooth.* import android.bluetooth.le.* +import android.content.BroadcastReceiver +import android.content.Context import android.content.Intent +import android.content.IntentFilter import android.content.pm.PackageManager import android.os.Build +import android.os.Handler import android.os.IBinder +import android.os.Looper import android.os.ParcelUuid import android.util.Log import java.io.File @@ -59,11 +64,35 @@ class BleHrService : Service() { private var fitproWriteChar: BluetoothGattCharacteristic? = null private var fitproGatt: BluetoothGatt? = null + // One handler for all postDelayed work, so teardown can cancel every pending callback at once + // (a stray callback touching a GATT/scanner after the adapter is off crashes the native BT stack). + private val handler = Handler(Looper.getMainLooper()) + + // When the user turns Bluetooth off, stop touching GATT immediately and shut the service down. + private val btStateReceiver = object : BroadcastReceiver() { + override fun onReceive(ctx: Context?, intent: Intent?) { + if (intent?.action != BluetoothAdapter.ACTION_STATE_CHANGED) return + val state = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR) + if (state == BluetoothAdapter.STATE_TURNING_OFF || state == BluetoothAdapter.STATE_OFF) { + Log.d(TAG, "Bluetooth turning off - stopping BLE service") + handler.removeCallbacksAndMessages(null) + disconnect() + writeData("""{"status":"bt_off","ts":${System.currentTimeMillis()/1000}}""") + stopSelf() + } + } + } + + private fun adapter(): BluetoothAdapter? = + (getSystemService(BLUETOOTH_SERVICE) as? android.bluetooth.BluetoothManager)?.adapter + override fun onBind(intent: Intent?): IBinder? = null override fun onCreate() { super.onCreate() createNotificationChannel() + try { registerReceiver(btStateReceiver, IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED)) } + catch (e: Exception) { Log.e(TAG, "register BT receiver: ${e.message}") } } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { @@ -138,10 +167,10 @@ class BleHrService : Service() { } } - scanner.startScan(callback) + try { scanner.startScan(callback) } catch (e: Exception) { Log.e(TAG, "startScan: ${e.message}"); writeScanResults("[]"); stopSelf(); return } // Stop after 5 seconds - android.os.Handler(mainLooper).postDelayed({ - scanner.stopScan(callback) + handler.postDelayed({ + try { scanner.stopScan(callback) } catch (e: Exception) { Log.e(TAG, "stopScan: ${e.message}") } writeScanResults("[${results.joinToString(",")}]") Log.d(TAG, "Scan complete: ${results.size} devices") // Don't stopSelf() — connect intent may follow immediately @@ -174,8 +203,9 @@ class BleHrService : Service() { override fun onScanResult(callbackType: Int, result: ScanResult) { if (result.device.address == address) { Log.d(TAG, "Found target device in scan, connecting...") - scanner.stopScan(this) - gatt = result.device.connectGatt(this@BleHrService, false, gattCallback, BluetoothDevice.TRANSPORT_LE) + try { scanner.stopScan(this) } catch (e: Exception) { Log.e(TAG, "stopScan: ${e.message}") } + try { gatt = result.device.connectGatt(this@BleHrService, false, gattCallback, BluetoothDevice.TRANSPORT_LE) } + catch (e: Exception) { Log.e(TAG, "connectGatt: ${e.message}") } } } override fun onScanFailed(errorCode: Int) { @@ -183,10 +213,10 @@ class BleHrService : Service() { directConnect(adapter, address) } } - scanner.startScan(scanCallback) + try { scanner.startScan(scanCallback) } catch (e: Exception) { Log.e(TAG, "startScan: ${e.message}"); stopSelf(); return } // Timeout: if device not found in 5 seconds, try direct connect - android.os.Handler(mainLooper).postDelayed({ - scanner.stopScan(scanCallback) + handler.postDelayed({ + try { scanner.stopScan(scanCallback) } catch (e: Exception) { Log.e(TAG, "stopScan: ${e.message}") } if (gatt == null) { // Not seen in the scan → the band isn't nearby/on. Do NOT hold a GATT connect to // an absent device (that keeps the radio busy and drains the battery). Give up; @@ -199,9 +229,12 @@ class BleHrService : Service() { } private fun directConnect(adapter: BluetoothAdapter, address: String) { - val device = adapter.getRemoteDevice(address) - Log.d(TAG, "Direct connecting to $address ($deviceType)") - gatt = device.connectGatt(this, false, gattCallback, BluetoothDevice.TRANSPORT_LE) + if (!adapter.isEnabled) { Log.e(TAG, "directConnect: adapter off"); stopSelf(); return } + try { + val device = adapter.getRemoteDevice(address) + Log.d(TAG, "Direct connecting to $address ($deviceType)") + gatt = device.connectGatt(this, false, gattCallback, BluetoothDevice.TRANSPORT_LE) + } catch (e: Exception) { Log.e(TAG, "directConnect: ${e.message}"); stopSelf() } } private val gattCallback = object : BluetoothGattCallback() { @@ -209,7 +242,7 @@ class BleHrService : Service() { if (newState == BluetoothProfile.STATE_CONNECTED) { Log.d(TAG, "Connected, discovering services...") updateNotification("Подключён, поиск сервисов...") - g.discoverServices() + try { g.discoverServices() } catch (e: Exception) { Log.e(TAG, "discoverServices: ${e.message}") } } else if (newState == BluetoothProfile.STATE_DISCONNECTED) { Log.d(TAG, "Disconnected") isRunning = false @@ -290,24 +323,24 @@ class BleHrService : Service() { if (service == null) { Log.e(TAG, "HR service not found"); return } val char = service.getCharacteristic(HR_MEASUREMENT_UUID) if (char == null) { Log.e(TAG, "HR characteristic not found"); return } - g.setCharacteristicNotification(char, true) - val desc = char.getDescriptor(CCCD_UUID) - if (desc != null) { - desc.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE - g.writeDescriptor(desc) - } + try { + g.setCharacteristicNotification(char, true) + val desc = char.getDescriptor(CCCD_UUID) + if (desc != null) { + desc.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE + g.writeDescriptor(desc) + } + } catch (e: Exception) { Log.e(TAG, "HR notify setup: ${e.message}"); return } Log.d(TAG, "Standard HR setup complete") // Strap battery (0x180F/0x2A19): read once after the HR subscribe settles, then every 60s. - android.os.Handler(mainLooper).also { h -> - val readBat = object : Runnable { - override fun run() { - if (!isRunning) return - readBattery(g) - h.postDelayed(this, 60000) - } + val readBat = object : Runnable { + override fun run() { + if (!isRunning) return + readBattery(g) + handler.postDelayed(this, 60000) } - h.postDelayed(readBat, 2500) } + handler.postDelayed(readBat, 2500) } private fun readBattery(g: BluetoothGatt) { @@ -334,7 +367,7 @@ class BleHrService : Service() { fun onGattOperationComplete() { gattBusy = false - android.os.Handler(mainLooper).postDelayed({ processGattQueue() }, 100) + handler.postDelayed({ processGattQueue() }, 100) } private fun setupFitPro(g: BluetoothGatt) { @@ -347,16 +380,18 @@ class BleHrService : Service() { // Queue all operations: enable notify → PAIR → HR_SWITCH → REALTIME → MEASURE enqueueGatt { - g.setCharacteristicNotification(notifyChar, true) - val desc = notifyChar.getDescriptor(CCCD_UUID) - if (desc != null) { - desc.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE - g.writeDescriptor(desc) - Log.d(TAG, "FitPro: writing CCCD descriptor") - } else { - Log.d(TAG, "FitPro: no CCCD, skipping") - onGattOperationComplete() - } + try { + g.setCharacteristicNotification(notifyChar, true) + val desc = notifyChar.getDescriptor(CCCD_UUID) + if (desc != null) { + desc.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE + g.writeDescriptor(desc) + Log.d(TAG, "FitPro: writing CCCD descriptor") + } else { + Log.d(TAG, "FitPro: no CCCD, skipping") + onGattOperationComplete() + } + } catch (e: Exception) { Log.e(TAG, "FitPro notify setup: ${e.message}"); onGattOperationComplete() } } enqueueGatt { Log.d(TAG, "FitPro: PAIR"); writeCommand(g, writeChar, FITPRO_PAIR) } enqueueGatt { Log.d(TAG, "FitPro: HR_SWITCH"); writeCommand(g, writeChar, FITPRO_HR_SWITCH_ON) } @@ -369,37 +404,36 @@ class BleHrService : Service() { // Re-trigger measurement every 25 seconds fitproWriteChar = writeChar fitproGatt = g - android.os.Handler(mainLooper).also { h -> - val retrigger = object : Runnable { - override fun run() { - if (!isRunning) return - Log.d(TAG, "FitPro: re-trigger measurement") - fitproGatt?.let { gg -> fitproWriteChar?.let { wc -> - writeCommand(gg, wc, FITPRO_MEASURE_START) - android.os.Handler(mainLooper).postDelayed({ - writeCommand(gg, wc, FITPRO_MEASURE_OLD) - }, 300) - }} - h.postDelayed(this, 25000) - } + val retrigger = object : Runnable { + override fun run() { + if (!isRunning) return + Log.d(TAG, "FitPro: re-trigger measurement") + fitproGatt?.let { gg -> fitproWriteChar?.let { wc -> + writeCommand(gg, wc, FITPRO_MEASURE_START) + handler.postDelayed({ + writeCommand(gg, wc, FITPRO_MEASURE_OLD) + }, 300) + }} + handler.postDelayed(this, 25000) } - h.postDelayed(retrigger, 25000) } + handler.postDelayed(retrigger, 25000) } private fun writeCommand(g: BluetoothGatt, char: BluetoothGattCharacteristic, data: ByteArray) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - g.writeCharacteristic(char, data, BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE) - // NO_RESPONSE may not trigger onCharacteristicWrite, so auto-complete after 200ms - android.os.Handler(mainLooper).postDelayed({ onGattOperationComplete() }, 200) - } else { - @Suppress("DEPRECATION") - char.value = data - char.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE - @Suppress("DEPRECATION") - g.writeCharacteristic(char) - android.os.Handler(mainLooper).postDelayed({ onGattOperationComplete() }, 200) - } + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + g.writeCharacteristic(char, data, BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE) + } else { + @Suppress("DEPRECATION") + char.value = data + char.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE + @Suppress("DEPRECATION") + g.writeCharacteristic(char) + } + } catch (e: Exception) { Log.e(TAG, "writeCommand: ${e.message}") } + // NO_RESPONSE may not trigger onCharacteristicWrite, so auto-complete after 200ms + handler.postDelayed({ onGattOperationComplete() }, 200) } private fun parseStandardHr(data: ByteArray) { @@ -467,10 +501,13 @@ class BleHrService : Service() { } private fun disconnect() { - gatt?.disconnect() - gatt?.close() - gatt = null isRunning = false + handler.removeCallbacksAndMessages(null) // no stray callback may touch GATT after this + try { gatt?.disconnect() } catch (e: Exception) { Log.e(TAG, "gatt.disconnect: ${e.message}") } + try { gatt?.close() } catch (e: Exception) { Log.e(TAG, "gatt.close: ${e.message}") } + gatt = null + fitproGatt = null + fitproWriteChar = null } private fun createNotificationChannel() { @@ -499,6 +536,7 @@ class BleHrService : Service() { } override fun onDestroy() { + try { unregisterReceiver(btStateReceiver) } catch (e: Exception) { /* not registered / already gone */ } disconnect() super.onDestroy() } From eeca9a7d9456f1cb09abf53d633b63715414f48b Mon Sep 17 00:00:00 2001 From: 4aiman Date: Mon, 19 Oct 2026 13:43:55 +0300 Subject: [PATCH 64/65] HR connect: one BT-enable prompt per start, only on user action setHrOn gains an 'interactive' flag; it pops the OS enable-Bluetooth dialog only on a user tap (record-screen toggle). Auto-calls at recording start no longer re-request enable after the single ensureSystemReady prompt, so a decline is not immediately followed by the system dialog and is not re-asked until the next start. --- ...26-10-19_bt-off-crash-and-single-prompt.md | 54 +++++++++++++++++++ frontend/js/gps.js | 17 ++++-- frontend/js/main.js | 2 +- 3 files changed, 69 insertions(+), 4 deletions(-) create mode 100644 docs/session-logs/2026-10-19_bt-off-crash-and-single-prompt.md diff --git a/docs/session-logs/2026-10-19_bt-off-crash-and-single-prompt.md b/docs/session-logs/2026-10-19_bt-off-crash-and-single-prompt.md new file mode 100644 index 0000000..60cb6ed --- /dev/null +++ b/docs/session-logs/2026-10-19_bt-off-crash-and-single-prompt.md @@ -0,0 +1,54 @@ +# Сессия 2026-10-19 - краш при выключении Bluetooth + один запрос на включение вместо нескольких + +## Контекст +Пользователь: "при выключении блютуса балдав падает", и попапы про включение BT лезут +повторно ("если запретил - не просить до нового трекинга; просить ОДИН раз, а не 20"). +Действовали по утверждённому плану `~/.claude/plans/merry-discovering-dream.md` (финальный +вариант; сохранён в docs/plans не был - план короткий, воспроизведён в этом логе). + +## Разбор (две независимые причины) +1. Краш. Крэш-буфер телефона: нативный abort libc FORTIFY "pthread_mutex_lock called on a + destroyed mutex" (pid 19579), не Kotlin-исключение. Причина: живой GATT/scanner трогают + уже после выключения BT-адаптера. В `BleHrService.kt` не было приёмника состояния BT, а + отложенные `scanner.stopScan`, fitpro-ретриггер и периодические хендлеры продолжали дёргать + GATT. Точный фрейм без tombstone не прибивался - фикс сделан defense-in-depth (решение + пользователя: сразу защита + проверка воспроизведением). +2. Двойной запрос. `ensureSystemReady` (main.js) спрашивает один раз через appConfirm, но + `startRecording` авто-зовёт `setHrOn(true)`, а тот при BT off САМ дёргал нативный + `ble_request_enable` (gps.js:899), игнорируя отказ -> второе (системное) окно сразу после + отказа, и так каждый старт. + +## Сделано (2 коммита) +Коммит 1 (краш, Kotlin `BleHrService.kt`): +- Единый `handler = Handler(Looper.getMainLooper())`; все postDelayed через него; + `handler.removeCallbacksAndMessages(null)` в `disconnect()` и `onDestroy()`. +- `BroadcastReceiver` на `BluetoothAdapter.ACTION_STATE_CHANGED`: на STATE_TURNING_OFF/OFF - + снять хендлеры, disconnect, `writeData(status:bt_off)`, stopSelf. Снятие в onDestroy в try. +- try/catch на всех нативных BLE-вызовах: startScan/stopScan/connectGatt/discoverServices/ + writeCharacteristic/setCharacteristicNotification/writeDescriptor; directConnect проверяет + adapter.isEnabled. + +Коммит 2 (попапы, JS): +- `GPS.setHrOn(on, interactive = false)` (gps.js): системное окно `ble_request_enable` только + при `interactive === true`. Авто-режим при BT off: hrOn=false, не подключаться, без окна. +- Авто-вызовы на старте (gps.js 1433 зарядка, 1462 трек) остаются `setHrOn(true)` (без + interactive) -> единственный авто-запрос при старте = один appConfirm в ensureSystemReady. +- Ручной тоггл ЧСС (main.js:5148) -> `setHrOn(..., true)`: одно системное окно на тап. + Ручное "Подключить" (настройки) не трогали - оно и так user-initiated. + +## Проверка +Синтаксис JS ок (node -c обоих файлов). На телефоне (сборка `./build.sh -i -d android`): +1) ЧСС подключён, выключить BT в записи -> app НЕ падает, ЧСС гаснет, трек идёт; в logcat + `BleHR` виден STATE_OFF и чистый stop, нет нового FATAL/FORTIFY. +2) BT off, старт трека -> ровно одно окно "Включить Bluetooth?"; отказ -> системное окно НЕ + появляется, до конца записи повторов нет; новый старт -> снова один вопрос. +3) Тап по тогглу ЧСС при BT off -> одно системное окно. +4) Регрессии рядом: зарядка с ЧСС считает kcal; "Не спрашивать" гасят свои запросы; + "Подключить" в настройках работает на ходу. + +## Отдельно (read-only, без правок) +Разобрал `wind_marks` в 5 треках из 228: отметки встречного ветра нигде не используются +(коммент в коде - "ground truth для оценки ветра из трека", которой в коде нет), и +эмпирически сигнал противоречив (в 2 из 4 велотреков "в ветер" скорость даже выше). Вывод: +пользы нет; кнопки ветра уже нет, новые треки пишут пустой wind_marks, помех нет - оставили +как есть. diff --git a/frontend/js/gps.js b/frontend/js/gps.js index 41fc6cf..1e6e8db 100644 --- a/frontend/js/gps.js +++ b/frontend/js/gps.js @@ -884,7 +884,12 @@ GPS._stopHrMonitor = function() { // Turn HR on/off during a recording (the toggle on the record screen). ON connects to the // paired strap (Kotlin scans ~5s and stops cleanly if it's absent — so no constant scanning / // drain when the strap isn't there) and polls; OFF stops the poll and disconnects. -GPS.setHrOn = async function(on) { +// +// `interactive` = the user tapped something (the record-screen toggle, "Подключить"). Only then +// may we pop the OS "turn on Bluetooth?" dialog when BT is off. Auto-calls at recording start +// pass interactive=false: if BT is off they just leave HR off (no second popup after the single +// start-time prompt in ensureSystemReady), and a later manual connect still works. +GPS.setHrOn = async function(on, interactive = false) { GPS.recording.hrOn = !!on; if (!on) { GPS._stopHrMonitor(); @@ -896,8 +901,14 @@ GPS.setHrOn = async function(on) { if (!addr) { GPS.recording.hrOn = false; return false; } try { const bt = await window.__TAURI__.core.invoke("ble_check_bluetooth"); - if (bt === "off") { try { await window.__TAURI__.core.invoke("ble_request_enable"); } catch {} } - else if (bt !== "no_adapter") { try { await window.__TAURI__.core.invoke("ble_connect", { address: addr, deviceType: type || "hr_standard" }); } catch {} } + if (bt === "off") { + // Auto path never nags; only a user tap asks the OS to enable BT. Either way we can't + // connect with the radio off — leave HR off so the toggle shows grey, not a fake "on". + if (interactive) { try { await window.__TAURI__.core.invoke("ble_request_enable"); } catch {} } + GPS.recording.hrOn = false; + return false; + } + if (bt !== "no_adapter") { try { await window.__TAURI__.core.invoke("ble_connect", { address: addr, deviceType: type || "hr_standard" }); } catch {} } } catch {} try { await GPS._startHrMonitor(); } catch {} return true; diff --git a/frontend/js/main.js b/frontend/js/main.js index 8a971b3..9b83673 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -5145,7 +5145,7 @@ async function renderRecordingInline(container) { let hrToggle = container.querySelector("#rec-hr-toggle"); if (hrToggle) hrToggle.onclick = async () => { hrToggle.disabled = true; - await GPS.setHrOn(!GPS.recording.hrOn); + await GPS.setHrOn(!GPS.recording.hrOn, true); // user tap → may ask OS to enable BT (once) hrToggle.classList.toggle("active", !!GPS.recording.hrOn); if (!GPS.recording.hrOn) { let n = container.querySelector("#rec-hr-num"); if (n) n.textContent = ""; setHrBatteryFill(hrToggle, null); } // off -> icon only, no fill hrToggle.disabled = false; From 802b2cc6e9eca3a5ebd1992da979f0efb6f1efa3 Mon Sep 17 00:00:00 2001 From: 4aiman Date: Mon, 19 Oct 2026 14:12:04 +0300 Subject: [PATCH 65/65] Bump version (build.sh output) --- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index ac224f4..c5a960e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -139,7 +139,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "baldav" -version = "0.1.474" +version = "0.1.496" dependencies = [ "base64 0.22.1", "btleplug", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c5271e3..e83bd7f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "baldav" -version = "0.1.475" +version = "0.1.496" edition = "2021" [lib] diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 71ac2a1..d021293 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/nicegui/tauri-v2/refs/heads/main/tooling/cli/schema.json", "productName": "BalDav", - "version": "0.1.475", + "version": "0.1.496", "identifier": "dev.baldav.app", "build": { "frontendDist": "../frontend"