Dedup products, fix labels, fix track comparison, seed v9

1. Deduplicate products: 13482 -> 8325 in seed DB. JS migration
   deduplicates app DB on launch: reassigns food_log and
   recipe_ingredients to kept product before deleting dupes.

2. MICRO_FIELDS: full chemical names + alternatives for all nutrients.
   "B3 / PP / ниацин", "B9 / фолиевая", "Кальций / Ca" etc.

3. Track comparison: newer track always compared against older.
   Green = newer faster (progress), Red = newer slower (regression).
   Removed separate cyan dashed track — only colored segments.
   Order of selection doesn't matter — date determines which is newer.

4. Source badges in search: source column was already in SELECT,
   badges render via sourceBadge(p.source) in prod card.

5. Gap threshold already cosmetic-only (confirmed, no changes needed).

Seed DB v9, VACUUM'd, indexed.
This commit is contained in:
4aiman 2026-05-30 18:09:16 +03:00
commit 0136b8ae0e
3 changed files with 90 additions and 69 deletions

Binary file not shown.

View file

@ -1141,6 +1141,24 @@ async function runMigrations() {
await addSuppCol("omega6_mg", "REAL"); // омега-6
await addSuppCol("omega9_mg", "REAL"); // омега-9
// Deduplicate products: keep lowest id, reassign food_log and recipe_ingredients
try {
const dupes = await select(
"SELECT name_lower, MIN(id) as keep_id, COUNT(*) as cnt FROM products WHERE name_lower IS NOT NULL GROUP BY name_lower HAVING cnt > 1"
);
if (dupes.length > 0) {
for (const d of dupes) {
// Reassign food_log entries to the kept product
await execute("UPDATE food_log SET product_id = ?1 WHERE product_id IN (SELECT id FROM products WHERE name_lower = ?2 AND id != ?1)", [d.keep_id, d.name_lower]);
// Reassign recipe_ingredients
await execute("UPDATE recipe_ingredients SET product_id = ?1 WHERE product_id IN (SELECT id FROM products WHERE name_lower = ?2 AND id != ?1)", [d.keep_id, d.name_lower]);
// Delete duplicates
await execute("DELETE FROM products WHERE name_lower = ?1 AND id != ?2", [d.name_lower, d.keep_id]);
}
console.log(`[db.js] Deduplicated ${dupes.length} product groups`);
}
} catch (e) { console.error("[db.js] dedup error:", e); }
console.log("[db.js] recipe migrations done, checking seq repair...");
// Repair old tracks: assign seq to points where it's NULL, ordered by timestamp.

View file

@ -3281,18 +3281,28 @@ async function showComparisonView(currentId, compareId, currentSession, currentP
const compPoints = GPS.filterNoise(await DB.getGpsPoints(compareId), compSession.activity_type);
if (compPoints.length < 2 || currentPoints.length < 2) return;
// Compute duration diff
const curDur = currentPoints.length >= 2
? (new Date(currentPoints[currentPoints.length-1].ts) - new Date(currentPoints[0].ts)) / 1000 : 0;
const compDur = compPoints.length >= 2
? (new Date(compPoints[compPoints.length-1].ts) - new Date(compPoints[0].ts)) / 1000 : 0;
const durDiff = curDur - compDur;
const durDiffStr = (durDiff <= 0 ? "" : "+") + GPS.formatDuration(Math.abs(Math.round(durDiff)));
// Determine which is newer — newer is always "current" for color coding
// Red = newer is slower (regression), Green = newer is faster (progress)
const curDate = currentSession.date || "";
const compDate = compSession.date || "";
let newerPoints, olderPoints, newerSession, olderSession;
if (curDate >= compDate) {
newerPoints = currentPoints; olderPoints = compPoints;
newerSession = currentSession; olderSession = compSession;
} else {
newerPoints = compPoints; olderPoints = currentPoints;
newerSession = compSession; olderSession = currentSession;
}
// Compute duration diff (newer - older)
const newDur = (new Date(newerPoints[newerPoints.length-1].ts) - new Date(newerPoints[0].ts)) / 1000;
const oldDur = (new Date(olderPoints[olderPoints.length-1].ts) - new Date(olderPoints[0].ts)) / 1000;
const durDiff = newDur - oldDur;
const durLabel = durDiff < 0 ? `На ${GPS.formatDuration(Math.abs(Math.round(durDiff)))} быстрее` : durDiff > 0 ? `На ${GPS.formatDuration(Math.round(durDiff))} медленнее` : "Одинаково";
const curAvg = currentSession.avg_speed_kmh ?? 0;
const compAvg = compSession.avg_speed_kmh ?? 0;
const spdDiff = Math.round((curAvg - compAvg) * 10) / 10;
const newAvg = newerSession.avg_speed_kmh ?? 0;
const oldAvg = olderSession.avg_speed_kmh ?? 0;
const spdDiff = Math.round((newAvg - oldAvg) * 10) / 10;
showModal(`
<h3>Сравнение треков</h3>
@ -3302,14 +3312,14 @@ async function showComparisonView(currentId, compareId, currentSession, currentP
<div class="sd-stat"><span class="sd-val" style="color:${durDiff <= 0 ? "#4caf50" : "#f44336"}">${durLabel}</span><span class="sd-lbl">Время</span></div>
</div>
<div class="sd-row">
<div class="sd-stat"><span class="sd-val">${curAvg} км/ч</span><span class="sd-lbl">Текущая средняя</span></div>
<div class="sd-stat"><span class="sd-val">${compAvg} км/ч</span><span class="sd-lbl">Сравнение</span></div>
<div class="sd-stat"><span class="sd-val">${newAvg} км/ч</span><span class="sd-lbl">Новый (${newerSession.date})</span></div>
<div class="sd-stat"><span class="sd-val">${oldAvg} км/ч</span><span class="sd-lbl">Старый (${olderSession.date})</span></div>
<div class="sd-stat"><span class="sd-val" style="color:${spdDiff >= 0 ? "#4caf50" : "#f44336"}">${spdDiff > 0 ? "+" : ""}${spdDiff}</span><span class="sd-lbl">Разница</span></div>
</div>
</div>
<div class="sd-section">
<div class="sd-title">Карта</div>
<div style="font-size:11px;color:var(--muted);margin-bottom:4px">Фиолетовый — текущий, голубой — сравнение. Зелёные участки — быстрее, красные — медленнее.</div>
<div style="font-size:11px;color:var(--muted);margin-bottom:4px">Зелёные участки — прогресс (новый быстрее). Красные — регресс (новый медленнее).</div>
<div id="compare-map" class="sd-map" style="height:300px"></div>
</div>
`, () => {});
@ -3329,38 +3339,31 @@ async function showComparisonView(currentId, compareId, currentSession, currentP
const CachedLayer = TileCache.createLayer();
new CachedLayer({ maxZoom: 19 }).addTo(map);
// Draw comparison track (thin dashed cyan)
const compCoords = compPoints.map(p => [p.lat, p.lon]);
L.polyline(compCoords, { color: "#4dd0e1", weight: 3, dashArray: "8,6", opacity: 0.7 }).addTo(map);
// Build speed lookup for older track
const olderCoords = olderPoints.map(p => [p.lat, p.lon]);
// Build speed comparison: for each point on current track, find nearest on comparison track
// Pre-compute comparison speeds for quick nearest-neighbor lookup
const compSpeeds = [];
for (let i = 0; i < compPoints.length; i++) {
const spd = compPoints[i].speed_ms != null ? compPoints[i].speed_ms * 3.6 : 0;
compSpeeds.push(spd);
}
// Pre-compute older track speeds
const olderSpeeds = olderPoints.map(p => (p.speed_ms ?? 0) * 3.6);
// For each current segment, find nearest comparison point and compare speeds
// Draw colored polyline segments
for (let i = 1; i < currentPoints.length; i++) {
const cp = currentPoints[i];
const cpPrev = currentPoints[i - 1];
const curSpd = cp.speed_ms != null ? cp.speed_ms * 3.6 : 0;
// Draw newer track colored by speed difference vs older
// Green = newer faster (progress), Red = newer slower (regression)
for (let i = 1; i < newerPoints.length; i++) {
const cp = newerPoints[i];
const cpPrev = newerPoints[i - 1];
const newSpd = (cp.speed_ms ?? 0) * 3.6;
// Find nearest comparison point (simple brute force — OK for <2000 points)
// Find nearest older point
let nearestIdx = 0, nearestDist = Infinity;
for (let j = 0; j < compPoints.length; j++) {
const d = GPS.distance(cp.lat, cp.lon, compPoints[j].lat, compPoints[j].lon);
for (let j = 0; j < olderPoints.length; j++) {
const d = GPS.distance(cp.lat, cp.lon, olderPoints[j].lat, olderPoints[j].lon);
if (d < nearestDist) { nearestDist = d; nearestIdx = j; }
}
const compSpd = compSpeeds[nearestIdx];
const oldSpd = olderSpeeds[nearestIdx];
// Color: green if faster, red if slower, white if similar (within 1 km/h)
let segColor;
if (nearestDist > 100) segColor = "#bb86fc"; // too far to compare — use default
else if (curSpd > compSpd + 1) segColor = "#4caf50"; // faster
else if (curSpd < compSpd - 1) segColor = "#f44336"; // slower
if (nearestDist > 100) segColor = "#bb86fc"; // too far to compare
else if (newSpd > oldSpd + 1) segColor = "#4caf50"; // progress
else if (newSpd < oldSpd - 1) segColor = "#f44336"; // regression
else segColor = "#bb86fc"; // similar
L.polyline(
@ -3370,7 +3373,7 @@ async function showComparisonView(currentId, compareId, currentSession, currentP
}
// Fit to both tracks
const allCoords = currentPoints.map(p => [p.lat, p.lon]).concat(compCoords);
const allCoords = newerPoints.map(p => [p.lat, p.lon]).concat(olderCoords);
if (allCoords.length > 1) {
map.fitBounds(L.latLngBounds(allCoords).pad(0.1));
}
@ -3455,41 +3458,41 @@ async function showLogSupplementModal() {
// ---------------------------------------------------------------------------
const MICRO_FIELDS = [
{ key: "vit_a_mcg", label: "Вит A (мкг РЭ)" },
{ key: "vit_c_mg", label: "Вит C (мг)" },
{ key: "vit_d_mcg", label: "Вит D3 (мкг)" },
{ key: "vit_e_mg", label: "Вит E (мг ТЭ)" },
{ key: "vit_k_mcg", label: "Вит K (мкг)" },
{ key: "vit_b1_mg", label: "B1 (мг)" },
{ key: "vit_b2_mg", label: "B2 (мг)" },
{ key: "vit_b3_mg", label: "B3/PP (мг)" },
{ key: "vit_b5_mg", label: "B5 (мг)" },
{ key: "vit_b6_mg", label: "B6 (мг)" },
{ key: "vit_b7_mcg", label: "B7/биотин (мкг)" },
{ key: "folate_mcg", label: "B9/фолат (мг)", mult: 1000 },
{ key: "vit_b12_mcg", label: "B12 (мкг)" },
{ key: "calcium_mg", label: "Кальций (мг)" },
{ key: "magnesium_mg", label: "Магний (мг)" },
{ key: "iron_mg", label: "Железо (мг)" },
{ key: "zinc_mg", label: "Цинк (мг)" },
{ key: "copper_mg", label: "Медь (мг)" },
{ key: "selenium_mcg", label: "Селен (мг)", mult: 1000 },
{ key: "potassium_mg", label: "Калий (мг)" },
{ key: "sodium_mg", label: "Натрий (мг)" },
{ key: "phosphorus_mg", label: "Фосфор (мг)" },
{ key: "iodine_mg", label: "Йод (мг)" },
{ key: "chromium_mg", label: "Хром (мг)" },
{ key: "manganese_mg", label: "Марганец (мг)" },
{ key: "molybdenum_mcg", label: "Молибден (мкг)" },
{ key: "vit_a_mcg", label: "A / ретинол (мкг РЭ)" },
{ key: "carotene_mcg", label: "Бета-каротин (мкг)" },
{ key: "vit_c_mg", label: "C / аскорбинка (мг)" },
{ key: "vit_d_mcg", label: "D3 / холекальциферол (мкг)" },
{ key: "vit_e_mg", label: "E / токоферол (мг ТЭ)" },
{ key: "vit_k_mcg", label: "K / филлохинон (мкг)" },
{ key: "vit_b1_mg", label: "B1 / тиамин (мг)" },
{ key: "vit_b2_mg", label: "B2 / рибофлавин (мг)" },
{ key: "vit_b3_mg", label: "B3 / PP / ниацин (мг)" },
{ key: "vit_b5_mg", label: "B5 / пантотенат (мг)" },
{ key: "vit_b6_mg", label: "B6 / пиридоксин (мг)" },
{ key: "vit_b7_mcg", label: "B7 / биотин (мкг)" },
{ key: "folate_mcg", label: "B9 / фолиевая (мг)", mult: 1000 },
{ key: "vit_b12_mcg", label: "B12 / цианокобаламин (мкг)" },
{ key: "calcium_mg", label: "Кальций / Ca (мг)" },
{ key: "magnesium_mg", label: "Магний / Mg (мг)" },
{ key: "iron_mg", label: "Железо / Fe (мг)" },
{ key: "zinc_mg", label: "Цинк / Zn (мг)" },
{ key: "copper_mg", label: "Медь / Cu (мг)" },
{ key: "selenium_mcg", label: "Селен / Se (мг)", mult: 1000 },
{ key: "potassium_mg", label: "Калий / K (мг)" },
{ key: "sodium_mg", label: "Натрий / Na (мг)" },
{ key: "phosphorus_mg", label: "Фосфор / P (мг)" },
{ key: "iodine_mg", label: "Йод / I (мг)" },
{ key: "chromium_mg", label: "Хром / Cr (мг)" },
{ key: "manganese_mg", label: "Марганец / Mn (мг)" },
{ key: "molybdenum_mcg", label: "Молибден / Mo (мкг)" },
{ key: "fish_oil_mg", label: "Рыбий жир (мг)" },
{ key: "omega3_mg", label: "в т.ч. Омега-3 (мг)" },
{ key: "epa_mg", label: "в т.ч. EPA (мг)" },
{ key: "dha_mg", label: "в т.ч. DHA (мг)" },
{ key: "omega3_mg", label: "-- Омега-3 ПНЖК (мг)" },
{ key: "epa_mg", label: "-- EPA / эйкозапентаеновая (мг)" },
{ key: "dha_mg", label: "-- DHA / докозагексаеновая (мг)" },
{ key: "omega6_mg", label: "Омега-6 (мг)" },
{ key: "omega9_mg", label: "Омега-9 (мг)" },
{ key: "silicon_mg", label: "Кремний (мг)" },
{ key: "vanadium_mg", label: "Ванадий (мг)" },
{ key: "silicon_mg", label: "Кремний / Si (мг)" },
{ key: "vanadium_mg", label: "Ванадий / V (мг)" },
{ key: "lutein_mg", label: "Лютеин (мг)" },
{ key: "lycopene_mg", label: "Ликопин (мг)" },
];