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:
parent
daf71333ff
commit
b26264538b
20 changed files with 4234 additions and 37 deletions
35
src/activity-table.js
Normal file
35
src/activity-table.js
Normal 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
14
src/bmr.js
Normal 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
43
src/date-utils.js
Normal 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
338
src/db.js
Normal 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
91
src/food-table.js
Normal 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
526
src/main.js
Normal 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">←</button>
|
||||
<h2>${formatDate(state.date)}</h2>
|
||||
<button class="day-nav" id="next-day">→</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}">✕</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}">✕</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}">✕</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">✎</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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Start
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
init().catch(console.error);
|
||||
44
src/measures.js
Normal file
44
src/measures.js
Normal 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
112
src/parser.js
Normal 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
664
src/styles.css
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue