Product catalog cleanup (no-badge unused) + order-independent search

- searchProducts: order-independent token AND (every whitespace word must appear
  in name, any order), ranked full-substring > prefix > length. Fixes needing to
  type words in the name's order.
- Cleanup of unlabeled junk (health-diet/ashan/fast-food, null source): keep-list
  = magnit,5ka,ion,kdv,manual,recipe_import,gridnev. DB.cleanupUnlabeledProducts
  deletes non-keep AND unused (food_log/recipe_ingredients protected); Settings
  button 'Очистить продукты без магазина' with a counted appConfirm.
- Seed products_seed.db cleaned: 24443 -> 13008 (non-keep removed), seed_meta
  version 10 -> 11, VACUUM (13.5MB -> 8.9MB). So future imports don't re-add junk.
  On-device 11207 unused non-keep will be removed via the button (24 eaten stay).
This commit is contained in:
4aiman 2026-08-25 18:23:51 +03:00
commit f7b45d98f0
5 changed files with 71 additions and 16 deletions

View file

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

View file

@ -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_<ms>_s<id>.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** | |
На устройстве такая же чистка — кнопка «Очистить продукты без магазина» в настройках (удаляет только неиспользованные без метки; съеденные/в рецептах остаются).

Binary file not shown.

View file

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

View file

@ -5722,6 +5722,13 @@ async function renderSettings(container) {
</div>
<button class="btn-secondary" id="s-import-tiles" style="padding:6px 12px;font-size:13px;">Файл</button>
</div>
<div class="settings-item">
<div class="settings-item-left">
<div class="settings-item-title">Очистить продукты без магазина</div>
<div class="settings-item-desc">Удалить неиспользованные продукты без метки источника (health-diet, фастфуд). Съеденные и в рецептах остаются.</div>
</div>
<button class="btn-secondary" id="s-clean-products" style="padding:6px 12px;font-size:13px;">Очистить</button>
</div>
<div class="settings-item">
<div class="settings-item-left">
<div class="settings-item-title">Скачать с роутера</div>
@ -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");