Merge rides: long-press to multi-select tracks and combine them into one (reversible via orig_session_id)

This commit is contained in:
4aiman 2026-09-18 00:08:25 +03:00
commit cfadad4690
6 changed files with 176 additions and 3 deletions

View file

@ -151,6 +151,7 @@
- Рецепты: showRecipeDetailModal, showRecipeEditorModal, showAddIngredientModal
- Тренировки: showSessionDetail (карта, графики скорости/высоты, секция «Сегменты» - сворачиваемый `<details>`-аккордеон, свёрнут по умолчанию, чтобы кнопки внизу были доступны; пересчёт, оверлей маршрута, панель де-джиттера 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

View file

@ -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, до десятков штук) свёрнут в аккордеон (`<details>`, свёрнут по умолчанию, счётчик в заголовке); тап разворачивает/сворачивает. Кнопки внизу (Похвастаться, Экспорт, Пересчитать, Удалить) остаются доступны без долгого скролла.

View file

@ -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 сен там же).
## Не сделано (по просьбе - позже)
Разрезание и вырезание треков (были проблемы). Сейчас только склейка.

View file

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

View file

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

View file

@ -2746,6 +2746,11 @@ async function renderGpsScreen(container) {
<div id="gps-session-list">${sessions.length ? sessions.map(sessionCard).join("") : `<div class="prod-hint">Нет записей</div>`}</div>
<button id="gps-more" class="btn-secondary" style="width:100%;margin-top:10px;${sessions.length < PAGE_SIZE ? "display:none" : ""}">Показать ещё</button>
</div>
<div id="gps-sel-bar" class="gps-sel-bar" hidden>
<span id="gps-sel-count">Выбрано 0</span>
<button id="gps-sel-merge" class="btn-primary" disabled>Объединить</button>
<button id="gps-sel-cancel" class="btn-secondary">Отмена</button>
</div>
</div>`;
@ -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");