Activity types (squats/jumprope/stretch/swim), road map, tile import, fixes

Activity types:
- New types: squats, jump rope, stretching, swimming pool
- MET-based kcal auto-calculation (6.0/12.3/2.3/8.0)
- Dynamic modal fields per type (sets/reps, distance, minutes)
- Fix: state.latestWeight is object {weight_kg}, not number

Road map:
- road_points table with 2m dedup, activity type (bike/walk)
- Rust rebuild_roads + get_road_points (bypass SQL plugin 28K limit)
- Segment detection by 100m gap, bike=blue walk=brown

Tile system:
- CSP fix: a.tile.openstreetmap.org matches *.tile pattern
- pack_tiles.js tool, import from file/router, ureq body limit bypass
- Tile errors: done(null,tile) prevents crash cascade

Other fixes:
- Manual routes: skip gap segmentation (synthetic timestamps)
- Draw route: own CSS class, not affected by landscape rotation
- Orientation lock button (A/L), hysteresis 65/35 degrees
- GPS placeholder only when state=waiting_gps
- Food amount inputmode=text for formula support (4*30)
- showModal onMount callback
This commit is contained in:
4aiman 2026-06-26 21:04:21 +03:00
commit 4ed6e6f039
6 changed files with 145 additions and 45 deletions

View file

@ -1062,6 +1062,15 @@ select {
.rec-panel {
flex-shrink: 0;
}
.draw-screen {
display: flex;
flex-direction: column;
height: 100%;
}
.draw-map {
flex: 1;
min-height: 200px;
}
.rec-map-wrap {
flex: 1;

View file

@ -476,11 +476,19 @@ async function getActivitiesForDate(date) {
return select("SELECT * FROM activity_log WHERE date = ?1 ORDER BY time ASC", [date]);
}
async function addActivity(date, time, type, desc, minutes, kcal) {
await execute(
"INSERT INTO activity_log (date, time, type, desc, minutes, kcal) VALUES (?1,?2,?3,?4,?5,?6)",
[date, time, type ?? "manual", desc, minutes ?? null, kcal ?? null]
);
async function addActivity(date, time, type, desc, minutes, kcal, opts) {
// Build column list dynamically — new columns may not exist yet on first run
const cols = ["date", "time", "type", "desc", "minutes", "kcal"];
const vals = [date, time, type ?? "manual", desc, minutes ?? null, kcal ?? null];
if (opts) {
const actCols = (await select("PRAGMA table_info(activity_log)")).map(c => c.name);
if (actCols.includes("activity_type")) { cols.push("activity_type"); vals.push(opts.activity_type ?? "manual"); }
if (actCols.includes("sets") && opts.sets) { cols.push("sets"); vals.push(opts.sets); }
if (actCols.includes("reps") && opts.reps) { cols.push("reps"); vals.push(opts.reps); }
if (actCols.includes("distance_m") && opts.distance_m) { cols.push("distance_m"); vals.push(opts.distance_m); }
}
const placeholders = cols.map((_, i) => `?${i + 1}`).join(",");
await execute(`INSERT INTO activity_log (${cols.join(",")}) VALUES (${placeholders})`, vals);
}
async function deleteActivity(id) {
@ -1544,6 +1552,16 @@ async function runMigrations() {
// Spatial index for fast 2m proximity checks
await execute(`CREATE INDEX IF NOT EXISTS idx_road_lat_lon ON road_points(lat, lon)`);
// Activity types: extend activity_log with structured fields
const actCols = (await select("PRAGMA table_info(activity_log)")).map(c => c.name);
if (!actCols.includes("activity_type")) {
await execute("ALTER TABLE activity_log ADD COLUMN activity_type TEXT");
await execute("UPDATE activity_log SET activity_type = 'manual' WHERE activity_type IS NULL");
}
if (!actCols.includes("sets")) await execute("ALTER TABLE activity_log ADD COLUMN sets INTEGER");
if (!actCols.includes("reps")) await execute("ALTER TABLE activity_log ADD COLUMN reps INTEGER");
if (!actCols.includes("distance_m")) await execute("ALTER TABLE activity_log ADD COLUMN distance_m REAL");
// Water content column for hydration tracking
if (!productCols.includes("water_pct")) {
await execute("ALTER TABLE products ADD COLUMN water_pct REAL");

View file

@ -9,6 +9,13 @@ const app = document.getElementById("app");
const UPDATE_BASE_URL = "https://4aiman.top/baldav";
const ACTIVITY_MET = { squats: 6.0, jumprope: 12.3, stretch: 2.3, swim: 8.0, manual: 3.0 };
const ACTIVITY_NAMES = { squats: "Приседания", jumprope: "Скакалка", stretch: "Растяжки", swim: "Бассейн", manual: "Другое" };
function activityKcal(type, minutes, weightKg) {
const met = ACTIVITY_MET[type] || 3.0;
return Math.round(met * 3.5 * weightKg * minutes / 200);
}
let state = {
screen: "today",
date: todayIso(),
@ -393,15 +400,21 @@ async function renderToday(container) {
<span class="log-kcal" style="color:var(--primary)">${s.avg_speed_kmh ? s.avg_speed_kmh.toFixed(1) + " км/ч" : ""}</span>
</li>`;
}).join("")}
${activities.map(a =>
`<li class="log-item">
${activities.map(a => {
const at = a.activity_type || "manual";
const name = ACTIVITY_NAMES[at] || a.desc || "—";
let detail = "";
if (at === "squats" && a.sets && a.reps) detail = `${a.sets}x${a.reps}, `;
if (at === "swim" && a.distance_m) detail = `${a.distance_m} м, `;
if (a.minutes) detail += `${a.minutes} мин`;
if (at === "manual" && a.desc) detail = esc(a.desc) + (a.minutes ? `, ${a.minutes} мин` : "");
return `<li class="log-item">
<span class="log-time">${a.time ?? ""}</span>
<span class="log-desc">${esc(a.desc ?? "—")}
${a.minutes ? `<small>${a.minutes} мин</small>` : ""}</span>
<span class="log-kcal">${a.kcal != null ? "−" + Math.round(a.kcal) + " кк" : ""}</span>
<span class="log-desc">${at !== "manual" ? name : ""}${detail ? ` <small>${detail}</small>` : ""}</span>
<span class="log-kcal">${a.kcal && !isNaN(a.kcal) ? "−" + Math.round(a.kcal) + " кк" : ""}</span>
<button class="del-btn" data-table="activity" data-id="${a.id}">&#x2715;</button>
</li>`
).join("")}
</li>`;
}).join("")}
${!gpsSessions.length && !activities.length ? `<li class="log-empty">нет данных</li>` : ""}
</ul>
</section>
@ -966,7 +979,7 @@ async function renderStatsCharts(container, days, foodTotals, bmr) {
// Modals
// ---------------------------------------------------------------------------
function showModal(html, onSubmit) {
function showModal(html, onSubmit, onMount) {
const overlay = document.createElement("div");
overlay.className = "modal-overlay";
overlay.innerHTML = `
@ -1005,6 +1018,7 @@ function showModal(html, onSubmit) {
alert("Ошибка: " + e);
}
};
if (onMount) onMount(overlay);
}
function showWeightModal() {
@ -1062,7 +1076,7 @@ function showFoodModal() {
</div>
<div class="form-group">
<label id="m-amount-label">Количество (г)</label>
<input id="m-amount" type="text" inputmode="numeric" placeholder="100 или 4*30" value="100" />
<input id="m-amount" type="text" inputmode="text" placeholder="100 или 4*30" value="100" />
</div>
<div class="form-group">
<label>Время</label>
@ -1194,7 +1208,7 @@ function showEditFoodModal(entry) {
</div>
<div class="form-group">
<label id="m-amount-label">Количество (${(entry.water_pct && entry.water_pct > 80) ? "мл" : "г"})</label>
<input id="m-amount" type="text" inputmode="numeric" value="${entry.amount_g || 100}" />
<input id="m-amount" type="text" inputmode="text" value="${entry.amount_g || 100}" />
</div>
${!entry.product_id && !entry.recipe_id ? `
<div class="form-group" id="m-kcal-group">
@ -1270,31 +1284,89 @@ function showEditFoodModal(entry) {
}
function showActivityModal() {
const types = Object.entries(ACTIVITY_NAMES);
showModal(`
<h3>Активность</h3>
<div class="form-group">
<label>Тип</label>
<select id="m-atype" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg)">
${types.map(([k,v]) => `<option value="${k}">${v}</option>`).join("")}
</select>
</div>
<div class="form-group" id="m-desc-row">
<label>Описание</label>
<input id="m-desc" type="text" placeholder="прогулка" />
</div>
<div class="form-group">
<label>Минуты</label>
<input id="m-min" type="number" inputmode="numeric" placeholder="40" />
<div class="form-group" id="m-sets-row" style="display:none">
<label>Подходы x Повторения</label>
<div style="display:flex;gap:8px">
<input id="m-sets" type="number" inputmode="numeric" placeholder="4" style="width:50%" />
<input id="m-reps" type="number" inputmode="numeric" placeholder="15" style="width:50%" />
</div>
</div>
<div class="form-group" id="m-dist-row" style="display:none">
<label>Дистанция (м)</label>
<input id="m-dist" type="number" inputmode="numeric" placeholder="800" />
</div>
<div class="form-group">
<label>ккал расход (примерно)</label>
<input id="m-kcal" type="number" inputmode="numeric" placeholder="150" />
<label>Минуты</label>
<input id="m-min" type="number" inputmode="numeric" placeholder="20" />
</div>
<div class="form-group">
<label>ккал расход</label>
<input id="m-kcal" type="number" inputmode="numeric" placeholder="авто" />
</div>
<div class="form-group">
<label>Время</label>
<input id="m-time" type="time" value="${nowTime()}" />
</div>`,
async overlay => {
const atype = overlay.querySelector("#m-atype").value;
const desc = overlay.querySelector("#m-desc").value.trim();
const min = parseInt(overlay.querySelector("#m-min").value) || null;
const kcal = parseFloat(overlay.querySelector("#m-kcal").value) || null;
const sets = parseInt(overlay.querySelector("#m-sets").value) || null;
const reps = parseInt(overlay.querySelector("#m-reps").value) || null;
const dist = parseFloat(overlay.querySelector("#m-dist").value) || null;
const time = overlay.querySelector("#m-time").value;
if (!desc) return false;
await DB.addActivity(state.date, time, "manual", desc, min, kcal);
let kcalRaw = overlay.querySelector("#m-kcal").value.trim();
let kcal = kcalRaw ? parseFloat(kcalRaw) : null;
if (kcal === null || isNaN(kcal)) {
if (min && atype !== "manual") {
kcal = activityKcal(atype, min, parseFloat(state.latestWeight?.weight_kg) || 75);
} else {
kcal = null;
}
}
const label = atype === "manual" ? desc : ACTIVITY_NAMES[atype];
if (!label) return false;
await DB.addActivity(state.date, time, "manual", label, min, kcal,
{ activity_type: atype, sets, reps, distance_m: dist });
}, overlay => {
// Dynamic field visibility based on type
const sel = overlay.querySelector("#m-atype");
const update = () => {
const t = sel.value;
overlay.querySelector("#m-desc-row").style.display = t === "manual" ? "" : "none";
overlay.querySelector("#m-sets-row").style.display = t === "squats" ? "" : "none";
overlay.querySelector("#m-dist-row").style.display = t === "swim" ? "" : "none";
// Auto-calc kcal when minutes change
const minEl = overlay.querySelector("#m-min");
const kcalEl = overlay.querySelector("#m-kcal");
if (t !== "manual" && minEl.value && !kcalEl.value) {
const w = parseFloat(state.latestWeight?.weight_kg) || 75;
kcalEl.placeholder = activityKcal(t, parseInt(minEl.value) || 0, w) || "авто";
}
};
sel.onchange = update;
overlay.querySelector("#m-min").oninput = () => {
const t = sel.value;
if (t !== "manual") {
const w = parseFloat(state.latestWeight?.weight_kg) || 75;
const m = parseInt(overlay.querySelector("#m-min").value) || 0;
overlay.querySelector("#m-kcal").placeholder = m ? activityKcal(t, m, w) : "авто";
}
};
update();
});
}
@ -1494,7 +1566,7 @@ async function renderProducts(container) {
<h3>Добавить в дневник</h3>
<div class="form-group">
<label>Количество (г)</label>
<input id="m-amount" type="text" inputmode="numeric" placeholder="100 или 4*30" value="100" />
<input id="m-amount" type="text" inputmode="text" placeholder="100 или 4*30" value="100" />
</div>
<div class="form-group">
<label>Время</label>
@ -2577,8 +2649,8 @@ function showDrawRouteScreen(container) {
let polyline = null;
container.innerHTML = `
<div class="rec-screen">
<div id="draw-map" class="rec-map"></div>
<div class="draw-screen">
<div id="draw-map" class="draw-map"></div>
<div class="rec-stats">
<div class="rec-stat"><span id="draw-dist" class="rec-val">0 м</span><span class="rec-lbl">Дистанция</span></div>
<div class="rec-stat"><span id="draw-pts" class="rec-val">0</span><span class="rec-lbl">Точек</span></div>
@ -3376,29 +3448,30 @@ async function showSessionDetail(sessionId) {
const tRange = tN - t0 || 1;
// Split into segments by GPS gap threshold
// If accelerometer shows movement on both sides of gap → keep connected (don't break)
// Manual routes: no splitting — points are connected as-is (synthetic timestamps have large gaps)
const isManualRoute = session.source === "manual";
const gapSec = GPS.config.gapThresholdSec ?? 30;
const segments = []; // array of arrays of [lat, lon]
const gapLines = []; // dashed lines for accel-confirmed gaps
let curSeg = [[points[0].lat, points[0].lon]];
for (let i = 1; i < points.length; i++) {
const gap = (new Date(points[i].ts) - new Date(points[i-1].ts)) / 1000;
if (gap > gapSec) {
const accelBefore = points[i-1].accel_rms;
const accelAfter = points[i].accel_rms;
// If both sides show movement → bridge the gap with dashed line
if (accelBefore != null && accelAfter != null && accelBefore > 2 && accelAfter > 2) {
// Keep in same segment — person was moving, GPS just lost signal
curSeg.push([points[i].lat, points[i].lon]);
gapLines.push([[points[i-1].lat, points[i-1].lon], [points[i].lat, points[i].lon]]);
} else {
segments.push(curSeg);
curSeg = [];
curSeg.push([points[i].lat, points[i].lon]);
if (!isManualRoute) {
const gap = (new Date(points[i].ts) - new Date(points[i-1].ts)) / 1000;
if (gap > gapSec) {
const accelBefore = points[i-1].accel_rms;
const accelAfter = points[i].accel_rms;
if (accelBefore != null && accelAfter != null && accelBefore > 2 && accelAfter > 2) {
curSeg.push([points[i].lat, points[i].lon]);
gapLines.push([[points[i-1].lat, points[i-1].lon], [points[i].lat, points[i].lon]]);
} else {
segments.push(curSeg);
curSeg = [];
curSeg.push([points[i].lat, points[i].lon]);
}
continue;
}
} else {
curSeg.push([points[i].lat, points[i].lon]);
}
curSeg.push([points[i].lat, points[i].lon]);
}
segments.push(curSeg);

2
src-tauri/Cargo.lock generated
View file

@ -139,7 +139,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "baldav"
version = "0.1.279"
version = "0.1.289"
dependencies = [
"base64 0.22.1",
"btleplug",

View file

@ -1,6 +1,6 @@
[package]
name = "baldav"
version = "0.1.279"
version = "0.1.289"
edition = "2021"
[lib]

View file

@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/nicegui/tauri-v2/refs/heads/main/tooling/cli/schema.json",
"productName": "BalDav",
"version": "0.1.279",
"version": "0.1.289",
"identifier": "dev.baldav.app",
"build": {
"frontendDist": "../frontend"