GPS point ordering + GPX export + chart zoom fixes

GPS background recording:
- Batched writes (10s flush) in native service
- Polling lock prevents parallel processing
- Track by timestamp instead of line count
- Recovery idempotent via max DB timestamp
- Seq numbers on live recorded points

GPX export:
- Button in session detail
- SAF file picker on Android (ACTION_CREATE_DOCUMENT)
- GPS config in metadata (accuracy, minSpeed, minDist)

Charts:
- Pinch zoom enabled at creation (Hammer.js registers handlers)
- touch-action: pan-y blocks pinch in normal view (page scrolls)
- Fullscreen: CSS-only expand (no canvas manipulation)
- Downsampled to 500 points for smooth rendering
- Back gesture collapses fullscreen before closing modal

Build: --real flag finds first non-emulator device
This commit is contained in:
4aiman 2026-05-16 21:21:13 +03:00
commit b26264538b
20 changed files with 4234 additions and 37 deletions

339
data.js Normal file
View file

@ -0,0 +1,339 @@
const DATA = {
profile: {
height_cm: 178,
birth_year: 1988,
sex: "m",
bmr_formula: 1800,
bmr_estimated: 1580
},
days: [
// ===== 03.04 =====
{
date: "2026-04-03",
weights: [{ time: null, kg: 88.7 }],
sleep: { bed: "23:00", asleep: "23:40", wake: "09:00" },
food: [],
activity: [],
water: 0,
notes: ""
},
// ===== 04.04 =====
{
date: "2026-04-04",
weights: [
{ time: "10:39", kg: 88.7 },
{ time: "12:49", kg: 88.7 },
{ time: "17:44", kg: 87.8 }
],
sleep: { bed: "23:10", asleep: "23:39", wake: "08:30" },
food: [
{ time: "10:50", desc: "чай с сахаром", kcal: 20 },
{ time: "12:34", desc: "чай, булочка 40г, хачапури с сыром 100г (неслоёное тесто)", kcal: 390 },
{ time: "18:00", desc: "салат (капуста, огурец, подсолнечное масло), редиска, чай с сахаром, хлеб", kcal: 295 },
{ time: "19:20", desc: "чай, охотничьи колбаски 50г", kcal: 180 }
],
activity: [
{ time: "11:00", desc: "поход за продуктами (нёс 10кг половину пути)", minutes: 68, kcal: 300 },
{ time: "14:19", desc: "раскройка/распил ДВП, пробивание к стенке", minutes: 100, kcal: 350 },
{ time: "16:00", desc: "починка рулетки", minutes: 20, kcal: 30 },
{ time: "16:30", desc: "распил брусков 4x4", minutes: 74, kcal: 250 },
{ time: "18:40", desc: "прогулка", minutes: 40, kcal: 175 }
],
water: 3,
notes: "головная боль вечером (тупая давящая в лобных долях), жажда"
},
// ===== 05.04 =====
{
date: "2026-04-05",
weights: [
{ time: "10:35", kg: 88.1 },
{ time: "21:00", kg: 88.9 }
],
sleep: { bed: null, asleep: "01:40", wake: "08:45", nap_minutes: 150 },
food: [
{ time: "10:40", desc: "салат (капуста, огурцы, редиска), 0.6л чая с сахаром", kcal: 120 },
{ time: "16:10", desc: "чай, карамельный попкорн 60г, творог со сметаной/сахаром/колбасой", kcal: 640 },
{ time: "21:00", desc: "чай, попкорн, редис", kcal: 150 }
],
activity: [
{ time: "12:00", desc: "сборка каркаса шкафа (саморезы, уголки, брусья), перенос 2300x860x518", minutes: 240, kcal: 600 },
{ time: "20:20", desc: "зарядка", minutes: 20, kcal: 100 }
],
water: 0,
notes: "тяжесть в голове с утра (проснулся с болью), после стройки и дневного сна прошло"
},
// ===== 06.04 =====
{
date: "2026-04-06",
weights: [
{ time: "14:30", kg: 87.1 },
{ time: null, kg: 88.9 }
],
sleep: { bed: null, asleep: null, wake: "09:20" },
food: [
{ time: "12:30", desc: "пирожок бездрожжевой с капустой, сметана, чай с 1 ч.л. сахара", kcal: 320 },
{ time: "14:30", desc: "чай", kcal: 20 },
{ time: "14:35", desc: "6 редисок, кусочек жареной курицы", kcal: 170 },
{ time: "14:40", desc: "ещё 100г курицы", kcal: 200 },
{ time: null, desc: "стакан молока 200мл", kcal: 106 },
{ time: "18:30", desc: "картошка 100г жареная в масле, 2 огурца свежих", kcal: 220 },
{ time: "18:30", desc: "чай с сахаром ~700мл", kcal: 80 }
],
activity: [
{ time: "12:55", desc: "велосипед (отвёз ребёнка + круг, ~10км, пробил колесо)", minutes: 50, kcal: 350 },
{ time: "13:55", desc: "пешком с 10кг воды и великом", minutes: 17, kcal: 100 },
{ time: "14:12", desc: "затащил воду и велик на 5 этаж", minutes: 3, kcal: 50 }
],
water: 0,
notes: ""
},
// ===== 07.04 =====
{
date: "2026-04-07",
weights: [{ time: "12:55", kg: 88.3 }],
sleep: { bed: null, asleep: "00:00", wake: "09:20" },
food: [
{ time: "10:00", desc: "гречка с молоком и сахаром (дес. ложка), ел час", kcal: 370 },
{ time: "12:30", desc: "творог со сметаной и сахаром", kcal: 280 },
{ time: "16:00", desc: "кружка чая с 2 ч.л. сахара", kcal: 40 },
{ time: "17:50", desc: "100г сметаны, пирожок с тушёной капустой", kcal: 460 },
{ time: "18:40", desc: "чай с сахаром, банан", kcal: 150 },
{ time: "20:00", desc: "35г колбасок", kcal: 115 },
{ time: "23:00", desc: "чай с сахаром", kcal: 40 }
],
activity: [
{ time: "13:20", desc: "прогулка", minutes: 30, kcal: 130 }
],
water: 2,
notes: "удалёнка IT, витамины"
},
// ===== 08.04 =====
{
date: "2026-04-08",
weights: [{ time: "19:30", kg: 88.3 }],
sleep: { bed: "22:30", asleep: "22:30", wake: "09:20" },
food: [
{ time: "10:30", desc: "творог 180г с сахаром, сметаной и колбасками 35г", kcal: 430 },
{ time: "14:00", desc: "пол литра молока", kcal: 275 },
{ time: "17:00", desc: "40г жареного арахиса", kcal: 235 },
{ time: "19:30", desc: "гречка с молоком и сахаром, 150г батона, 350мл чая", kcal: 785 }
],
activity: [
{ time: "12:40", desc: "прогулка", minutes: 50, kcal: 220 },
{ time: "18:30", desc: "прогулка", minutes: 60, kcal: 260 }
],
water: 0,
notes: "утром полудрёма 08:20-08:40. Ночью будили, поднялись ЧСС и давление, не мог спать до 03:23"
},
// ===== 09.04 =====
{
date: "2026-04-09",
weights: [],
sleep: { bed: null, asleep: null, wake: "09:20", nap_minutes: 25 },
food: [
{ time: "11:30", desc: "гречка с молоком и сахаром (дес. ложка)", kcal: 370 },
{ time: "12:40", desc: "100г варёной картошки, 4 шпротинки, 50г салата (капуста, зелень, масло)", kcal: 170 },
{ time: "14:20", desc: "40г жареного арахиса", kcal: 235 },
{ time: "20:00", desc: "сырный багет 200г, 2 яйца глазунья, чай 600мл", kcal: 850 },
{ time: "20:15", desc: "15г морковки", kcal: 5 }
],
activity: [
{ time: "18:50", desc: "прогулка + магазин", minutes: 50, kcal: 220 }
],
water: 2,
notes: ""
},
// ===== 10.04 =====
{
date: "2026-04-10",
weights: [{ time: "17:30", kg: 87.7 }],
sleep: { bed: "01:24", asleep: "01:24", wake: "09:30" },
food: [
{ time: "10:20", desc: "50г творога со сметаной и сахаром", kcal: 110 },
{ time: "12:10", desc: "150г пюре, 120г творога со сметаной/сахаром, 50г морковки со сметаной/чесноком", kcal: 420 },
{ time: "13:32", desc: "чай с сахаром 220мл", kcal: 25 },
{ time: "17:20", desc: "2 чашки чая, 30г плавленного сыра, 3 кусочка батона", kcal: 320 },
{ time: "18:00", desc: "60г колбасок (в прогулке)", kcal: 195 },
{ time: "20:30", desc: "картошка, макароны, яйцо глазунья, 4 кусочка батона (250г всего)", kcal: 420 },
{ time: "22:00", desc: "чай", kcal: 40 }
],
activity: [
{ time: "14:30", desc: "замена покрышки и камеры на велике", minutes: 20, kcal: 50 },
{ time: "16:05", desc: "прогулка", minutes: 60, kcal: 260 },
{ time: "18:00", desc: "прогулка, магазины", minutes: 120, kcal: 520 }
],
water: 0,
notes: "витамины, омега, железо"
},
// ===== 11.04 =====
{
date: "2026-04-11",
weights: [],
sleep: { bed: null, asleep: "01:07", wake: "09:00" },
food: [
{ time: "10:30", desc: "чай, 30г протеинового батончика, витамины, железо, омега", kcal: 120 },
{ time: "12:00", desc: "150г сырного багета, чай", kcal: 475 },
{ time: "15:00", desc: "30г протеинового батончика", kcal: 80 },
{ time: "20:00", desc: "картошка, горошек, 15г колбасок, 2 чая", kcal: 320 }
],
activity: [
{ time: "11:00", desc: "прогулка", minutes: 60, kcal: 260 },
{ time: "13:30", desc: "велосипед 10+ км", minutes: 70, kcal: 400 },
{ time: "14:50", desc: "подъём велика на 5 этаж", minutes: 3, kcal: 50 }
],
water: 0,
notes: "ванна 15:30-16:50"
},
// ===== 12.04 =====
{
date: "2026-04-12",
weights: [{ time: null, kg: 88.7 }],
sleep: { bed: null, asleep: null, wake: "09:00" },
food: [
{ time: "11:00", desc: "пюре 200г, сметана, 5 песочных печенек домашних", kcal: 560 },
{ time: "14:00", desc: "2 чая, 2 протеиновых батончика по 30г (80ккал)", kcal: 240 },
{ time: "18:00", desc: "100г кулича без изюма", kcal: 320 },
{ time: "20:00", desc: "пюре 150г, горошек 100г, 20г колбасок, 2 чая, 150г кулича", kcal: 845 }
],
activity: [
{ time: "10:00", desc: "зарядка", minutes: 20, kcal: 80 },
{ time: "16:50", desc: "прогулка", minutes: 40, kcal: 175 },
{ time: "18:00", desc: "зарядка", minutes: 15, kcal: 60 }
],
water: 0,
notes: ""
},
// ===== 13.04 =====
{
date: "2026-04-13",
weights: [{ time: null, kg: 88.7 }],
sleep: { bed: null, asleep: "22:14", wake: "09:20" },
food: [
{ time: "11:00", desc: "300г молока", kcal: 165 },
{ time: "12:30", desc: "150г молока, 200г кулича, протеиновый батончик 30г", kcal: 803 },
{ time: "19:20", desc: "творог+сметана, пирожное (бисквит+масло) 70г, 2 чая, 6 кусочков колбасы, 5 кусочков батона", kcal: 965 }
],
activity: [
{ time: "12:50", desc: "велосипед", minutes: 65, kcal: 400 },
{ time: "14:00", desc: "подъём велика на 5 этаж, душ", minutes: 5, kcal: 50 },
{ time: "18:00", desc: "прогулка + магазин", minutes: 60, kcal: 260 }
],
water: 0,
notes: ""
},
// ===== 14.04 =====
{
date: "2026-04-14",
weights: [{ time: null, kg: 87.8 }],
sleep: { bed: null, asleep: null, wake: "09:20" },
food: [
{ time: "10:20", desc: "яблоко 200г, молоко 350мл, протеиновый батончик", kcal: 367 },
{ time: null, desc: "творог 200г со сметаной, 4 кружки чая, колбаса 50г", kcal: 610 },
{ time: null, desc: "лапша б/п (без бульона), 3 кусочка хлеба с плавленным сыром", kcal: 590 }
],
activity: [],
water: 0,
notes: ""
},
// ===== 15.04 =====
{
date: "2026-04-15",
weights: [],
sleep: { bed: "02:30", asleep: "02:30", wake: "09:20" },
food: [
{ time: "10:30", desc: "протеиновый батончик, 350мл молока", kcal: 273 },
{ time: "13:20", desc: "гречка с молоком, чай, витамины, омега", kcal: 410 },
{ time: "19:00", desc: "чай, домашние вафли с цукатами", kcal: 390 }
],
activity: [
{ time: "17:40", desc: "велосипед 10км", minutes: 50, kcal: 350 }
],
water: 0,
notes: "кодинг до 02:30"
},
// ===== 16.04 =====
{
date: "2026-04-16",
weights: [],
sleep: { bed: "22:40", asleep: "22:40", wake: null },
food: [
{ time: "10:30", desc: "протеиновый батончик, 350мл молока", kcal: 273 },
{ time: "12:40", desc: "макароны с сыром, домашняя вафля", kcal: 500 },
{ time: "13:30", desc: "чай 350мл", kcal: 40 },
{ time: "20:00", desc: "гречка с молоком, чай, плавленый сыр 90г, 15шт арахиса", kcal: 750 }
],
activity: [
{ time: "19:00", desc: "прогулка", minutes: 40, kcal: 175 }
],
water: 0,
notes: ""
},
// ===== 17.04 =====
{
date: "2026-04-17",
weights: [{ time: null, kg: 87.6 }],
sleep: { bed: null, asleep: null, wake: null, nap_minutes: 30 },
food: [
{ time: "10:30", desc: "арахис 30г, молоко", kcal: 286 },
{ time: "12:30", desc: "картошка, маринованный перец (испорчен)", kcal: 145 },
{ time: "20:20", desc: "пломбир в шоколаде с орехами 65г, чай", kcal: 235 }
],
activity: [
{ time: "16:10", desc: "прогулка", minutes: 60, kcal: 260 },
{ time: "18:50", desc: "прогулка", minutes: 30, kcal: 130 }
],
water: 0,
notes: "отравление маринованным перцем, пронесло в 16:00 и 17:10"
},
// ===== 18.04 =====
{
date: "2026-04-18",
weights: [],
sleep: { bed: "22:00", asleep: "22:00", wake: "08:40" },
food: [
{ time: null, desc: "2 огурца, немного сушек, яблоко, 2 кружки чая (весь день)", kcal: 350 }
],
activity: [],
water: 1,
notes: "температура 36.8-37.0, много спал, собака воет за стенкой 9-21, стресс, болит голова от лежания и недостатка еды. Ночью проснулся в 02:30, комп до 04:30, затем сон"
},
// ===== 19.04 =====
{
date: "2026-04-19",
weights: [{ time: null, kg: 86.7 }],
sleep: { bed: null, asleep: "00:00", wake: "09:20" },
food: [
{ time: null, desc: "огурец, десяток редисок, творог со сметаной и сахаром, много чая, газировка 0.5л, жареная курица в специях", kcal: 845 }
],
activity: [
{ time: null, desc: "прогулка", minutes: 30, kcal: 130 },
{ time: null, desc: "распил ХДФ лобзиком, сборка рамы шкафа", minutes: 60, kcal: 200 },
{ time: null, desc: "зарядка", minutes: 20, kcal: 80 }
],
water: 0,
notes: "тело тяжёлое, много лежал"
},
// ===== 20.04 =====
{
date: "2026-04-20",
weights: [{ time: null, kg: 87.0 }],
sleep: { bed: null, asleep: null, wake: "09:20" },
food: [
{ time: "10:00", desc: "чай", kcal: 40 }
],
activity: [],
water: 0,
notes: "неполные данные"
},
// ===== 27.04 =====
{
date: "2026-04-27",
weights: [{ time: null, kg: 88.0 }],
sleep: { bed: null, asleep: null, wake: null },
food: [],
activity: [],
water: 0,
notes: "только вес (был 86.7, поел много)"
}
]
};

View file

@ -1208,6 +1208,41 @@ body {
border-radius: 4px; border-radius: 4px;
} }
.chart-wrap {
position: relative;
cursor: pointer;
height: 200px;
width: 100%;
}
.chart-full {
position: fixed;
left: 0;
top: var(--safe-area-top);
z-index: 9999;
background: var(--bg);
width: 100vw;
height: calc(100vh - var(--safe-area-top) - var(--safe-area-bottom));
}
.chart-close-btn {
position: absolute;
top: calc(12px + var(--safe-area-top));
right: calc(12px + var(--safe-area-right, 0px));
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--on-bg);
font-size: 18px;
cursor: pointer;
z-index: 10000;
}
.gps-download-status { .gps-download-status {
text-align: center; text-align: center;
font-size: 12px; font-size: 12px;

View file

@ -13,8 +13,8 @@
<!-- Load order matters: db.js and bmr.js expose window.DB / window.BMR, <!-- Load order matters: db.js and bmr.js expose window.DB / window.BMR,
then main.js uses them. Tauri injects window.__TAURI__ before page scripts. --> then main.js uses them. Tauri injects window.__TAURI__ before page scripts. -->
<script src="lib/leaflet.js"></script> <script src="lib/leaflet.js"></script>
<script src="lib/chart.min.js"></script>
<script src="lib/hammer.min.js"></script> <script src="lib/hammer.min.js"></script>
<script src="lib/chart.min.js"></script>
<script src="lib/chartjs-plugin-zoom.min.js"></script> <script src="lib/chartjs-plugin-zoom.min.js"></script>
<script src="lib/jszip.min.js"></script> <script src="lib/jszip.min.js"></script>
<script src="js/db.js"></script> <script src="js/db.js"></script>

View file

@ -362,16 +362,23 @@ GPS.startRecording = async function(activityType, weightKg) {
// Poll native service output file // Poll native service output file
if (GPS.recording.nativeService) { if (GPS.recording.nativeService) {
let polling = false;
GPS.recording._pollTimer = setInterval(async () => { GPS.recording._pollTimer = setInterval(async () => {
if (!GPS.recording.active) { clearInterval(GPS.recording._pollTimer); return; } if (!GPS.recording.active) { clearInterval(GPS.recording._pollTimer); return; }
const nativePoints = await GPS._readNativePoints(); if (polling) return; // skip if previous poll still running
// Process only points newer than last processed (by timestamp) polling = true;
for (const p of nativePoints) { try {
const pTs = new Date(p.ts).getTime(); const nativePoints = await GPS._readNativePoints();
if (pTs <= GPS.recording._lastProcessedTs) continue; for (const p of nativePoints) {
await GPS._processIncomingPoint(p); const pTs = new Date(p.ts).getTime();
GPS.recording._lastProcessedTs = pTs; if (pTs <= GPS.recording._lastProcessedTs) continue;
await GPS._processIncomingPoint(p);
GPS.recording._lastProcessedTs = pTs;
}
} catch (e) {
console.error("[GPS] poll error:", e);
} }
polling = false;
}, 2000); }, 2000);
} }
@ -689,9 +696,14 @@ GPS.exportGPX = async function(sessionId) {
const esc = s => String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;"); const esc = s => String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");
const name = esc(session.activity_type || "Track") + " " + esc(session.date); const name = esc(session.activity_type || "Track") + " " + esc(session.date);
const cfg = GPS.config;
let gpx = `<?xml version="1.0" encoding="UTF-8"?> let gpx = `<?xml version="1.0" encoding="UTF-8"?>
<gpx creator="BalDav" version="1.1" xmlns="http://www.topografix.com/GPX/1/1"> <gpx creator="BalDav" version="1.1" xmlns="http://www.topografix.com/GPX/1/1">
<metadata><name>${name}</name><time>${points[0].ts}</time></metadata> <metadata>
<name>${name}</name>
<time>${points[0].ts}</time>
<desc>minSpeed=${(cfg.minSpeedMs*3.6).toFixed(1)}kmh maxAcc=${cfg.minAccuracyM}m minDist=${cfg.minDistanceM}m points=${points.length}</desc>
</metadata>
<trk> <trk>
<name>${name}</name> <name>${name}</name>
<type>${esc(session.activity_type || "other")}</type> <type>${esc(session.activity_type || "other")}</type>
@ -711,16 +723,15 @@ GPS.exportGPX = async function(sessionId) {
</trk> </trk>
</gpx>`; </gpx>`;
// Download // Save via Rust to Downloads folder
const blob = new Blob([gpx], { type: "application/gpx+xml" }); const time = (session.start_time || "").replace(/:/g, "-");
const url = URL.createObjectURL(blob); const filename = `baldav-${session.date}_${time}-${session.activity_type || "track"}.gpx`;
const a = document.createElement("a"); try {
a.href = url; const path = await window.__TAURI__.core.invoke("save_file", { filename, content: gpx });
a.download = `baldav-${session.date}-${session.activity_type || "track"}.gpx`; console.log("[GPX] Saved to:", path);
document.body.appendChild(a); } catch (e) {
a.click(); console.error("[GPX] Save error:", e);
document.body.removeChild(a); }
URL.revokeObjectURL(url);
}; };
window.GPS = GPS; window.GPS = GPS;

View file

@ -308,20 +308,30 @@ function showModal(html, onSubmit) {
history.pushState({ modal: true }, ""); history.pushState({ modal: true }, "");
const popHandler = () => { const popHandler = () => {
// If fullscreen map is open inside this modal, collapse it instead of closing modal // Fullscreen map inside modal? Collapse it first.
const fsMap = overlay.querySelector(".sd-map-full"); const fsMap = overlay.querySelector(".sd-map-full");
if (fsMap) { if (fsMap) {
fsMap.classList.remove("sd-map-full"); fsMap.classList.remove("sd-map-full");
const expandBtn = fsMap.querySelector(".sd-map-expand-btn"); const expandBtn = fsMap.querySelector(".sd-map-expand-btn");
if (expandBtn) expandBtn.innerHTML = "&#x26F6;"; if (expandBtn) expandBtn.innerHTML = "&#x26F6;";
try { map.dragging.disable(); } catch {} try { map.dragging.disable(); map.scrollWheelZoom.disable(); } catch {}
history.pushState({ modal: true }, ""); history.pushState({ modal: true }, "");
window.addEventListener("popstate", popHandler, { once: true });
return; return;
} }
// Fullscreen chart inside modal? Collapse it first.
const fsChart = document.querySelector(".chart-full");
if (fsChart) {
fsChart.classList.remove("chart-full");
const btn = fsChart.querySelector(".chart-close-btn");
if (btn) btn.remove();
history.pushState({ modal: true }, "");
return;
}
// Nothing fullscreen — close modal
window.removeEventListener("popstate", popHandler);
closeModal(false); closeModal(false);
}; };
window.addEventListener("popstate", popHandler, { once: true }); window.addEventListener("popstate", popHandler);
function closeModal(goBack) { function closeModal(goBack) {
overlay.remove(); overlay.remove();
@ -1244,7 +1254,7 @@ async function showSessionDetail(sessionId) {
<div class="sd-stat"><span class="sd-val">${stats.avgSpeed} км/ч</span><span class="sd-lbl">Средняя</span></div> <div class="sd-stat"><span class="sd-val">${stats.avgSpeed} км/ч</span><span class="sd-lbl">Средняя</span></div>
<div class="sd-stat"><span class="sd-val" id="sd-max-speed">${stats.maxSpeed} км/ч</span><span class="sd-lbl">Макс.</span></div> <div class="sd-stat"><span class="sd-val" id="sd-max-speed">${stats.maxSpeed} км/ч</span><span class="sd-lbl">Макс.</span></div>
</div> </div>
<canvas id="sd-speed-chart" height="150" style="touch-action:pan-y"></canvas> <div class="chart-wrap" id="sd-speed-wrap"><canvas id="sd-speed-chart" height="150" style="touch-action:pan-y"></canvas></div>
</div> </div>
<div class="sd-section"> <div class="sd-section">
@ -1266,7 +1276,7 @@ async function showSessionDetail(sessionId) {
<div class="sd-stat"><span class="sd-val">${stats.elevGain} м</span><span class="sd-lbl">Подъём</span></div> <div class="sd-stat"><span class="sd-val">${stats.elevGain} м</span><span class="sd-lbl">Подъём</span></div>
<div class="sd-stat"><span class="sd-val">${stats.elevLoss} м</span><span class="sd-lbl">Спуск</span></div> <div class="sd-stat"><span class="sd-val">${stats.elevLoss} м</span><span class="sd-lbl">Спуск</span></div>
</div> </div>
<canvas id="sd-elev-chart" height="150" style="touch-action:pan-y"></canvas> <div class="chart-wrap" id="sd-elev-wrap"><canvas id="sd-elev-chart" height="150" style="touch-action:pan-y"></canvas></div>
</div>` : ""} </div>` : ""}
<button id="sd-export" class="btn-secondary full-width" style="margin-top:16px">Экспорт GPX</button> <button id="sd-export" class="btn-secondary full-width" style="margin-top:16px">Экспорт GPX</button>
@ -1344,11 +1354,13 @@ async function showSessionDetail(sessionId) {
if (mapIsFullscreen) { if (mapIsFullscreen) {
collapseMap(); collapseMap();
map.dragging.disable(); map.dragging.disable();
map.scrollWheelZoom.disable();
} else { } else {
mapIsFullscreen = true; mapIsFullscreen = true;
mapEl.classList.add("sd-map-full"); mapEl.classList.add("sd-map-full");
fsDiv.innerHTML = "&#x2715;"; fsDiv.innerHTML = "&#x2715;";
map.dragging.enable(); map.dragging.enable();
map.scrollWheelZoom.enable(); // zoom without ctrl in fullscreen
setTimeout(() => { map.invalidateSize(); map.fitBounds(L.latLngBounds(allCoords).pad(0.05)); }, 100); setTimeout(() => { map.invalidateSize(); map.fitBounds(L.latLngBounds(allCoords).pad(0.05)); }, 100);
} }
} }
@ -1391,6 +1403,26 @@ async function showSessionDetail(sessionId) {
}); });
}, 200); }, 200);
// Downsample arrays for smooth chart rendering (max ~500 points)
function downsample(arr, maxPts) {
if (arr.length <= maxPts) return arr;
const step = arr.length / maxPts;
const result = [];
for (let i = 0; i < maxPts; i++) {
const idx = Math.floor(i * step);
result.push(arr[idx]);
}
return result;
}
const MAX_CHART_PTS = 500;
const dsSmoothed = downsample(smoothed, MAX_CHART_PTS);
const dsTimeLabels = downsample(timeLabels, MAX_CHART_PTS);
const dsElevations = downsample(elevations, MAX_CHART_PTS);
const dsElevLabels = downsample(
points.filter(p => p.altitude_gps_m != null).map(p => ((new Date(p.ts).getTime() - t0) / 60000).toFixed(1)),
MAX_CHART_PTS
);
// Render speed chart // Render speed chart
setTimeout(() => { setTimeout(() => {
const ctx = document.querySelector("#sd-speed-chart"); const ctx = document.querySelector("#sd-speed-chart");
@ -1401,9 +1433,9 @@ async function showSessionDetail(sessionId) {
new Chart(ctx, { new Chart(ctx, {
type: "line", type: "line",
data: { data: {
labels: timeLabels, labels: dsTimeLabels,
datasets: [{ datasets: [{
data: smoothed, data: dsSmoothed,
borderColor: "#bb86fc", borderColor: "#bb86fc",
borderWidth: 1.5, borderWidth: 1.5,
pointRadius: 0, pointRadius: 0,
@ -1412,11 +1444,16 @@ async function showSessionDetail(sessionId) {
}], }],
}, },
options: { options: {
responsive: true, responsive: true, maintainAspectRatio: false,
plugins: { plugins: {
legend: { display: false }, legend: { display: false },
zoom: { zoom: {
zoom: { wheel: { enabled: true, modifierKey: "ctrl" }, pinch: { enabled: true }, drag: { enabled: false }, mode: "x" }, zoom: {
wheel: { enabled: true, modifierKey: "ctrl", speed: 0.02 },
pinch: { enabled: true },
mode: "x",
limits: { x: { minRange: 0.5 } },
},
pan: { enabled: false }, pan: { enabled: false },
}, },
}, },
@ -1439,9 +1476,9 @@ async function showSessionDetail(sessionId) {
new Chart(ctx, { new Chart(ctx, {
type: "line", type: "line",
data: { data: {
labels: elevLabels, labels: dsElevLabels,
datasets: [{ datasets: [{
data: elevations, data: dsElevations,
borderColor: "#ffb74d", borderColor: "#ffb74d",
borderWidth: 1.5, borderWidth: 1.5,
pointRadius: 0, pointRadius: 0,
@ -1451,11 +1488,16 @@ async function showSessionDetail(sessionId) {
}], }],
}, },
options: { options: {
responsive: true, responsive: true, maintainAspectRatio: false,
plugins: { plugins: {
legend: { display: false }, legend: { display: false },
zoom: { zoom: {
zoom: { wheel: { enabled: true, modifierKey: "ctrl" }, pinch: { enabled: true }, drag: { enabled: false }, mode: "x" }, zoom: {
wheel: { enabled: true, modifierKey: "ctrl", speed: 0.02 },
pinch: { enabled: true },
mode: "x",
limits: { x: { minRange: 0.5 } },
},
pan: { enabled: false }, pan: { enabled: false },
}, },
}, },
@ -1474,6 +1516,25 @@ async function showSessionDetail(sessionId) {
const exp = document.querySelector("#sd-export"); const exp = document.querySelector("#sd-export");
if (exp) exp.onclick = () => GPS.exportGPX(sessionId); if (exp) exp.onclick = () => GPS.exportGPX(sessionId);
// Chart fullscreen toggle — tap to expand, tap X or back to collapse
document.querySelectorAll(".chart-wrap").forEach(wrap => {
wrap.onclick = () => {
if (wrap.classList.contains("chart-full")) return;
wrap.classList.add("chart-full");
// Push history so back gesture collapses chart, not modal
history.pushState({ chartFull: true }, "");
const closeBtn = document.createElement("div");
closeBtn.className = "chart-close-btn";
closeBtn.innerHTML = "&#x2715;";
closeBtn.onclick = (e) => {
e.stopPropagation();
wrap.classList.remove("chart-full");
closeBtn.remove();
};
wrap.appendChild(closeBtn);
};
});
const del = document.querySelector("#sd-delete"); const del = document.querySelector("#sd-delete");
if (del) del.onclick = async () => { if (del) del.onclick = async () => {
await DB.deleteGpsSession(sessionId); await DB.deleteGpsSession(sessionId);

13
index.html Normal file
View file

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0" />
<title>BalDav</title>
<link rel="stylesheet" href="/src/styles.css" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

1849
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

2
src-tauri/Cargo.lock generated
View file

@ -128,7 +128,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]] [[package]]
name = "baldav" name = "baldav"
version = "0.1.59" version = "0.1.74"
dependencies = [ dependencies = [
"rusqlite", "rusqlite",
"serde", "serde",

View file

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

View file

@ -56,6 +56,28 @@ fn gps_service_read_points(app: tauri::AppHandle) -> Result<String, String> {
} }
} }
#[tauri::command]
fn save_file(app: tauri::AppHandle, filename: String, content: String) -> Result<String, String> {
let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
std::fs::create_dir_all(&data_dir).ok();
// Write content to temp file for Kotlin to read
let content_path = data_dir.join("save_content.txt");
std::fs::write(&content_path, &content).map_err(|e| e.to_string())?;
// Send save command to Kotlin (triggers SAF file picker on Android)
let cmd_path = data_dir.join("gps_command.txt");
std::fs::write(&cmd_path, format!("save:{}", filename)).map_err(|e| e.to_string())?;
// Also write to app root for Kotlin FileObserver
if let Some(root) = data_dir.parent().and_then(|p| p.parent()) {
let _ = std::fs::write(root.join("save_content.txt"), &content);
let _ = std::fs::write(root.join("gps_command.txt"), format!("save:{}", filename));
}
Ok("save_requested".to_string())
}
#[tauri::command] #[tauri::command]
fn import_seed_products(app: tauri::AppHandle) -> Result<String, String> { fn import_seed_products(app: tauri::AppHandle) -> Result<String, String> {
// Find seed DB — try multiple locations // Find seed DB — try multiple locations
@ -392,7 +414,7 @@ pub fn run() {
.add_migrations("sqlite:baldav.db", migrations) .add_migrations("sqlite:baldav.db", migrations)
.build(), .build(),
) )
.invoke_handler(tauri::generate_handler![get_safe_insets, import_seed_products, get_app_info, gps_service_command, gps_service_read_points]) .invoke_handler(tauri::generate_handler![get_safe_insets, import_seed_products, get_app_info, gps_service_command, gps_service_read_points, save_file])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
} }

View file

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

35
src/activity-table.js Normal file
View file

@ -0,0 +1,35 @@
// Activity calorie expenditure rates
// kcal_per_min_per_kg: calories burned per minute per kg of body weight
// (beyond basal metabolic rate)
// default_min: typical duration in minutes
export const ACTIVITIES = [
{ key: "walk", name: "прогулка", aliases: ["прогулк", "ходьб", "гулял", "пешком"], kcal_min_kg: 0.05, default_min: 30 },
{ key: "bike", name: "велосипед", aliases: ["велосипед", "вело", "велик"], kcal_min_kg: 0.08, default_min: 60 },
{ key: "run", name: "бег", aliases: ["бег", "пробежк", "бежал"], kcal_min_kg: 0.12, default_min: 30 },
{ key: "sleep", name: "сон", aliases: ["сон", "спал", "сплю"], kcal_min_kg: 0.015, default_min: 480 },
{ key: "chore", name: "домашние дела", aliases: ["убор", "уборк", "стирк", "готовк", "мыл"], kcal_min_kg: 0.04, default_min: 30 },
{ key: "desk_work", name: "работа за столом", aliases: ["работ", "сидел"], kcal_min_kg: 0.02, default_min: 60 },
{ key: "stairs", name: "лестница", aliases: ["лестниц", "ступен"], kcal_min_kg: 0.10, default_min: 5 },
];
export const ACTIVITY_MAP = Object.fromEntries(
ACTIVITIES.map((a) => [a.key, a])
);
export function matchActivity(text) {
const lower = text.toLowerCase();
for (const act of ACTIVITIES) {
for (const alias of act.aliases) {
if (lower.includes(alias.toLowerCase())) {
return act;
}
}
}
return null;
}
// Calculate kcal for an activity given weight and duration
export function activityKcal(activity, weightKg, minutes) {
return Math.round(activity.kcal_min_kg * weightKg * minutes);
}

14
src/bmr.js Normal file
View file

@ -0,0 +1,14 @@
// Mifflin-St Jeor equation for Basal Metabolic Rate
// Most accurate BMR formula validated by research (Mifflin et al., 1990)
//
// Males: BMR = 10 * weight(kg) + 6.25 * height(cm) - 5 * age(years) - 5
// Females: BMR = 10 * weight(kg) + 6.25 * height(cm) - 5 * age(years) + 161
export function calculateBmr(weightKg, heightCm, age, sex) {
const base = 10 * weightKg + 6.25 * heightCm - 5 * age;
return Math.round(sex === "m" ? base - 5 : base + 161);
}
export function getAge(birthYear) {
return new Date().getFullYear() - birthYear;
}

43
src/date-utils.js Normal file
View file

@ -0,0 +1,43 @@
const DAY_NAMES = ["вс", "пн", "вт", "ср", "чт", "пт", "сб"];
const MONTH_NAMES = [
"января", "февраля", "марта", "апреля", "мая", "июня",
"июля", "августа", "сентября", "октября", "ноября", "декабря",
];
// Format: "2026-04-05"
export function toIsoDate(date) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
// Format: "сб, 5 апреля"
export function toHumanDate(dateStr) {
const d = new Date(dateStr + "T00:00:00");
const day = DAY_NAMES[d.getDay()];
const num = d.getDate();
const month = MONTH_NAMES[d.getMonth()];
return `${day}, ${num} ${month}`;
}
export function today() {
return toIsoDate(new Date());
}
export function shiftDate(dateStr, days) {
const d = new Date(dateStr + "T00:00:00");
d.setDate(d.getDate() + days);
return toIsoDate(d);
}
export function currentTime() {
const now = new Date();
const h = String(now.getHours()).padStart(2, "0");
const m = String(now.getMinutes()).padStart(2, "0");
return `${h}:${m}`;
}
export function isToday(dateStr) {
return dateStr === today();
}

338
src/db.js Normal file
View file

@ -0,0 +1,338 @@
// Database layer — wraps Tauri SQL plugin
// All queries go through this module; no raw SQL elsewhere
let db = null;
export async function initDb() {
const Database = (await import("@tauri-apps/plugin-sql")).default;
db = await Database.load("sqlite:baldav.db");
return db;
}
export async function getDb() {
if (!db) await initDb();
return db;
}
// ---------------------------------------------------------------------------
// Profile (key-value store)
// ---------------------------------------------------------------------------
export async function getProfileValue(key) {
const d = await getDb();
const rows = await d.select("SELECT value FROM profile WHERE key = $1", [key]);
return rows.length ? rows[0].value : null;
}
export async function setProfileValue(key, value) {
const d = await getDb();
await d.execute(
"INSERT OR REPLACE INTO profile (key, value) VALUES ($1, $2)",
[key, String(value)]
);
}
export async function getFullProfile() {
const d = await getDb();
const rows = await d.select("SELECT key, value FROM profile");
const p = {};
for (const r of rows) p[r.key] = r.value;
return p;
}
// ---------------------------------------------------------------------------
// Weight log
// ---------------------------------------------------------------------------
export async function addWeight(date, time, kg) {
const d = await getDb();
await d.execute(
"INSERT INTO weight_log (date, time, weight_kg) VALUES ($1, $2, $3)",
[date, time, kg]
);
}
export async function deleteWeight(id) {
const d = await getDb();
await d.execute("DELETE FROM weight_log WHERE id = $1", [id]);
}
export async function getWeightsForDate(date) {
const d = await getDb();
return d.select(
"SELECT * FROM weight_log WHERE date = $1 ORDER BY time ASC",
[date]
);
}
export async function getLatestWeight() {
const d = await getDb();
const rows = await d.select(
"SELECT * FROM weight_log ORDER BY date DESC, time DESC LIMIT 1"
);
return rows.length ? rows[0] : null;
}
export async function getWeightsRange(from, to) {
const d = await getDb();
return d.select(
"SELECT * FROM weight_log WHERE date >= $1 AND date <= $2 ORDER BY date ASC, time ASC",
[from, to]
);
}
// ---------------------------------------------------------------------------
// Sleep log
// ---------------------------------------------------------------------------
export async function getSleepForDate(date) {
const d = await getDb();
const rows = await d.select("SELECT * FROM sleep_log WHERE date = $1", [date]);
return rows.length ? rows[0] : null;
}
export async function saveSleep(date, bedTime, asleepTime, wakeTime, napMinutes, notes) {
const d = await getDb();
const existing = await getSleepForDate(date);
if (existing) {
await d.execute(
`UPDATE sleep_log SET bed_time=$1, asleep_time=$2, wake_time=$3,
nap_minutes=$4, notes=$5 WHERE date=$6`,
[bedTime, asleepTime, wakeTime, napMinutes || 0, notes || null, date]
);
} else {
await d.execute(
`INSERT INTO sleep_log (date, bed_time, asleep_time, wake_time, nap_minutes, notes)
VALUES ($1, $2, $3, $4, $5, $6)`,
[date, bedTime, asleepTime, wakeTime, napMinutes || 0, notes || null]
);
}
}
// ---------------------------------------------------------------------------
// Products
// ---------------------------------------------------------------------------
export async function searchProducts(query, limit = 30) {
const d = await getDb();
return d.select(
`SELECT id, name, brand, category, kcal_per_100g, protein_per_100g,
fat_per_100g, carbs_per_100g, fiber_per_100g
FROM products
WHERE name LIKE $1 OR brand LIKE $1
ORDER BY name COLLATE NOCASE
LIMIT $2`,
[`%${query}%`, limit]
);
}
export async function getProduct(id) {
const d = await getDb();
const rows = await d.select("SELECT * FROM products WHERE id = $1", [id]);
return rows.length ? rows[0] : null;
}
export async function addProduct(p) {
const d = await getDb();
const r = await d.execute(
`INSERT INTO products
(name, brand, category, weight_g, kcal_per_100g, protein_per_100g,
fat_per_100g, carbs_per_100g, fiber_per_100g,
vit_a_mcg, vit_c_mg, vit_d_mcg, vit_e_mg, vit_k_mcg,
vit_b1_mg, vit_b2_mg, vit_b3_mg, vit_b6_mg, vit_b12_mcg, folate_mcg,
calcium_mg, iron_mg, magnesium_mg, zinc_mg, copper_mg,
selenium_mcg, potassium_mg, sodium_mg, source)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29)`,
[
p.name, p.brand ?? null, p.category ?? null, p.weight_g ?? null,
p.kcal_per_100g ?? null, p.protein_per_100g ?? null,
p.fat_per_100g ?? null, p.carbs_per_100g ?? null, p.fiber_per_100g ?? null,
p.vit_a_mcg ?? null, p.vit_c_mg ?? null, p.vit_d_mcg ?? null,
p.vit_e_mg ?? null, p.vit_k_mcg ?? null,
p.vit_b1_mg ?? null, p.vit_b2_mg ?? null, p.vit_b3_mg ?? null,
p.vit_b6_mg ?? null, p.vit_b12_mcg ?? null, p.folate_mcg ?? null,
p.calcium_mg ?? null, p.iron_mg ?? null, p.magnesium_mg ?? null,
p.zinc_mg ?? null, p.copper_mg ?? null,
p.selenium_mcg ?? null, p.potassium_mg ?? null, p.sodium_mg ?? null,
p.source ?? "manual",
]
);
return r.lastInsertId;
}
// ---------------------------------------------------------------------------
// Food log
// ---------------------------------------------------------------------------
export async function getFoodForDate(date) {
const d = await getDb();
return d.select(
`SELECT fl.*, p.name as product_name, p.brand as product_brand,
r.name as recipe_name
FROM food_log fl
LEFT JOIN products p ON fl.product_id = p.id
LEFT JOIN recipes r ON fl.recipe_id = r.id
WHERE fl.date = $1
ORDER BY fl.time ASC`,
[date]
);
}
// Add a food log entry. Calculates macros from product if product_id given.
export async function addFoodEntry(date, time, productId, recipeId, amountG, note) {
const d = await getDb();
let kcal = null, protein = null, fat = null, carbs = null, fiber = null;
if (productId) {
const p = await getProduct(productId);
if (p && p.kcal_per_100g != null) {
const f = amountG / 100;
kcal = Math.round((p.kcal_per_100g ?? 0) * f);
protein = Math.round((p.protein_per_100g ?? 0) * f * 10) / 10;
fat = Math.round((p.fat_per_100g ?? 0) * f * 10) / 10;
carbs = Math.round((p.carbs_per_100g ?? 0) * f * 10) / 10;
fiber = Math.round((p.fiber_per_100g ?? 0) * f * 10) / 10;
}
}
await d.execute(
`INSERT INTO food_log
(date, time, product_id, recipe_id, amount_g, kcal, protein_g, fat_g, carbs_g, fiber_g, note)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`,
[date, time, productId ?? null, recipeId ?? null, amountG,
kcal, protein, fat, carbs, fiber, note ?? null]
);
}
// Add food by manual kcal only (for backward compat with old text entries)
export async function addFoodManual(date, time, desc, kcal) {
const d = await getDb();
await d.execute(
`INSERT INTO food_log (date, time, note, amount_g, kcal)
VALUES ($1, $2, $3, 0, $4)`,
[date, time, desc, kcal ?? null]
);
}
export async function deleteFoodEntry(id) {
const d = await getDb();
await d.execute("DELETE FROM food_log WHERE id = $1", [id]);
}
// ---------------------------------------------------------------------------
// Activity log
// ---------------------------------------------------------------------------
export async function getActivitiesForDate(date) {
const d = await getDb();
return d.select(
"SELECT * FROM activity_log WHERE date = $1 ORDER BY time ASC",
[date]
);
}
export async function addActivity(date, time, type, desc, minutes, kcal) {
const d = await getDb();
await d.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]
);
}
export async function deleteActivity(id) {
const d = await getDb();
await d.execute("DELETE FROM activity_log WHERE id = $1", [id]);
}
// ---------------------------------------------------------------------------
// Supplements
// ---------------------------------------------------------------------------
export async function getSupplements() {
const d = await getDb();
return d.select("SELECT * FROM supplements ORDER BY name COLLATE NOCASE");
}
export async function addSupplement(s) {
const d = await getDb();
const r = await d.execute(
`INSERT INTO supplements
(name, daily_dose_g, vit_a_mcg, vit_c_mg, vit_d_mcg, vit_e_mg, vit_k_mcg,
vit_b1_mg, vit_b2_mg, vit_b3_mg, vit_b6_mg, vit_b12_mcg, folate_mcg,
calcium_mg, iron_mg, magnesium_mg, zinc_mg, copper_mg, selenium_mcg,
potassium_mg, sodium_mg)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21)`,
[
s.name, s.daily_dose_g ?? null,
s.vit_a_mcg ?? null, s.vit_c_mg ?? null, s.vit_d_mcg ?? null,
s.vit_e_mg ?? null, s.vit_k_mcg ?? null,
s.vit_b1_mg ?? null, s.vit_b2_mg ?? null, s.vit_b3_mg ?? null,
s.vit_b6_mg ?? null, s.vit_b12_mcg ?? null, s.folate_mcg ?? null,
s.calcium_mg ?? null, s.iron_mg ?? null, s.magnesium_mg ?? null,
s.zinc_mg ?? null, s.copper_mg ?? null, s.selenium_mcg ?? null,
s.potassium_mg ?? null, s.sodium_mg ?? null,
]
);
return r.lastInsertId;
}
export async function logSupplement(date, time, supplementId, doseG) {
const d = await getDb();
await d.execute(
"INSERT INTO supplement_log (date, time, supplement_id, dose_g) VALUES ($1,$2,$3,$4)",
[date, time, supplementId, doseG ?? null]
);
}
export async function getSupplementLogForDate(date) {
const d = await getDb();
return d.select(
`SELECT sl.*, s.name FROM supplement_log sl
JOIN supplements s ON sl.supplement_id = s.id
WHERE sl.date = $1 ORDER BY sl.time ASC`,
[date]
);
}
// ---------------------------------------------------------------------------
// Analytics helpers
// ---------------------------------------------------------------------------
// Returns {kcal, protein_g, fat_g, carbs_g, fiber_g} totals for a date
export async function getFoodTotalsForDate(date) {
const d = await getDb();
const rows = await d.select(
`SELECT
SUM(kcal) as kcal,
SUM(protein_g) as protein_g,
SUM(fat_g) as fat_g,
SUM(carbs_g) as carbs_g,
SUM(fiber_g) as fiber_g
FROM food_log WHERE date = $1`,
[date]
);
return rows[0] || { kcal: 0, protein_g: 0, fat_g: 0, carbs_g: 0, fiber_g: 0 };
}
export async function getActivityTotalsForDate(date) {
const d = await getDb();
const rows = await d.select(
"SELECT SUM(kcal) as kcal, SUM(minutes) as minutes FROM activity_log WHERE date = $1",
[date]
);
return rows[0] || { kcal: 0, minutes: 0 };
}
// Weight data for charts: last N days
export async function getRecentWeights(days = 30) {
const d = await getDb();
return d.select(
`SELECT date, MIN(weight_kg) as weight_kg
FROM weight_log
WHERE date >= date('now', $1)
GROUP BY date
ORDER BY date ASC`,
[`-${days} days`]
);
}

91
src/food-table.js Normal file
View file

@ -0,0 +1,91 @@
// Calorie database for common Russian products
// kcal_per_100g: calories per 100 grams
// default_portion_g: typical serving size in grams
// aliases: alternative names/spellings for parser matching
export const FOODS = [
// --- Grains & starchy ---
{ key: "rice", name: "рис", aliases: ["рис", "рисов"], kcal: 130, portion: 200 },
{ key: "buckwheat", name: "гречка", aliases: ["гречк", "гречнев"], kcal: 110, portion: 200 },
{ key: "pasta", name: "макароны", aliases: ["макарон", "паст", "спагетти", "лапш"], kcal: 130, portion: 200 },
{ key: "potato", name: "картошка", aliases: ["картош", "картофел", "пюре"], kcal: 80, portion: 250 },
{ key: "potato_fried", name: "жареная картошка", aliases: [], kcal: 190, portion: 200 },
{ key: "bread_white", name: "хлеб белый", aliases: ["хлеб", "батон"], kcal: 250, portion: 30 },
{ key: "bread_black", name: "хлеб чёрный", aliases: ["чёрный хлеб", "бородинск"], kcal: 200, portion: 30 },
{ key: "corn_canned", name: "кукуруза конс.", aliases: ["кукуруз"], kcal: 80, portion: 100 },
{ key: "peas_canned", name: "горошек конс.", aliases: ["горош", "горох"], kcal: 70, portion: 100 },
// --- Dairy ---
{ key: "tvorog", name: "творог", aliases: ["творог", "творож"], kcal: 120, portion: 150 },
{ key: "smetana", name: "сметана 20%", aliases: ["сметан"], kcal: 200, portion: 20 },
{ key: "milk", name: "молоко", aliases: ["молок", "молоч"], kcal: 55, portion: 250 },
{ key: "cheese_45", name: "сыр 45%", aliases: ["сыр"], kcal: 350, portion: 30 },
{ key: "cheese_proc", name: "плавленный сыр", aliases: ["плавлен"], kcal: 300, portion: 30 },
{ key: "butter", name: "масло сливочное", aliases: ["сливочн", "масло слив"], kcal: 720, portion: 10 },
{ key: "kefir", name: "кефир", aliases: ["кефир"], kcal: 45, portion: 250 },
// --- Meat & sausage ---
{ key: "kolbasa", name: "колбаса", aliases: ["колбас", "докторск"], kcal: 300, portion: 50 },
{ key: "sausage_pork", name: "колбаса свиная", aliases: ["свин колбас"], kcal: 330, portion: 50 },
{ key: "sausage_beef", name: "колбаса говяжья", aliases: ["говяж колбас"], kcal: 250, portion: 50 },
{ key: "chicken", name: "курица", aliases: ["курин", "куриц", "курочк"], kcal: 165, portion: 150 },
{ key: "chicken_breast", name: "куриная грудка", aliases: ["грудк"], kcal: 110, portion: 150 },
{ key: "beef", name: "говядина", aliases: ["говядин"], kcal: 190, portion: 150 },
{ key: "pork", name: "свинина", aliases: ["свинин"], kcal: 260, portion: 150 },
{ key: "egg", name: "яйцо", aliases: ["яйц", "яичниц"], kcal: 155, portion: 60 },
{ key: "sosiska", name: "сосиска", aliases: ["сосиск"], kcal: 280, portion: 50 },
// --- Vegetables ---
{ key: "cabbage", name: "капуста", aliases: ["капуст"], kcal: 25, portion: 150 },
{ key: "carrot", name: "морковь", aliases: ["морков"], kcal: 35, portion: 100 },
{ key: "radish", name: "редис", aliases: ["редис", "редьк"], kcal: 16, portion: 100 },
{ key: "cucumber", name: "огурец", aliases: ["огурц", "огурец"], kcal: 15, portion: 100 },
{ key: "tomato", name: "помидор", aliases: ["помидор", "томат"], kcal: 20, portion: 120 },
{ key: "onion", name: "лук", aliases: ["лук", "луков"], kcal: 40, portion: 50 },
// --- Berries & fruit ---
{ key: "strawberry", name: "клубника", aliases: ["клубник"], kcal: 33, portion: 150 },
{ key: "raspberry", name: "малина", aliases: ["малин"], kcal: 40, portion: 150 },
{ key: "apple", name: "яблоко", aliases: ["яблок", "яблоч"], kcal: 47, portion: 150 },
{ key: "banana", name: "банан", aliases: ["банан"], kcal: 89, portion: 120 },
// --- Nuts & oils ---
{ key: "nuts_mixed", name: "орехи", aliases: ["орех", "орешк"], kcal: 600, portion: 30 },
{ key: "sunflower_oil", name: "подсолн. масло", aliases: ["подсолн", "раст масл", "растительн"], kcal: 900, portion: 15 },
// --- Sweets & drinks ---
{ key: "sugar", name: "сахар", aliases: ["сахар"], kcal: 400, portion: 5 },
{ key: "tea", name: "чай", aliases: ["чай", "чаю"], kcal: 0, portion: 300 },
{ key: "coffee", name: "кофе", aliases: ["кофе"], kcal: 2, portion: 200 },
{ key: "cola", name: "газировка", aliases: ["газировк", "кола", "пепси", "спрайт"], kcal: 42, portion: 330 },
{ key: "chips", name: "чипсы", aliases: ["чипс"], kcal: 530, portion: 50 },
{ key: "chocolate", name: "шоколад", aliases: ["шоколад"], kcal: 540, portion: 25 },
{ key: "cookie", name: "печенье", aliases: ["печень"], kcal: 430, portion: 30 },
// --- Soups & prepared ---
{ key: "soup", name: "суп", aliases: ["суп", "борщ", "щи", "рассольник"], kcal: 50, portion: 300 },
{ key: "porridge", name: "каша", aliases: ["каш"], kcal: 100, portion: 250 },
];
// Build a lookup map for fast access by key
export const FOOD_MAP = Object.fromEntries(FOODS.map((f) => [f.key, f]));
// Find matching food by description text
// Returns array of {food, matchLen} sorted by match length descending
export function matchFoods(text) {
const lower = text.toLowerCase();
const matches = [];
for (const food of FOODS) {
for (const alias of food.aliases) {
if (lower.includes(alias.toLowerCase())) {
matches.push({ food, matchLen: alias.length });
break;
}
}
}
// Longer matches first (more specific)
matches.sort((a, b) => b.matchLen - a.matchLen);
return matches.map((m) => m.food);
}

526
src/main.js Normal file
View file

@ -0,0 +1,526 @@
import { initDb, getFullProfile, setProfileValue, getLatestWeight,
getWeightsForDate, addWeight, deleteWeight,
getFoodForDate, addFoodManual, deleteFoodEntry,
getActivitiesForDate, addActivity, deleteActivity,
getFoodTotalsForDate, getActivityTotalsForDate,
getSleepForDate, saveSleep,
searchProducts } from "./db.js";
import { calculateBmr, getAge } from "./bmr.js";
import { today, currentTime } from "./date-utils.js";
const app = document.getElementById("app");
let state = {
screen: "today",
date: today(),
profile: {},
latestWeight: null,
};
// ---------------------------------------------------------------------------
// Boot
// ---------------------------------------------------------------------------
async function init() {
await initDb();
state.profile = await getFullProfile();
state.latestWeight = await getLatestWeight();
if (!state.profile.height_cm) {
renderScreen("settings");
} else {
renderScreen("today");
}
}
// ---------------------------------------------------------------------------
// Router
// ---------------------------------------------------------------------------
function renderScreen(name) {
state.screen = name;
const screens = {
today: renderToday,
products: renderProducts,
charts: renderCharts,
settings: renderSettings,
};
app.innerHTML = "";
const nav = buildNav(name);
app.appendChild(nav);
const content = document.createElement("div");
content.className = "screen-content";
app.appendChild(content);
screens[name](content);
}
function buildNav(active) {
const nav = document.createElement("nav");
nav.className = "bottom-nav";
const items = [
{ id: "today", label: "Сегодня" },
{ id: "products", label: "Продукты" },
{ id: "charts", label: "Графики" },
{ id: "settings", label: "Профиль" },
];
for (const item of items) {
const btn = document.createElement("button");
btn.className = "nav-btn" + (item.id === active ? " active" : "");
btn.textContent = item.label;
btn.onclick = () => renderScreen(item.id);
nav.appendChild(btn);
}
return nav;
}
// ---------------------------------------------------------------------------
// TODAY screen
// ---------------------------------------------------------------------------
async function renderToday(container) {
container.innerHTML = `<div class="loading">Загрузка...</div>`;
const [weights, food, activities, sleep, foodTotals, actTotals] =
await Promise.all([
getWeightsForDate(state.date),
getFoodForDate(state.date),
getActivitiesForDate(state.date),
getSleepForDate(state.date),
getFoodTotalsForDate(state.date),
getActivityTotalsForDate(state.date),
]);
const bmr = calcBmrForDay(weights);
container.innerHTML = `
<div class="day-header">
<button class="day-nav" id="prev-day">&#8592;</button>
<h2>${formatDate(state.date)}</h2>
<button class="day-nav" id="next-day">&#8594;</button>
</div>
<div class="summary-row">
<div class="summary-card">
<span class="summary-val">${Math.round(foodTotals.kcal ?? 0)}</span>
<span class="summary-lbl">ккал приход</span>
</div>
<div class="summary-card">
<span class="summary-val">${bmr != null ? bmr + Math.round(actTotals.kcal ?? 0) : "—"}</span>
<span class="summary-lbl">ккал расход</span>
</div>
<div class="summary-card ${balanceClass(foodTotals.kcal, bmr, actTotals.kcal)}">
<span class="summary-val">${balanceStr(foodTotals.kcal, bmr, actTotals.kcal)}</span>
<span class="summary-lbl">баланс</span>
</div>
</div>
<section class="log-section">
<div class="section-header">
<h3>Вес</h3>
<button class="add-btn" id="add-weight-btn">+</button>
</div>
<ul class="log-list" id="weight-list">
${weights.map(w => `
<li class="log-item">
<span class="log-time">${w.time ?? ""}</span>
<span class="log-desc">${w.weight_kg} кг</span>
<button class="del-btn" data-table="weight" data-id="${w.id}">&#x2715;</button>
</li>`).join("") || `<li class="log-empty">нет данных</li>`}
</ul>
</section>
<section class="log-section">
<div class="section-header">
<h3>Еда</h3>
<button class="add-btn" id="add-food-btn">+</button>
</div>
<ul class="log-list" id="food-list">
${food.map(f => `
<li class="log-item">
<span class="log-time">${f.time ?? ""}</span>
<span class="log-desc">${escHtml(f.product_name ?? f.recipe_name ?? f.note ?? "—")}
${f.amount_g ? `<small>${f.amount_g}г</small>` : ""}
</span>
<span class="log-kcal">${f.kcal != null ? Math.round(f.kcal) + " кк" : ""}</span>
<button class="del-btn" data-table="food" data-id="${f.id}">&#x2715;</button>
</li>`).join("") || `<li class="log-empty">нет данных</li>`}
</ul>
</section>
<section class="log-section">
<div class="section-header">
<h3>Активность</h3>
<button class="add-btn" id="add-act-btn">+</button>
</div>
<ul class="log-list" id="act-list">
${activities.map(a => `
<li class="log-item">
<span class="log-time">${a.time ?? ""}</span>
<span class="log-desc">${escHtml(a.desc ?? "—")}
${a.minutes ? `<small>${a.minutes} мин</small>` : ""}
</span>
<span class="log-kcal">${a.kcal != null ? "−" + Math.round(a.kcal) + " кк" : ""}</span>
<button class="del-btn" data-table="activity" data-id="${a.id}">&#x2715;</button>
</li>`).join("") || `<li class="log-empty">нет данных</li>`}
</ul>
</section>
<section class="log-section">
<div class="section-header">
<h3>Сон</h3>
<button class="add-btn" id="add-sleep-btn">&#x270E;</button>
</div>
<div id="sleep-info" class="sleep-info">
${sleep ? formatSleepInfo(sleep) : '<span class="log-empty">нет данных</span>'}
</div>
</section>
`;
container.querySelector("#prev-day").onclick = () => {
state.date = shiftDate(state.date, -1);
renderScreen("today");
};
container.querySelector("#next-day").onclick = () => {
state.date = shiftDate(state.date, 1);
renderScreen("today");
};
container.querySelectorAll(".del-btn").forEach(btn => {
btn.onclick = async () => {
const { table, id } = btn.dataset;
if (table === "weight") await deleteWeight(parseInt(id));
if (table === "food") await deleteFoodEntry(parseInt(id));
if (table === "activity") await deleteActivity(parseInt(id));
renderScreen("today");
};
});
container.querySelector("#add-weight-btn").onclick = () => showWeightModal();
container.querySelector("#add-food-btn").onclick = () => showFoodModal();
container.querySelector("#add-act-btn").onclick = () => showActivityModal();
container.querySelector("#add-sleep-btn").onclick = () => showSleepModal(sleep);
}
// ---------------------------------------------------------------------------
// Modals
// ---------------------------------------------------------------------------
function showModal(html, onSubmit) {
const overlay = document.createElement("div");
overlay.className = "modal-overlay";
overlay.innerHTML = `<div class="modal">${html}<div class="modal-btns">
<button id="modal-cancel" class="btn-secondary">Отмена</button>
<button id="modal-ok" class="btn-primary">Сохранить</button>
</div></div>`;
document.body.appendChild(overlay);
overlay.querySelector("#modal-cancel").onclick = () => overlay.remove();
overlay.querySelector("#modal-ok").onclick = async () => {
const ok = await onSubmit(overlay);
if (ok !== false) { overlay.remove(); renderScreen("today"); }
};
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
}
function showWeightModal() {
showModal(`
<h3>Вес</h3>
<div class="form-group">
<label>кг</label>
<input id="m-kg" type="number" step="0.1" inputmode="decimal" placeholder="88.5" autofocus />
</div>
<div class="form-group">
<label>Время</label>
<input id="m-time" type="time" value="${currentTime()}" />
</div>
`, async (overlay) => {
const kg = parseFloat(overlay.querySelector("#m-kg").value);
const time = overlay.querySelector("#m-time").value;
if (isNaN(kg)) return false;
await addWeight(state.date, time, kg);
});
}
function showFoodModal() {
showModal(`
<h3>Еда</h3>
<div class="form-group">
<label>Описание</label>
<input id="m-desc" type="text" placeholder="гречка с молоком" autofocus />
</div>
<div class="form-group">
<label>ккал (примерно)</label>
<input id="m-kcal" type="number" inputmode="numeric" placeholder="350" />
</div>
<div class="form-group">
<label>Время</label>
<input id="m-time" type="time" value="${currentTime()}" />
</div>
`, async (overlay) => {
const desc = overlay.querySelector("#m-desc").value.trim();
const kcal = parseFloat(overlay.querySelector("#m-kcal").value) || null;
const time = overlay.querySelector("#m-time").value;
if (!desc) return false;
await addFoodManual(state.date, time, desc, kcal);
});
}
function showActivityModal() {
showModal(`
<h3>Активность</h3>
<div class="form-group">
<label>Описание</label>
<input id="m-desc" type="text" placeholder="прогулка" autofocus />
</div>
<div class="form-group">
<label>Минуты</label>
<input id="m-min" type="number" inputmode="numeric" placeholder="40" />
</div>
<div class="form-group">
<label>ккал расход (примерно)</label>
<input id="m-kcal" type="number" inputmode="numeric" placeholder="150" />
</div>
<div class="form-group">
<label>Время</label>
<input id="m-time" type="time" value="${currentTime()}" />
</div>
`, async (overlay) => {
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 time = overlay.querySelector("#m-time").value;
if (!desc) return false;
await addActivity(state.date, time, "manual", desc, min, kcal);
});
}
function showSleepModal(existing) {
showModal(`
<h3>Сон</h3>
<div class="form-group">
<label>Лёг в кровать</label>
<input id="m-bed" type="time" value="${existing?.bed_time ?? ""}" />
</div>
<div class="form-group">
<label>Уснул</label>
<input id="m-asleep" type="time" value="${existing?.asleep_time ?? ""}" />
</div>
<div class="form-group">
<label>Проснулся</label>
<input id="m-wake" type="time" value="${existing?.wake_time ?? ""}" />
</div>
<div class="form-group">
<label>Дневной сон (мин)</label>
<input id="m-nap" type="number" inputmode="numeric" value="${existing?.nap_minutes ?? 0}" />
</div>
<div class="form-group">
<label>Заметки</label>
<input id="m-notes" type="text" value="${existing?.notes ?? ""}" />
</div>
`, async (overlay) => {
const bed = overlay.querySelector("#m-bed").value || null;
const asleep = overlay.querySelector("#m-asleep").value || null;
const wake = overlay.querySelector("#m-wake").value || null;
const nap = parseInt(overlay.querySelector("#m-nap").value) || 0;
const notes = overlay.querySelector("#m-notes").value.trim() || null;
await saveSleep(state.date, bed, asleep, wake, nap, notes);
});
}
// ---------------------------------------------------------------------------
// PRODUCTS screen (Phase 2 stub)
// ---------------------------------------------------------------------------
async function renderProducts(container) {
container.innerHTML = `
<div class="stub-screen">
<h2>Продукты</h2>
<input id="prod-search" class="search-input" type="search"
placeholder="Начните вводить название..." />
<ul id="prod-results" class="log-list"></ul>
</div>
`;
const input = container.querySelector("#prod-search");
const list = container.querySelector("#prod-results");
input.oninput = async () => {
const q = input.value.trim();
if (q.length < 2) { list.innerHTML = ""; return; }
const results = await searchProducts(q);
if (!results.length) {
list.innerHTML = `<li class="log-empty">Не найдено. Каталог ещё пуст — скоро добавим!</li>`;
return;
}
list.innerHTML = results.map(p => `
<li class="prod-item">
<span class="prod-name">${escHtml(p.name)}${p.brand ? " <small>" + escHtml(p.brand) + "</small>" : ""}</span>
<span class="prod-kcal">${p.kcal_per_100g != null ? Math.round(p.kcal_per_100g) + " кк/100г" : "?"}</span>
</li>
`).join("");
};
}
// ---------------------------------------------------------------------------
// CHARTS screen (Phase 4 stub)
// ---------------------------------------------------------------------------
async function renderCharts(container) {
container.innerHTML = `
<div class="stub-screen">
<h2>Графики</h2>
<p class="stub-note">Будет готово в Phase 4. Данные уже пишутся в БД.</p>
</div>
`;
}
// ---------------------------------------------------------------------------
// SETTINGS screen
// ---------------------------------------------------------------------------
async function renderSettings(container) {
const p = state.profile;
container.innerHTML = `
<div class="settings-screen">
<h2>Профиль</h2>
<div class="form-group">
<label>Рост (см)</label>
<input id="s-height" type="number" inputmode="numeric"
value="${p.height_cm ?? ""}" placeholder="178" />
</div>
<div class="form-group">
<label>Год рождения</label>
<input id="s-year" type="number" inputmode="numeric"
value="${p.birth_year ?? ""}" placeholder="1988" />
</div>
<div class="form-group">
<label>Пол</label>
<div class="type-chips">
<button class="type-chip ${(p.sex ?? "m") === "m" ? "selected" : ""}" data-sex="m">М</button>
<button class="type-chip ${p.sex === "f" ? "selected" : ""}" data-sex="f">Ж</button>
</div>
</div>
<div class="form-group">
<label>BMR по формуле (ккал/день)</label>
<input id="s-bmr-formula" type="number" inputmode="numeric"
value="${p.bmr_formula ?? ""}" placeholder="1800" />
</div>
<div class="form-group">
<label>BMR реальный / адаптированный</label>
<input id="s-bmr-est" type="number" inputmode="numeric"
value="${p.bmr_estimated ?? ""}" placeholder="1580" />
</div>
<button id="save-profile" class="btn-primary full-width">Сохранить</button>
</div>
`;
let selectedSex = p.sex ?? "m";
container.querySelectorAll(".type-chip").forEach(btn => {
btn.onclick = () => {
selectedSex = btn.dataset.sex;
container.querySelectorAll(".type-chip").forEach(b => b.classList.remove("selected"));
btn.classList.add("selected");
};
});
container.querySelector("#save-profile").onclick = async () => {
const height = parseInt(container.querySelector("#s-height").value);
const year = parseInt(container.querySelector("#s-year").value);
const bFormula = parseInt(container.querySelector("#s-bmr-formula").value) || null;
const bEst = parseInt(container.querySelector("#s-bmr-est").value) || null;
if (isNaN(height) || isNaN(year)) {
alert("Введите рост и год рождения");
return;
}
await setProfileValue("height_cm", height);
await setProfileValue("birth_year", year);
await setProfileValue("sex", selectedSex);
if (bFormula) await setProfileValue("bmr_formula", bFormula);
if (bEst) await setProfileValue("bmr_estimated", bEst);
state.profile = { ...state.profile, height_cm: String(height),
birth_year: String(year), sex: selectedSex,
bmr_formula: bFormula ? String(bFormula) : null,
bmr_estimated: bEst ? String(bEst) : null };
renderScreen("today");
};
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function calcBmrForDay(weights) {
const p = state.profile;
if (!p.height_cm || !p.birth_year) return null;
if (p.bmr_estimated) return parseInt(p.bmr_estimated);
const kg = weights.length ? weights[weights.length - 1].weight_kg
: state.latestWeight?.weight_kg;
if (!kg) return parseInt(p.bmr_formula) || null;
return calculateBmr(kg, parseInt(p.height_cm), getAge(parseInt(p.birth_year)), p.sex ?? "m");
}
function balanceStr(intake, bmr, actKcal) {
if (bmr == null) return "—";
const b = Math.round((intake ?? 0) - bmr - (actKcal ?? 0));
return (b >= 0 ? "+" : "") + b;
}
function balanceClass(intake, bmr, actKcal) {
if (bmr == null) return "";
const b = (intake ?? 0) - bmr - (actKcal ?? 0);
return b > 100 ? "surplus" : b < -100 ? "deficit" : "balanced";
}
function formatSleepInfo(s) {
const parts = [];
if (s.asleep_time && s.wake_time) {
const h = sleepHours(s.asleep_time, s.wake_time);
parts.push(`Сон: ${h.toFixed(1)} ч (${s.asleep_time} – ${s.wake_time})`);
}
if (s.nap_minutes) parts.push(`Дневной: ${s.nap_minutes} мин`);
if (s.notes) parts.push(escHtml(s.notes));
return parts.map(p => `<span class="sleep-line">${p}</span>`).join("");
}
function sleepHours(asleep, wake) {
const a = timeToMin(asleep);
const w = timeToMin(wake);
const diff = a >= 720 ? (1440 - a + w) : (w - a);
return diff / 60;
}
function timeToMin(t) {
if (!t) return 0;
const [h, m] = t.split(":").map(Number);
return h * 60 + m;
}
function shiftDate(dateStr, days) {
const d = new Date(dateStr);
d.setDate(d.getDate() + days);
return d.toISOString().slice(0, 10);
}
function formatDate(dateStr) {
const d = new Date(dateStr + "T00:00:00");
return d.toLocaleDateString("ru-RU", { weekday: "long", day: "numeric", month: "long" });
}
// Prevent XSS when inserting user text into innerHTML
function escHtml(str) {
return String(str ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
// ---------------------------------------------------------------------------
// Start
// ---------------------------------------------------------------------------
init().catch(console.error);

44
src/measures.js Normal file
View file

@ -0,0 +1,44 @@
// Household measures → grams/ml
// Used by parser.js to convert "ст.л." → 15g, etc.
export const MEASURES = {
// Spoons
"ч.л.": 5,
"чайная ложка": 5,
"чайн ложк": 5,
"д.л.": 10,
"десертная ложка": 10,
"ст.л.": 15,
"столовая ложка": 15,
"стол ложк": 15,
// Cups / glasses
"стакан": 250,
"полстакана": 125,
"кружка": 300,
// Pieces (generic, product-specific overrides in food-table)
"кусок хлеба": 30,
"кусок": 30,
"ломтик": 20,
"долька": 10,
// Pinches
"щепотка": 1,
"горсть": 30,
};
// Pattern aliases for parser regex matching
export const MEASURE_PATTERNS = [
{ pattern: /\bч\.?\s*л\.?\b/i, grams: 5 },
{ pattern: /\bчайн\w*\s+ложк\w*\b/i, grams: 5 },
{ pattern: /\bд\.?\s*л\.?\b/i, grams: 10 },
{ pattern: /\bдесертн\w*\s+ложк\w*\b/i, grams: 10 },
{ pattern: /\bст\.?\s*л\.?\b/i, grams: 15 },
{ pattern: /\bстолов\w*\s+ложк\w*\b/i, grams: 15 },
{ pattern: /\bстакан\w*\b/i, grams: 250 },
{ pattern: /\bполстакан\w*\b/i, grams: 125 },
{ pattern: /\bкружк\w*\b/i, grams: 300 },
{ pattern: /\bгорст\w*\b/i, grams: 30 },
{ pattern: /\bщепотк\w*\b/i, grams: 1 },
];

112
src/parser.js Normal file
View file

@ -0,0 +1,112 @@
// Parse free-text amount input into grams
// Handles: "200г", "половина пачки 400г", "2 ст.л.", "3 куска", etc.
import { MEASURE_PATTERNS } from "./measures.js";
// Extract a number near a gram-like unit
const GRAMS_RE = /(\d+(?:[.,]\d+)?)\s*(г|гр|грамм|g)\b/i;
// Extract fraction of a package
const FRACTION_PACK_RE = /(?:полов\w*|пол|1\/2)\s*(?:пачк\w*|упаков\w*|банк\w*)/i;
const THIRD_PACK_RE = /(?:треть|1\/3)\s*(?:пачк\w*|упаков\w*|банк\w*)/i;
const QUARTER_PACK_RE = /(?:четверт\w*|1\/4)\s*(?:пачк\w*|упаков\w*|банк\w*)/i;
const PACK_WEIGHT_RE = /(\d+)\s*(г|гр|грамм|g)\b/i;
// Count of items
const COUNT_RE = /(\d+)\s*(кус\w*|штук\w*|шт\.?)\b/i;
// Duration for activities (minutes/hours)
const DURATION_MIN_RE = /(\d+)\s*(мин\w*|m)\b/i;
const DURATION_HR_RE = /(\d+(?:[.,]\d+)?)\s*(час\w*|ч\.?|h)\b/i;
// Just a bare number
const BARE_NUMBER_RE = /^(\d+(?:[.,]\d+)?)$/;
export function parseAmount(amountStr, description) {
if (!amountStr || !amountStr.trim()) {
return { grams: null, minutes: null, count: null, source: "default" };
}
const text = amountStr.trim().toLowerCase();
// 1. Exact grams: "200г"
let m = text.match(GRAMS_RE);
if (m) {
return { grams: parseFloat(m[1].replace(",", ".")), minutes: null, count: null, source: "grams" };
}
// 2. Fraction of package: "половина пачки 400г"
const combined = (amountStr + " " + (description || "")).toLowerCase();
if (FRACTION_PACK_RE.test(combined)) {
const pw = combined.match(PACK_WEIGHT_RE);
if (pw) {
return { grams: parseFloat(pw[1]) / 2, minutes: null, count: null, source: "half-pack" };
}
}
if (THIRD_PACK_RE.test(combined)) {
const pw = combined.match(PACK_WEIGHT_RE);
if (pw) {
return { grams: Math.round(parseFloat(pw[1]) / 3), minutes: null, count: null, source: "third-pack" };
}
}
if (QUARTER_PACK_RE.test(combined)) {
const pw = combined.match(PACK_WEIGHT_RE);
if (pw) {
return { grams: parseFloat(pw[1]) / 4, minutes: null, count: null, source: "quarter-pack" };
}
}
// 3. Duration (for activities): "30 мин", "1.5 часа"
m = text.match(DURATION_HR_RE);
if (m) {
return { grams: null, minutes: Math.round(parseFloat(m[1].replace(",", ".")) * 60), count: null, source: "hours" };
}
m = text.match(DURATION_MIN_RE);
if (m) {
return { grams: null, minutes: parseInt(m[1]), count: null, source: "minutes" };
}
// 4. Household measure: "2 ст.л." or just "ст.л."
for (const mp of MEASURE_PATTERNS) {
if (mp.pattern.test(text)) {
// Check for a multiplier before the measure
const before = text.split(mp.pattern)[0].trim();
const multiplier = before.match(/(\d+)/) ? parseInt(before.match(/(\d+)/)[1]) : 1;
return { grams: mp.grams * multiplier, minutes: null, count: null, source: "measure" };
}
}
// 5. Count: "3 куска", "2 шт"
m = text.match(COUNT_RE);
if (m) {
return { grams: null, minutes: null, count: parseInt(m[1]), source: "count" };
}
// 6. Bare number → assume grams
m = text.match(BARE_NUMBER_RE);
if (m) {
return { grams: parseFloat(m[1].replace(",", ".")), minutes: null, count: null, source: "bare-grams" };
}
return { grams: null, minutes: null, count: null, source: "unknown" };
}
// Estimate kcal from parsed amount + food data
export function estimateKcal(parsed, food) {
if (!food) return null;
if (parsed.grams != null) {
return Math.round((food.kcal / 100) * parsed.grams);
}
if (parsed.count != null) {
return Math.round((food.kcal / 100) * food.portion * parsed.count);
}
// Use default portion
return Math.round((food.kcal / 100) * food.portion);
}

664
src/styles.css Normal file
View file

@ -0,0 +1,664 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--bg: #121212;
--surface: #1e1e1e;
--primary: #bb86fc;
--on-bg: #e0e0e0;
--on-surface: #ffffff;
--positive: #cf6679;
--negative: #03dac6;
--muted: #888;
--border: #333;
--radius: 8px;
}
body {
font-family: system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--on-bg);
font-size: 16px;
line-height: 1.4;
overflow-x: hidden;
-webkit-tap-highlight-color: transparent;
}
#app {
max-width: 480px;
margin: 0 auto;
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* Header / date navigation */
.day-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: var(--surface);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 10;
}
.day-header button {
background: none;
border: none;
color: var(--primary);
font-size: 24px;
padding: 8px;
cursor: pointer;
border-radius: var(--radius);
}
.day-header button:active {
background: rgba(187, 134, 252, 0.15);
}
.day-header .date-text {
font-size: 18px;
font-weight: 500;
color: var(--on-surface);
}
/* Weight display */
.weight-row {
padding: 8px 16px;
background: var(--surface);
border-bottom: 1px solid var(--border);
font-size: 14px;
color: var(--muted);
}
.weight-row span {
color: var(--on-surface);
font-weight: 500;
}
/* Entry list */
.entries {
flex: 1;
overflow-y: auto;
padding: 8px 0;
}
.entry-item {
display: grid;
grid-template-columns: 50px 1fr 70px;
align-items: center;
padding: 10px 16px;
border-bottom: 1px solid var(--border);
}
.entry-item:active {
background: rgba(255, 255, 255, 0.05);
}
.entry-time {
font-size: 13px;
color: var(--muted);
}
.entry-desc {
font-size: 15px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.entry-kcal {
text-align: right;
font-size: 14px;
font-weight: 500;
}
.entry-kcal.intake {
color: var(--positive);
}
.entry-kcal.expenditure {
color: var(--negative);
}
/* Balance summary */
.balance-bar {
display: flex;
justify-content: space-around;
padding: 12px 16px;
background: var(--surface);
border-top: 1px solid var(--border);
font-size: 14px;
}
.balance-bar .label {
color: var(--muted);
font-size: 12px;
}
.balance-bar .value {
font-size: 18px;
font-weight: 600;
}
.balance-bar .value.deficit {
color: var(--negative);
}
.balance-bar .value.surplus {
color: var(--positive);
}
/* FAB (add button) */
.fab {
position: fixed;
bottom: 80px;
right: 24px;
width: 56px;
height: 56px;
border-radius: 50%;
background: var(--primary);
color: #000;
border: none;
font-size: 28px;
font-weight: 300;
cursor: pointer;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
}
.fab:active {
transform: scale(0.95);
}
/* Bottom nav */
.bottom-nav {
display: flex;
justify-content: space-around;
padding: 10px 0;
background: var(--surface);
border-top: 1px solid var(--border);
position: sticky;
bottom: 0;
}
.bottom-nav button {
background: none;
border: none;
color: var(--muted);
font-size: 12px;
padding: 4px 16px;
cursor: pointer;
}
.bottom-nav button.active {
color: var(--primary);
}
/* Modal / add entry overlay */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
z-index: 100;
display: flex;
align-items: flex-end;
justify-content: center;
}
.modal-sheet {
background: var(--surface);
width: 100%;
max-width: 480px;
border-radius: 16px 16px 0 0;
padding: 20px 16px;
max-height: 85vh;
overflow-y: auto;
}
.modal-sheet h2 {
font-size: 18px;
margin-bottom: 16px;
color: var(--on-surface);
}
/* Form elements */
.form-group {
margin-bottom: 14px;
}
.form-group label {
display: block;
font-size: 13px;
color: var(--muted);
margin-bottom: 4px;
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: 10px 12px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--on-surface);
font-size: 16px;
font-family: inherit;
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--primary);
}
.type-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.type-chip {
padding: 6px 14px;
border-radius: 20px;
border: 1px solid var(--border);
background: none;
color: var(--on-bg);
font-size: 14px;
cursor: pointer;
}
.type-chip.selected {
background: var(--primary);
color: #000;
border-color: var(--primary);
}
.btn-row {
display: flex;
gap: 12px;
margin-top: 16px;
}
.btn {
flex: 1;
padding: 12px;
border-radius: var(--radius);
border: none;
font-size: 16px;
font-family: inherit;
cursor: pointer;
}
.btn-primary {
background: var(--primary);
color: #000;
font-weight: 600;
}
.btn-secondary {
background: var(--border);
color: var(--on-bg);
}
/* Setup screen */
.setup-screen {
padding: 24px 16px;
display: flex;
flex-direction: column;
gap: 20px;
min-height: 100vh;
justify-content: center;
}
.setup-screen h1 {
font-size: 24px;
color: var(--on-surface);
text-align: center;
}
.setup-screen p {
color: var(--muted);
text-align: center;
font-size: 14px;
}
/* Kcal preview */
.kcal-preview {
padding: 10px 12px;
background: rgba(187, 134, 252, 0.1);
border-radius: var(--radius);
text-align: center;
font-size: 18px;
font-weight: 500;
color: var(--primary);
}
/* Empty state */
.empty-state {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: var(--muted);
font-size: 15px;
padding: 40px;
text-align: center;
}
/* Screen layout */
#app {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.bottom-nav {
order: 2;
position: sticky;
bottom: 0;
}
.screen-content {
order: 1;
flex: 1;
overflow-y: auto;
padding-bottom: 8px;
}
.nav-btn {
background: none;
border: none;
color: var(--muted);
font-size: 13px;
padding: 8px 12px;
cursor: pointer;
font-family: inherit;
}
.nav-btn.active {
color: var(--primary);
font-weight: 600;
}
/* Day navigation */
.day-header h2 {
font-size: 15px;
font-weight: 500;
text-align: center;
}
.day-nav {
background: none;
border: none;
color: var(--primary);
font-size: 20px;
padding: 4px 10px;
cursor: pointer;
}
/* Summary cards */
.summary-row {
display: flex;
gap: 8px;
padding: 10px 12px;
background: var(--surface);
border-bottom: 1px solid var(--border);
}
.summary-card {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
padding: 6px 4px;
border-radius: var(--radius);
background: var(--bg);
}
.summary-val {
font-size: 18px;
font-weight: 700;
}
.summary-lbl {
font-size: 10px;
color: var(--muted);
text-align: center;
}
.summary-card.surplus .summary-val { color: #cf6679; }
.summary-card.deficit .summary-val { color: #03dac6; }
.summary-card.balanced .summary-val { color: var(--on-bg); }
/* Log sections */
.log-section {
padding: 0 0 4px;
border-bottom: 1px solid var(--border);
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 14px 4px;
}
.section-header h3 {
font-size: 13px;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.add-btn {
background: none;
border: none;
color: var(--primary);
font-size: 22px;
line-height: 1;
cursor: pointer;
padding: 0 4px;
}
.log-list {
list-style: none;
padding: 0;
margin: 0;
}
.log-item {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 14px;
border-top: 1px solid rgba(255,255,255,0.04);
}
.log-time {
font-size: 12px;
color: var(--muted);
min-width: 38px;
}
.log-desc {
flex: 1;
font-size: 14px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.log-desc small {
color: var(--muted);
margin-left: 4px;
font-size: 11px;
}
.log-kcal {
font-size: 13px;
color: var(--muted);
min-width: 52px;
text-align: right;
}
.log-empty {
padding: 6px 14px;
font-size: 13px;
color: var(--muted);
font-style: italic;
}
.del-btn {
background: none;
border: none;
color: #555;
font-size: 14px;
cursor: pointer;
padding: 2px 4px;
flex-shrink: 0;
}
.del-btn:active { color: #cf6679; }
/* Sleep info */
.sleep-info {
padding: 6px 14px 8px;
display: flex;
flex-direction: column;
gap: 2px;
}
.sleep-line {
font-size: 13px;
color: var(--on-bg);
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.75);
z-index: 200;
display: flex;
align-items: flex-end;
justify-content: center;
}
.modal {
background: var(--surface);
width: 100%;
max-width: 480px;
border-radius: 16px 16px 0 0;
padding: 20px 16px 16px;
max-height: 90vh;
overflow-y: auto;
}
.modal h3 {
font-size: 17px;
margin-bottom: 16px;
}
.modal-btns {
display: flex;
gap: 10px;
margin-top: 16px;
}
.btn-primary, .btn-secondary {
flex: 1;
padding: 12px;
border-radius: var(--radius);
border: none;
font-size: 16px;
font-family: inherit;
cursor: pointer;
}
.btn-primary {
background: var(--primary);
color: #000;
font-weight: 600;
}
.btn-secondary {
background: var(--border);
color: var(--on-bg);
}
.full-width { width: 100%; }
/* Products screen */
.stub-screen, .settings-screen {
padding: 16px;
}
.stub-screen h2, .settings-screen h2 {
font-size: 20px;
margin-bottom: 16px;
}
.stub-note {
color: var(--muted);
font-size: 14px;
margin-top: 8px;
}
.search-input {
width: 100%;
padding: 10px 14px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--on-surface);
font-size: 16px;
font-family: inherit;
margin-bottom: 12px;
}
.search-input:focus {
outline: none;
border-color: var(--primary);
}
.prod-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 9px 4px;
border-bottom: 1px solid var(--border);
}
.prod-name {
font-size: 14px;
flex: 1;
}
.prod-name small {
color: var(--muted);
font-size: 12px;
margin-left: 4px;
}
.prod-kcal {
font-size: 13px;
color: var(--muted);
}
/* Loading */
.loading {
text-align: center;
padding: 40px;
color: var(--muted);
}