Physics settings in UI, recipe modal cleanup, weight parseFloat fixes

Settings:
- Aero drag (CdA), air density, pedal efficiency now in GPS config
- Human-readable names: "Аэросопротивление", "Плотность воздуха", "КПД педалирования"
- Saved to localStorage, used in computeStats

Bug fixes:
- Recipe detail: overlay.remove without popHandler cleanup → now uses history.back()
- weightKg parseFloat in startRecording and importGPX (string "88" bug)
- profile.weight_kg stored as number not string
- getDailyActivityTotals: separate params for UNION ALL subqueries

Docs:
- PROJECT_MAP.md — full project structure, DB schema, screens, formulas
- README.md — overview, build instructions, data sources
This commit is contained in:
4aiman 2026-05-26 02:14:43 +03:00
commit 7cbfd98e27
5 changed files with 266 additions and 17 deletions

166
PROJECT_MAP.md Normal file
View file

@ -0,0 +1,166 @@
# PROJECT MAP — BalDav
Трекер здоровья и активности. Pure JS + Tauri 2 + SQLite. Без фреймворков и бандлеров.
## Статистика
| Метрика | Значение |
|---------|----------|
| Строк кода | ~15000 |
| Файлов исходников | 44 |
| Язык | JS 71%, Rust 4%, Kotlin 10%, CSS 15% |
| Таблиц в БД | 15 |
| Экранов | 7 основных + 12 модалок |
| IPC команд (Tauri) | 6 |
| Библиотеки | Leaflet, Chart.js, Hammer.js, JSZip |
| Платформы | Android (Kotlin), Desktop (Tauri) |
## Структура файлов
### Frontend (`frontend/`)
| Файл | Строк | Назначение |
|------|-------|------------|
| `js/main.js` | 3264 | Экраны, навигация, модалки, графики |
| `js/db.js` | 972 | SQLite через Tauri IPC. Миграции, CRUD |
| `js/gps.js` | 947 | GPS запись, физика ккал, haversine, импорт/экспорт |
| `js/tilecache.js` | 178 | Оффлайн кэш тайлов карты (IndexedDB) |
| `js/bmr.js` | 14 | Базальный метаболизм (Mifflin-St Jeor) |
| `css/styles.css` | 1599 | Темная тема, мобильный дизайн |
| `index.html` | 26 | Точка входа, порядок загрузки скриптов |
| `data/products_seed.db` | 8.5MB | 13480 продуктов (magnit, 5ka, ION, kdv) |
**Библиотеки** (`lib/`): leaflet.js, chart.min.js, chartjs-plugin-zoom.min.js, hammer.min.js, jszip.min.js
### Бэкенд Rust (`src-tauri/`)
| Файл | Строк | Назначение |
|------|-------|------------|
| `src/lib.rs` | 512 | Миграции v1, IPC команды, seed import |
| `src/main.rs` | 5 | Точка входа |
| `Cargo.toml` | 18 | tauri, tauri-plugin-sql, rusqlite, serde |
| `tauri.conf.json` | — | Конфиг приложения, плагины, ресурсы |
### Android Kotlin (`src-tauri/gen/android/.../dev/baldav/app/`)
| Файл | Строк | Назначение |
|------|-------|------------|
| `MainActivity.kt` | 153 | FileObserver для IPC, seed DB копирование, safe insets, SAF |
| `GpsRecorderService.kt` | 205 | Foreground service GPS, WakeLock, JSONL батчевая запись |
| `generated/*.kt` | ~1100 | Tauri-сгенерированные: WebView, IPC, permissions |
### Инструменты (`tools/`)
| Скрипт | Назначение |
|--------|------------|
| `5ka_scraper.user.js` | Tampermonkey скрейпер 5ka.ru (рекурсивный BFS категорий) |
| `ion_scraper.user.js` | Tampermonkey скрейпер web.ion.ru (fetch, без перезагрузок) |
| `import_ion.js` | Импорт ION продуктов в seed DB |
| `import_with_dedup.js` | Импорт с дедупликацией по имени |
| `enrich_nutrients.js` | Обогащение микронутриентами из ION |
| `parse_magnit.js` | Парсер HTML каталога Магнит |
| `parse_kdv.js` | Парсер HTML каталога KDV |
| `scrape_5ka.js`, `scrape_5ka_api.js` | CLI скрейперы 5ka |
| `download_tiles.js` | Скачивание тайлов карт для оффлайна |
| `fetch_openfoodfacts.js` | OpenFoodFacts API |
| `fuzzy_dedup.js` | Нечёткая дедупликация |
### Корень
| Файл | Назначение |
|------|------------|
| `build.sh` | Сборка: android/debug/release, sync frontend, версионирование |
| `BUILD_NUMBER` | Автоинкремент номера сборки |
| `ion_products.json` | 1111 продуктов ION (сырые данные скрейпера) |
| `5ka_products_fresh.json` | 3103 продукта 5ka (сырые данные) |
## База данных
### Таблицы (Rust v1 миграция + JS миграции)
| Таблица | Назначение | Ключевые поля |
|---------|------------|---------------|
| `profile` | Key-value настройки | key, value |
| `products` | Продукты (13480) | name, kcal/protein/fat/carbs per 100g, витамины, минералы, nutrients_guessed |
| `food_log` | Дневник еды | date, time, product_id/recipe_id, amount_g, unit_id, unit_amount, ккал/БЖУ |
| `recipes` | Рецепты | name, cooking_method_id, servings, notes |
| `recipe_ingredients` | Ингредиенты рецептов | recipe_id, product_id, custom_name, amount_g, cooking_method_id |
| `cooking_methods` | Способы готовки (6 шт) | name, потери витаминов/БЖУ в % |
| `serving_units` | Единицы измерения (5 шт) | name (шт/ст.л./ч.л./стакан/мл), grams_equivalent |
| `product_units` | Привязка единиц к продуктам | product_id, unit_id, grams_per_unit |
| `weight_log` | Лог веса | date, time, weight_kg |
| `sleep_log` | Лог сна | date, bed/asleep/wake time, nap_minutes |
| `activity_log` | Лог активности | date, type, minutes, kcal |
| `supplements` | Добавки (БАД) | name, daily_dose_g, витамины/минералы |
| `supplement_log` | Приём добавок | date, supplement_id, dose_g |
| `gps_sessions` | GPS тренировки | date, activity_type, distance, kcal, speed, elevation, bike_id |
| `gps_points` | Точки трека | session_id, ts, lat, lon, altitude, speed, seq |
| `bikes` | Велосипеды | name, weight_kg, wheel, звёзды, surface_type, is_active |
### Seed DB (products_seed.db)
| Источник | Продуктов | Микронутриенты |
|----------|-----------|----------------|
| magnit | 8627 | нет |
| 5ka | 3103 | нет |
| ION | 1078 | витамины + минералы |
| kdv | 672 | нет |
| **Всего** | **13480** | 1398 обогащены (nutrients_guessed) |
## Экраны и навигация
### Нижний навбар (5 табов)
```
[Сегодня] [Продукты] [Трек] [Добавки] [Настройки]
```
### Экраны
- **Сегодня** — дневной баланс ккал, еда (продукты + блюда), активность, вес, сон, добавки, статистика (графики)
- **Продукты** — подтабы [Продукты | Рецепты], поиск, карточки с КБЖУ, добавление в дневник
- **Трек** — список GPS тренировок, запись, импорт GPX/FTB, скачивание карт
- **Добавки** — список БАД, логирование приёма
- **Настройки** — профиль (вес, рост, BMR), велосипеды, GPS конфиг, оффлайн режим
### Модалки
- Еда: showFoodModal, showEditFoodModal, showFoodFromRecipeModal
- Рецепты: showRecipeDetailModal, showRecipeEditorModal, showAddIngredientModal
- Тренировки: showSessionDetail (карта, графики скорости/высоты, пересчёт)
- Прочее: showWeightModal, showSleepModal, showActivityModal, showProfileModal, showBikeModal, showMapDownloadModal, showLogSupplementModal
## IPC команды (Rust → JS)
| Команда | Назначение |
|---------|------------|
| `import_seed_products` | Импорт продуктов из seed DB, прогресс-события |
| `get_safe_insets` | Отступы Android status/nav bar |
| `gps_service_command` | start/stop GPS сервиса |
| `gps_service_read_points` | Чтение JSONL файла точек |
| `save_file` | SAF диалог сохранения (GPX экспорт) |
| `get_app_info` | Версия, git hash, дата сборки |
## Формула ккал (GPS)
### Велосипед — физическая модель
```
Power = v * (m*g*sin(gradient) + Crr*m*g*cos(gradient) + 0.5*CdA*rho*v^2)
m = rider_kg + bike_kg
Crr = 0.004 (асфальт) / 0.006 (смешанный) / 0.008 (грунт)
CdA = 0.6, rho = 1.225, efficiency = 0.25
Power > 0: педалирование → kcal = power*dt / (0.25 * 4184)
Power <= 0: каботаж → kcal = базальный метаболизм
```
### Ходьба/бег — MET-таблица
```
<4 км/ч = 2.5 MET, 4-5.5 = 3.5, 5.5-7 = 5.0, 7-9 = 8.0, 9-12 = 10.0, 12-16 = 12.5, 16+ = 15.0
kcal = (dt/60) * (MET * 3.5 * weight_kg) / 200
```
## Сборка
```bash
./build.sh -d android # debug APK
./build.sh android # release APK
./build.sh -i android -d # debug + install
```

58
README.md Normal file
View file

@ -0,0 +1,58 @@
# BalDav
Персональный трекер здоровья и физической активности.
## Возможности
- **Дневник питания** — 13480 продуктов из магазинов (Магнит, 5ка, KDV) + база ИОН с микронутриентами. Рецепты с учётом термообработки (потери витаминов при варке/жарке). Единицы измерения (штуки, ложки, стаканы).
- **GPS трекинг** — запись маршрутов на велосипеде/пешком в фоне. Оффлайн карты (Leaflet + IndexedDB кэш). Импорт GPX/FTB (FitoTrack). Экспорт GPX.
- **Расчёт ккал** — физическая модель для велосипеда (мощность из gradient + Crr + CdA). MET-таблица для ходьбы/бега. Профили велосипедов (вес, звёзды, покрытие).
- **Графики** — скорость и высота тренировки с зумом. Тренд веса. Донат макронутриентов. Баланс ккал (приход/расход).
- **Вес, сон, добавки** — логирование с историей.
## Стек
- **Frontend**: чистый JavaScript, HTML, CSS. Без React/Vue/bundler.
- **Backend**: Rust (Tauri 2) — SQLite, IPC, seed import.
- **Android**: Kotlin — foreground GPS service, WakeLock, SAF.
- **Библиотеки**: Leaflet (карты), Chart.js (графики), Hammer.js (жесты), JSZip.
## Сборка
```bash
# Зависимости: Rust, Node.js, Android SDK + NDK
./build.sh -d android # debug APK
./build.sh android # release APK
./build.sh -i android -d # debug + установка на устройство
```
## Структура
Подробно: [PROJECT_MAP.md](PROJECT_MAP.md)
```
frontend/
js/main.js — UI, навигация, модалки (3264 строк)
js/db.js — БД, миграции, CRUD (972)
js/gps.js — GPS, физика ккал (947)
js/tilecache.js — оффлайн тайлы (178)
css/styles.css — стили (1599)
data/products_seed.db — 13480 продуктов
src-tauri/
src/lib.rs — Rust бэкенд (512)
gen/android/ — Kotlin: MainActivity, GpsRecorderService
tools/
*_scraper.user.js — скрейперы магазинов (Tampermonkey)
import_*.js — импорт в seed DB
enrich_nutrients.js — обогащение микронутриентами
```
## Данные продуктов
| Источник | Продуктов | Витамины/минералы |
|----------|-----------|-------------------|
| Магнит (SSR парсинг) | 8627 | нет |
| 5ка (API скрейпер) | 3103 | нет |
| ИОН (web.ion.ru) | 1078 | да |
| KDV (SSR парсинг) | 672 | нет |
| Обогащено из ИОН | 1398 | да (nutrients_guessed) |

View file

@ -582,13 +582,14 @@ async function getDailyFoodTotals(days) {
}
async function getDailyActivityTotals(days) {
const offset = `-${days ?? 30} days`;
return select(
`SELECT date, SUM(kcal) as kcal FROM (
SELECT date, kcal FROM activity_log WHERE date >= date('now', ?1)
UNION ALL
SELECT date, total_kcal as kcal FROM gps_sessions WHERE date >= date('now', ?1) AND total_kcal IS NOT NULL
SELECT date, total_kcal as kcal FROM gps_sessions WHERE date >= date('now', ?2) AND total_kcal IS NOT NULL
) GROUP BY date ORDER BY date ASC`,
[`-${days ?? 30} days`]
[offset, offset]
);
}

View file

@ -10,6 +10,9 @@ GPS.config = {
minAccuracyM: 20, // skip fixes less accurate than this
minDistanceM: 1, // skip points closer than this
maxSpeedKmh: 60, // above this = GPS glitch, skip segment
airDrag: 0.6, // аэродинамическое сопротивление (CdA, м2). Город=0.6, спорт=0.4
airDensity: 1.225, // плотность воздуха (кг/м3). Уровень моря=1.225, горы=1.0
pedalEfficiency: 25, // КПД педалирования (%). Человек ~25%
};
// Load saved config
@ -59,11 +62,11 @@ GPS.computeStats = function(points, weightKg, opts) {
return cnt > 0 ? sum / cnt : alt;
});
// Physics constants
// Physics constants (configurable in settings)
const g = 9.81;
const CdA = 0.6; // typical upright position drag area (m^2)
const rho = 1.225; // air density at sea level (kg/m^3)
const efficiency = 0.25; // human mechanical efficiency (~25%)
const CdA = GPS.config.airDrag ?? 0.6;
const rho = GPS.config.airDensity ?? 1.225;
const efficiency = (GPS.config.pedalEfficiency ?? 25) / 100;
const Crr = opts?.surfaceCrr ?? 0.006; // rolling resistance (mixed surface default)
const wKg = parseFloat(weightKg) || 80; // ensure numeric (profile stores strings)
const mTotal = wKg + (parseFloat(opts?.bikeWeightKg) || 0);
@ -446,7 +449,7 @@ GPS.startRecording = async function(activityType, weightKg) {
GPS.recording.activityType = activityType || "bike";
GPS.recording.points = [];
GPS.recording.startTime = null;
GPS.recording.weightKg = weightKg ?? 80;
GPS.recording.weightKg = parseFloat(weightKg) || 80;
GPS.recording.state = "waiting_gps";
GPS.recording.sessionId = null;
GPS.recording.pauseStart = null;
@ -618,7 +621,7 @@ GPS.importGPX = async function(xmlString, weightKg) {
const { points, activityType } = GPS.parseGPX(xmlString);
if (points.length < 2) throw new Error("GPX has fewer than 2 points");
const stats = GPS.computeStats(points, weightKg ?? 80);
const stats = GPS.computeStats(points, parseFloat(weightKg) || 80);
const first = points[0].ts ? new Date(points[0].ts) : new Date();
const last = points[points.length - 1].ts ? new Date(points[points.length - 1].ts) : new Date();

View file

@ -1246,21 +1246,18 @@ async function showRecipeDetailModal(recipeId) {
logBtn.onclick = async () => {
await DB.addFoodFromRecipe(state.date, nowTime(), recipeId);
overlay.remove();
history.back();
history.back(); // popHandler closes modal
renderScreen(state.screen);
};
editBtn.onclick = () => {
overlay.remove();
history.back();
showRecipeEditorModal(recipeId);
history.back(); // popHandler closes modal
setTimeout(() => showRecipeEditorModal(recipeId), 50);
};
delBtn.onclick = async () => {
await DB.deleteRecipe(recipeId);
overlay.remove();
history.back();
history.back(); // popHandler closes modal
renderScreen("recipes");
};
}
@ -2755,6 +2752,27 @@ async function renderSettings(container) {
</div>
<input id="s-max-speed" type="number" step="1" value="${GPS.config.maxSpeedKmh}" style="width:60px;text-align:right;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg);padding:4px 6px;">
</div>
<div class="settings-item">
<div class="settings-item-left">
<div class="settings-item-title">Аэросопротивление</div>
<div class="settings-item-desc">Город 0.6, спорт 0.4</div>
</div>
<input id="s-air-drag" type="number" step="0.05" value="${GPS.config.airDrag}" style="width:60px;text-align:right;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg);padding:4px 6px;">
</div>
<div class="settings-item">
<div class="settings-item-left">
<div class="settings-item-title">Плотность воздуха (кг/м3)</div>
<div class="settings-item-desc">Море 1.225, горы 1.0</div>
</div>
<input id="s-air-density" type="number" step="0.01" value="${GPS.config.airDensity}" style="width:60px;text-align:right;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg);padding:4px 6px;">
</div>
<div class="settings-item">
<div class="settings-item-left">
<div class="settings-item-title">КПД педалирования (%)</div>
<div class="settings-item-desc">Человек ~25%</div>
</div>
<input id="s-pedal-eff" type="number" step="1" value="${GPS.config.pedalEfficiency}" style="width:60px;text-align:right;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg);padding:4px 6px;">
</div>
</div>
<div class="settings-section">
@ -2801,7 +2819,7 @@ async function renderSettings(container) {
};
// GPS config inputs
const gpsInputs = ["s-min-speed", "s-min-acc", "s-min-dist", "s-max-speed"];
const gpsInputs = ["s-min-speed", "s-min-acc", "s-min-dist", "s-max-speed", "s-air-drag", "s-air-density", "s-pedal-eff"];
for (const id of gpsInputs) {
const el = container.querySelector("#" + id);
if (el) el.onchange = () => {
@ -2809,6 +2827,9 @@ async function renderSettings(container) {
GPS.config.minAccuracyM = parseFloat(container.querySelector("#s-min-acc").value);
GPS.config.minDistanceM = parseFloat(container.querySelector("#s-min-dist").value);
GPS.config.maxSpeedKmh = parseFloat(container.querySelector("#s-max-speed").value) || 60;
GPS.config.airDrag = parseFloat(container.querySelector("#s-air-drag").value) || 0.6;
GPS.config.airDensity = parseFloat(container.querySelector("#s-air-density").value) || 1.225;
GPS.config.pedalEfficiency = parseFloat(container.querySelector("#s-pedal-eff").value) || 25;
GPS.saveConfig();
};
}
@ -2924,7 +2945,7 @@ function showProfileModal() {
if (bEst) await DB.setProfileValue("bmr_estimated", bEst);
state.profile = { ...state.profile,
weight_kg: weight ? String(weight) : state.profile.weight_kg,
weight_kg: weight ? weight : state.profile.weight_kg,
height_cm: String(height), birth_year: String(year), sex: selectedSex,
bmr_formula: bFormula ? String(bFormula) : null,
bmr_estimated: bEst ? String(bEst) : null };