diff --git a/PROJECT_MAP.md b/PROJECT_MAP.md index f9082fe..e90061b 100644 --- a/PROJECT_MAP.md +++ b/PROJECT_MAP.md @@ -30,7 +30,7 @@ | `css/styles.css` | ~2100 | Темная тема, мобильный дизайн, HR зоны, ландшафтный режим | | `index.html` | 28 | Точка входа, порядок загрузки скриптов | | `img/gps-placeholder.png` | 63KB | Пиксельарт заглушка при поиске GPS | -| `data/products_seed.db` | ~12MB | 24443 продукта (HD 8090, magnit 7690, 5ka 3354, ashan 2000, ion 1079, kdv 667, mcdonalds 619, kfc 361, dodo 258, gridnev 217) | +| `data/products_seed.db` | ~9MB | 13008 продуктов с меткой источника (magnit 7690, 5ka 3354, ion 1079, kdv 667, gridnev 217, manual 1). Импорт без плашки (HD/ashan/фастфуд) вычищен, seed_meta.version=11. Поиск — `searchProducts` порядко-независимый (AND по токенам). Чистка каталога: `cleanupUnlabeledProducts` (неиспользованные non-keep) | **Библиотеки** (`lib/`): leaflet.js (1.9.4), leaflet-rotate.js (компас), chart.min.js, chartjs-plugin-zoom.min.js, hammer.min.js, jszip.min.js diff --git a/README.md b/README.md index 4f9c042..e5e43a2 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ ## Возможности -- **Дневник питания** — 24400+ продуктов (Магнит, 5ка, Health-Diet.ru, Ашан, Макдоналдс, KFC, Додо Пицца, KDV, ИОН, Гриднев). 37 нутриентов (витамины, минералы, omega-3/6, вода, клетчатка). Рецепты с термообработкой. Формулы порций (4*30, 300+50). +- **Дневник питания** — ~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` — «велик катился бы сам») = только базовый обмен. MET-таблица для ходьбы/бега и тренировок (приседания 6.0, скакалка 12.3, растяжки 2.3, бассейн 8.0). Набор высоты — кумулятивный гистерезис (устойчив на плотных треках, где per-point разница <1м). Пункт меню «Пересчитать высоту/калории» пересчитывает статистику всех треков без изменения точек. Профили велосипедов. BMR по Mifflin-St Jeor с коэффициентом активности. @@ -80,16 +80,16 @@ smart/ ## Данные продуктов +Каталог (seed) очищен от импорта без метки источника (health-diet, ашан, фастфуд) — остаются источники с плашкой: + | Источник | Продуктов | Витамины/минералы | |----------|-----------|-------------------| -| Health-Diet.ru | 8090 | да (37 нутриентов, omega-3/6, вода) | | Магнит (SSR парсинг) | 7690 | нет | | 5ка (API скрейпер) | 3354 | нет | -| Ашан (HD) | 2000 | да | | ИОН (web.ion.ru) | 1079 | да | | KDV (SSR парсинг) | 667 | нет | -| Макдоналдс (HD) | 619 | да | -| KFC (HD) | 361 | да | -| Додо Пицца | 258 | КБЖУ | | Гриднев (PDF) | 217 | да | -| **Всего** | **~24400** | | +| Свои (manual) | — | по вводу | +| **Всего (seed)** | **~13000** | | + +На устройстве такая же чистка — кнопка «Очистить продукты без магазина» в настройках (удаляет только неиспользованные без метки; съеденные/в рецептах остаются). diff --git a/frontend/data/products_seed.db b/frontend/data/products_seed.db index cbe090b..0eb9ea3 100644 Binary files a/frontend/data/products_seed.db and b/frontend/data/products_seed.db differ diff --git a/frontend/js/db.js b/frontend/js/db.js index ca103be..8fbce5d 100644 --- a/frontend/js/db.js +++ b/frontend/js/db.js @@ -106,24 +106,36 @@ async function saveSleep(date, bedTime, asleepTime, wakeTime, napMinutes, notes) // --------------------------------------------------------------------------- async function searchProducts(query, limit) { - const q = query.toLowerCase(); - return select( - `SELECT id, name, brand, category, weight_g, kcal_per_100g, + const q = (query || "").toLowerCase().trim(); + // Order-independent: every whitespace-separated word must appear in the name + // (in any order). Fixes "надо вводить слова в порядке названия". + const tokens = q.split(/\s+/).filter(Boolean); + if (tokens.length === 0) return []; + const cols = `id, name, brand, category, weight_g, kcal_per_100g, protein_per_100g, fat_per_100g, carbs_per_100g, fiber_per_100g, composition, nutrients_guessed, source, water_pct, vit_a_mcg, vit_c_mg, vit_b1_mg, vit_b2_mg, vit_b3_mg, vit_e_mg, calcium_mg, iron_mg, magnesium_mg, potassium_mg, sodium_mg, phosphorus_mg, carotene_mcg, selenium_mcg, zinc_mg, copper_mg, folate_mcg, - vit_d_mcg, vit_k_mcg, vit_b6_mg, vit_b12_mcg + vit_d_mcg, vit_k_mcg, vit_b6_mg, vit_b12_mcg`; + const where = tokens.map((_, i) => `name_lower LIKE ?${i + 1}`).join(" AND "); + const vals = tokens.map(t => `%${t}%`); + const nFull = vals.length + 1, nPrefix = vals.length + 2, nLimit = vals.length + 3; + vals.push(`%${q}%`); // full contiguous string present → rank first + vals.push(`${q}%`); // name starts with the query → rank next + vals.push(limit ?? 200); + return select( + `SELECT ${cols} FROM products - WHERE name_lower LIKE ?1 + WHERE ${where} GROUP BY COALESCE(store_id, id) ORDER BY - CASE WHEN name_lower LIKE ?2 THEN 0 ELSE 1 END, + CASE WHEN name_lower LIKE ?${nFull} THEN 0 ELSE 1 END, + CASE WHEN name_lower LIKE ?${nPrefix} THEN 0 ELSE 1 END, length(name), name COLLATE NOCASE - LIMIT ?3`, - [`%${q}%`, `${q}%`, limit ?? 200] + LIMIT ?${nLimit}`, + vals ); } @@ -172,6 +184,27 @@ async function deleteProduct(id) { await execute("DELETE FROM products WHERE id = ?1", [id]); } +// Catalog cleanup: sources that carry a "плашка" (badge) and are worth keeping even +// if never eaten. Everything else (health-diet / ashan / fast food / null source) is +// removed ONLY when unused — i.e. not referenced by food_log or recipe_ingredients, +// so eaten products and recipe ingredients always survive. +const PRODUCT_KEEP_SOURCES = "'magnit','5ka','ion','kdv','manual','recipe_import','gridnev'"; +const UNLABELED_UNUSED_WHERE = + `(source IS NULL OR source NOT IN (${PRODUCT_KEEP_SOURCES})) + AND id NOT IN (SELECT product_id FROM food_log WHERE product_id IS NOT NULL) + AND id NOT IN (SELECT product_id FROM recipe_ingredients WHERE product_id > 0)`; + +async function countUnlabeledUnusedProducts() { + const rows = await select(`SELECT COUNT(*) AS cnt FROM products WHERE ${UNLABELED_UNUSED_WHERE}`); + return rows[0]?.cnt || 0; +} + +async function cleanupUnlabeledProducts() { + const n = await countUnlabeledUnusedProducts(); + await execute(`DELETE FROM products WHERE ${UNLABELED_UNUSED_WHERE}`); + return n; +} + // --------------------------------------------------------------------------- // Cooking methods & Recipes // --------------------------------------------------------------------------- @@ -1106,6 +1139,8 @@ window.DB.getManualProducts = getManualProducts; window.DB.addProduct = addProduct; window.DB.deleteProduct = deleteProduct; window.DB.getProductRefs = getProductRefs; +window.DB.countUnlabeledUnusedProducts = countUnlabeledUnusedProducts; +window.DB.cleanupUnlabeledProducts = cleanupUnlabeledProducts; window.DB.getFoodForDate = getFoodForDate; window.DB.addFoodManual = addFoodManual; window.DB.addFoodEntry = addFoodEntry; diff --git a/frontend/js/main.js b/frontend/js/main.js index 1ccac45..ca2c38f 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -5722,6 +5722,13 @@ async function renderSettings(container) { +
+
+
Очистить продукты без магазина
+
Удалить неиспользованные продукты без метки источника (health-diet, фастфуд). Съеденные и в рецептах остаются.
+
+ +
Скачать с роутера
@@ -5999,6 +6006,19 @@ async function renderSettings(container) { if (el) el.textContent = `${n} тайлов в кэше`; }); + // Catalog cleanup: drop unused products that carry no source badge (health-diet, + // fast food, null source). Eaten / recipe-used products are protected. + let cleanBtn = container.querySelector("#s-clean-products"); + if (cleanBtn) cleanBtn.onclick = async () => { + let n = await DB.countUnlabeledUnusedProducts(); + if (!n) { showToast("Нечего чистить"); return; } + if (!(await appConfirm(`Удалить ${n} неиспользованных продуктов без метки источника (health-diet, фастфуд)? Съеденные и в рецептах останутся.`, "Удалить"))) return; + cleanBtn.disabled = true; cleanBtn.textContent = "Чистка..."; + let removed = await DB.cleanupUnlabeledProducts(); + cleanBtn.disabled = false; cleanBtn.textContent = "Очистить"; + showToast(`Удалено ${removed} продуктов`); + }; + // Import tiles from a file the user picks on the device (SAF picker on Android). container.querySelector("#s-import-tiles").onclick = async () => { let btn = container.querySelector("#s-import-tiles");