From cfadad4690973af43c071d3ba3ad861a2529391e Mon Sep 17 00:00:00 2001 From: 4aiman Date: Fri, 18 Sep 2026 00:08:25 +0300 Subject: [PATCH] Merge rides: long-press to multi-select tracks and combine them into one (reversible via orig_session_id) --- PROJECT_MAP.md | 1 + README.md | 1 + docs/session-logs/2026-09-18_merge-rides.md | 41 +++++++++++ frontend/css/styles.css | 24 +++++++ frontend/js/db.js | 37 ++++++++++ frontend/js/main.js | 75 ++++++++++++++++++++- 6 files changed, 176 insertions(+), 3 deletions(-) create mode 100644 docs/session-logs/2026-09-18_merge-rides.md diff --git a/PROJECT_MAP.md b/PROJECT_MAP.md index cb02235..e9de6fd 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) +- Объединение поездок: в `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`) - Роутинг по проезженному: 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 12c35d3..10687aa 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`, кэшируется. +- **Объединение поездок** - долгий тап по треку в истории включает выделение, тап отмечает ещё; когда выбрано 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-18_merge-rides.md b/docs/session-logs/2026-09-18_merge-rides.md new file mode 100644 index 0000000..f62c0ee --- /dev/null +++ b/docs/session-logs/2026-09-18_merge-rides.md @@ -0,0 +1,41 @@ +# Сессия 2026-09-18 - объединение поездок (склейка треков) + +## Контекст +Трек 16 сен: реально 5 заездов, но первый оказался в отдельной сессии (запись +несколькими кусками). Нужна склейка: долгий тап = выделение, 2+ = объединить, +разрыв >5 мин по времени - предупреждать но не мешать, только склейка (разрез/вырез +были проблемными, позже). После склейки «Разбор повторов» должен увидеть все 5. + +## Сделано +- Миграция: `gps_points.orig_session_id TEXT` - исходная сессия точки (для будущего + разделения; склейка обратима без потери данных). +- `DB.mergeGpsSessions(idStrs)` (db.js): сессии сортируются по времени, target = самая + ранняя; точки остальных переносятся (`session_id=target`, `orig_session_id=COALESCE(...)`), + end_time продлевается, лишние сессии удаляются. `seq=NULL` у объединённого трека - + иначе совпадающие seq (у каждой сессии были 1..N) перемешивают порядок в getGpsPoints. + Ids - строками с CAST в INTEGER (i64 corruption). +- UI (`renderGpsScreen`): долгий тап 500мс (touch+mouse) -> режим выделения (`.selected`, + плавающая `#gps-sel-bar`: «Выбрано N», «Объединить» (>=2), «Отмена»). Тап в режиме + переключает. Клик после long-press подавляется. Диалог `appConfirm` с пометкой об + обратимости; предупреждение о разрыве >5 мин. После склейки - `recalculateSession + (noDelete)`, тост, ре-рендер. +- CSS: `.gps-session-item.selected`, `.gps-sel-bar`, фикс `[hidden]` (правило `display:flex` + перебивало атрибут hidden - панель висела всегда). + +## Баги по пути (и фиксы) +- Панель выделения показывалась всегда: `.gps-sel-bar{display:flex}` перебивал `hidden`. + Фикс: `.gps-sel-bar[hidden]{display:none}`. +- После склейки в приложении: участок 43 км, скорости 6745 км/ч. Причина: у трёх сессий + seq совпадали (1..N), `getGpsPoints` (ORDER BY seq) перемешивал треки. В node-тесте я + сортировал по ts и не поймал. Фикс: `seq=NULL` при склейке -> порядок по ts. + +## Проверка (десктоп Xvfb, подсажены 3 сессии 16 сен, БД восстановлена из бэкапа) +- Node: склейка 3 сессий -> detectRepeats 5 заездов, 1572м, [21.4,20.1,21.1,20.9,22.5], 105% + (по отдельности: две малые - «нет повторов», большая - 4). +- В приложении: долгий тап выделил 11:38 (рамка+фон, «Выбрано 1»), тап добрал 11:50/12:04 + («Выбрано 3»), «Объединить» -> диалог -> 3 сессии стали одной (11:38, 19.89 км, суммарно). + Открыл: «Разбор повторов» = 5 заездов, совпало с node, каденс 88-93, вердикт 105%. Скрины. +- Далее: сборка APK + проверка на телефоне (реальный трек 16 сен там же). + +## Не сделано (по просьбе - позже) +Разрезание и вырезание треков (были проблемы). Сейчас только склейка. diff --git a/frontend/css/styles.css b/frontend/css/styles.css index a3edd9b..770d259 100644 --- a/frontend/css/styles.css +++ b/frontend/css/styles.css @@ -1740,6 +1740,30 @@ body.dark-map .poi-badge { background: #111; border-color: #eee; } border-color: var(--primary); } +.gps-session-item.selected { + border-color: var(--primary); + background: color-mix(in srgb, var(--primary) 20%, var(--surface)); + box-shadow: inset 0 0 0 1px var(--primary); +} + +/* Floating action bar for multi-select merge (sits above the bottom tab bar) */ +.gps-sel-bar { + position: fixed; + left: 0; right: 0; + bottom: calc(56px + var(--safe-area-bottom)); + z-index: 90; + display: flex; + align-items: center; + gap: 10px; + padding: 10px 16px; + background: var(--surface); + border-top: 1px solid var(--border); + box-shadow: 0 -2px 8px rgba(0,0,0,0.3); +} +.gps-sel-bar[hidden] { display: none; } +.gps-sel-bar #gps-sel-count { flex: 1; font-size: 14px; } +.gps-sel-bar button { padding: 8px 14px; } + .gps-session-head { display: flex; justify-content: space-between; diff --git a/frontend/js/db.js b/frontend/js/db.js index f04263e..087f8c7 100644 --- a/frontend/js/db.js +++ b/frontend/js/db.js @@ -914,6 +914,41 @@ async function deleteGpsSession(id) { await execute("DELETE FROM gps_sessions WHERE id = ?1", [id]); } +// Merge several sessions into one. Points from every session move into the EARLIEST +// (by start time) session; each moved point keeps its original session in +// orig_session_id (so a future "split" can undo this), the target's end_time extends to +// the latest, and the other session rows are deleted. Ids are text (large i64 read back +// off-by-one), passed as strings and CAST to INTEGER for the id/session_id columns. +// Returns the surviving (target) session id_str. Caller recomputes stats afterwards. +async function mergeGpsSessions(idStrs) { + if (!Array.isArray(idStrs) || idStrs.length < 2) return null; + const sessions = []; + for (const id of idStrs) { const s = await getGpsSession(id); if (s) sessions.push(s); } + if (sessions.length < 2) return null; + const ord = (s) => (s.date || "1970-01-01") + "T" + (s.start_time || "00:00:00"); + sessions.sort((a, b) => ord(a) < ord(b) ? -1 : ord(a) > ord(b) ? 1 : 0); + const target = sessions[0]; + const targetId = target.id_str; + const endOf = (s) => (s.date || "1970-01-01") + "T" + (s.end_time || s.start_time || "00:00:00"); + const latestEnd = sessions.reduce((m, s) => endOf(s) > endOf(m) ? s : m, target).end_time; + // Tag the target's own points too, so orig_session_id is complete for a later split. + await execute("UPDATE gps_points SET orig_session_id = CAST(?1 AS TEXT) WHERE session_id = CAST(?1 AS INTEGER) AND orig_session_id IS NULL", [targetId]); + for (const s of sessions.slice(1)) { + await execute( + "UPDATE gps_points SET orig_session_id = COALESCE(orig_session_id, CAST(session_id AS TEXT)), session_id = CAST(?1 AS INTEGER) WHERE session_id = CAST(?2 AS INTEGER)", + [targetId, s.id_str] + ); + await execute("DELETE FROM gps_sessions WHERE id = CAST(?1 AS INTEGER)", [s.id_str]); + } + if (latestEnd) await execute("UPDATE gps_sessions SET end_time = ?1 WHERE id = CAST(?2 AS INTEGER)", [latestEnd, targetId]); + // Each source session numbered its points seq 1..N, so after the move the seq values + // collide and "ORDER BY seq" would interleave the tracks (measured: a 43 km "segment" + // and 6745 km/h passes). Clear seq so getGpsPoints falls back to timestamp order, which + // is the true order across the merged tracks. + await execute("UPDATE gps_points SET seq = NULL WHERE session_id = CAST(?1 AS INTEGER)", [targetId]); + return targetId; +} + async function importGpxSession(session, points) { await execute( `INSERT INTO gps_sessions (date, start_time, end_time, activity_type, @@ -1289,6 +1324,7 @@ window.DB.getGpsSessionsForDate = getGpsSessionsForDate; window.DB.getGpsSession = getGpsSession; window.DB.getGpsPoints = getGpsPoints; window.DB.deleteGpsSession = deleteGpsSession; +window.DB.mergeGpsSessions = mergeGpsSessions; window.DB.importGpxSession = importGpxSession; window.DB.saveGapFill = saveGapFill; window.DB.revertGapFill = revertGapFill; @@ -1516,6 +1552,7 @@ async function runMigrations() { if (!gpsCols.includes("lon_smoothed")) await execute("ALTER TABLE gps_points ADD COLUMN lon_smoothed REAL"); if (!gpsCols.includes("estimated")) await execute("ALTER TABLE gps_points ADD COLUMN estimated INTEGER DEFAULT 0"); // gap-fill (BRouter) points, reversible if (!gpsCols.includes("walk")) await execute("ALTER TABLE gps_points ADD COLUMN walk INTEGER DEFAULT 0"); // point is in a "walking" run inside a ride (excluded from ridden roads) + if (!gpsCols.includes("orig_session_id")) await execute("ALTER TABLE gps_points ADD COLUMN orig_session_id TEXT"); // set on merge = the session this point first belonged to (lets a future split undo the merge) // --- Cooking methods table --- await execute(`CREATE TABLE IF NOT EXISTS cooking_methods ( diff --git a/frontend/js/main.js b/frontend/js/main.js index 4aa2c58..38faa7a 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -2746,6 +2746,11 @@ async function renderGpsScreen(container) {
${sessions.length ? sessions.map(sessionCard).join("") : `
Нет записей
`}
+ `; @@ -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");