302 lines
11 KiB
JavaScript
302 lines
11 KiB
JavaScript
const $ = (id) => document.getElementById(id);
|
|
const settingsKey = 'calorie-ai-web-settings';
|
|
const entriesKey = 'calorie-ai-web-entries';
|
|
|
|
let selectedImageDataUrl = '';
|
|
let selectedImageName = '';
|
|
|
|
function todayKey() {
|
|
return dateKey(new Date());
|
|
}
|
|
|
|
function dateKey(date) {
|
|
const year = date.getFullYear();
|
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
const day = String(date.getDate()).padStart(2, '0');
|
|
return `${year}-${month}-${day}`;
|
|
}
|
|
|
|
function readJson(key, fallback) {
|
|
try { return JSON.parse(localStorage.getItem(key)) || fallback; } catch { return fallback; }
|
|
}
|
|
|
|
function saveJson(key, value) {
|
|
localStorage.setItem(key, JSON.stringify(value));
|
|
}
|
|
|
|
function settings() {
|
|
return readJson(settingsKey, { baseUrl: '', apiKey: '', visionModel: 'gpt-4o-mini', taskModel: 'gpt-4o-mini' });
|
|
}
|
|
|
|
function entries() {
|
|
return readJson(entriesKey, []);
|
|
}
|
|
|
|
function setStatus(message, error = false) {
|
|
$('status').textContent = message;
|
|
$('status').className = `status ${error ? 'error' : 'ok'}`;
|
|
}
|
|
|
|
function fileToDataUrl(file) {
|
|
return new Promise((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onload = () => resolve(reader.result);
|
|
reader.onerror = () => reject(reader.error);
|
|
reader.readAsDataURL(file);
|
|
});
|
|
}
|
|
|
|
async function chatCompletion(body) {
|
|
const cfg = settings();
|
|
const response = await fetch('/api/chat', {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ baseUrl: cfg.baseUrl, apiKey: cfg.apiKey, body }),
|
|
});
|
|
const text = await response.text();
|
|
if (!response.ok) throw new Error(text);
|
|
const json = JSON.parse(text);
|
|
return json.choices[0].message.content.trim();
|
|
}
|
|
|
|
async function requestVisionEstimate(description, measure, imageDataUrl) {
|
|
const cfg = settings();
|
|
if (!cfg.visionModel) return '';
|
|
return chatCompletion({
|
|
model: cfg.visionModel,
|
|
temperature: 0.15,
|
|
messages: [{
|
|
role: 'user',
|
|
content: [
|
|
{ type: 'text', text: `Analyze this meal photo for nutrition tracking. Estimate visible foods, portions, calories, protein grams, carbs grams, fat grams, fruit servings, and vegetable servings. One produce serving is about 80g or one cup raw leafy vegetables. Return concise JSON if possible. User description: ${description}; user portion note: ${measure}` },
|
|
{ type: 'image_url', image_url: { url: imageDataUrl } },
|
|
],
|
|
}],
|
|
});
|
|
}
|
|
|
|
async function requestTaskEstimate(description, measure, visionEstimate) {
|
|
const cfg = settings();
|
|
return chatCompletion({
|
|
model: cfg.taskModel,
|
|
temperature: 0.1,
|
|
messages: [
|
|
{ role: 'system', content: 'Return strict JSON only. Do not wrap the response in markdown.' },
|
|
{ role: 'user', content: `You are a nutrition logging assistant. Produce a final nutrition estimate for one meal. If a vision estimate is provided, use it as primary evidence for visible ingredients and portion sizes. Return only JSON with keys mealName, calories, proteinGrams, carbsGrams, fatGrams, fruitServings, vegetableServings, foodGroups, notes. Use integer grams and calories. fruitServings and vegetableServings can be decimal numbers. foodGroups should be a short comma-separated string. Mention uncertainty in notes.\n\nUser description: ${description}\nUser measure: ${measure}\nVision nutrition estimate: ${visionEstimate}` },
|
|
],
|
|
});
|
|
}
|
|
|
|
function parseEstimate(content) {
|
|
let cleaned = content.trim().replace(/^```json/i, '').replace(/^```/, '').replace(/```$/, '').trim();
|
|
const start = cleaned.indexOf('{');
|
|
const end = cleaned.lastIndexOf('}');
|
|
if (start >= 0 && end > start) cleaned = cleaned.slice(start, end + 1);
|
|
const parsed = JSON.parse(cleaned);
|
|
return {
|
|
mealName: parsed.mealName || 'Meal',
|
|
calories: Math.max(0, Number.parseInt(parsed.calories || 0, 10)),
|
|
proteinGrams: Math.max(0, Number.parseInt(parsed.proteinGrams || 0, 10)),
|
|
carbsGrams: Math.max(0, Number.parseInt(parsed.carbsGrams || 0, 10)),
|
|
fatGrams: Math.max(0, Number.parseInt(parsed.fatGrams || 0, 10)),
|
|
fruitServings: Math.max(0, Number(parsed.fruitServings ?? parsed.fruitsServings ?? 0)),
|
|
vegetableServings: Math.max(0, Number(parsed.vegetableServings ?? parsed.vegetablesServings ?? 0)),
|
|
foodGroups: Array.isArray(parsed.foodGroups) ? parsed.foodGroups.join(', ') : String(parsed.foodGroups || ''),
|
|
notes: parsed.notes || '',
|
|
raw: content,
|
|
};
|
|
}
|
|
|
|
function totalsFor(date) {
|
|
return entries().filter(e => e.date === date).reduce((sum, e) => ({
|
|
meals: sum.meals + 1,
|
|
calories: sum.calories + (e.calories || 0),
|
|
protein: sum.protein + (e.proteinGrams || 0),
|
|
carbs: sum.carbs + (e.carbsGrams || 0),
|
|
fat: sum.fat + (e.fatGrams || 0),
|
|
fruit: sum.fruit + (e.fruitServings || 0),
|
|
vegetables: sum.vegetables + (e.vegetableServings || 0),
|
|
}), { meals: 0, calories: 0, protein: 0, carbs: 0, fat: 0, fruit: 0, vegetables: 0 });
|
|
}
|
|
|
|
function last7Days() {
|
|
const now = new Date();
|
|
return Array.from({ length: 7 }, (_, index) => {
|
|
const d = new Date(now);
|
|
d.setDate(now.getDate() - (6 - index));
|
|
const date = dateKey(d);
|
|
return { date, label: d.toLocaleDateString(undefined, { weekday: 'short' }), ...totalsFor(date) };
|
|
});
|
|
}
|
|
|
|
function drawMacroChart(total) {
|
|
const canvas = $('macroChart');
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
const values = [total.protein, total.carbs, total.fat];
|
|
const colors = ['#2f7d59', '#da9648', '#6177c4'];
|
|
const labels = [`Protein ${total.protein}g`, `Carbs ${total.carbs}g`, `Fat ${total.fat}g`];
|
|
const sum = values.reduce((a, b) => a + b, 0) || 1;
|
|
let start = -Math.PI / 2;
|
|
values.forEach((value, index) => {
|
|
const arc = (value / sum) * Math.PI * 2;
|
|
ctx.beginPath();
|
|
ctx.lineWidth = 34;
|
|
ctx.strokeStyle = colors[index];
|
|
ctx.arc(120, 130, 70, start, start + arc);
|
|
ctx.stroke();
|
|
start += arc;
|
|
});
|
|
ctx.fillStyle = '#26372d';
|
|
ctx.font = '700 20px sans-serif';
|
|
ctx.fillText('Today', 240, 88);
|
|
ctx.font = '15px sans-serif';
|
|
labels.forEach((label, index) => {
|
|
ctx.fillStyle = colors[index];
|
|
ctx.fillRect(240, 112 + index * 34, 14, 14);
|
|
ctx.fillStyle = '#26372d';
|
|
ctx.fillText(label, 264, 125 + index * 34);
|
|
});
|
|
}
|
|
|
|
function drawBars(canvasId, days, mode) {
|
|
const canvas = $(canvasId);
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
const values = days.map(d => mode === 'calories' ? d.calories : d.fruit + d.vegetables);
|
|
const max = Math.max(1, ...values);
|
|
const left = 26;
|
|
const bottom = canvas.height - 44;
|
|
const height = canvas.height - 76;
|
|
const gap = 14;
|
|
const bar = (canvas.width - left * 2 - gap * 6) / 7;
|
|
days.forEach((day, index) => {
|
|
const x = left + index * (bar + gap);
|
|
const h = values[index] / max * height;
|
|
ctx.fillStyle = '#eef3ee';
|
|
roundRect(ctx, x, bottom - height, bar, height, 12);
|
|
ctx.fillStyle = mode === 'calories' ? '#2f7d59' : '#da9648';
|
|
roundRect(ctx, x, bottom - h, bar, h, 12);
|
|
if (mode === 'produce') {
|
|
const vegH = day.vegetables / max * height;
|
|
ctx.fillStyle = '#2f7d59';
|
|
roundRect(ctx, x + bar * .52, bottom - vegH, bar * .48, vegH, 10);
|
|
}
|
|
ctx.fillStyle = '#5b635c';
|
|
ctx.font = '13px sans-serif';
|
|
ctx.fillText(day.label, x, bottom + 24);
|
|
});
|
|
}
|
|
|
|
function roundRect(ctx, x, y, w, h, r) {
|
|
ctx.beginPath();
|
|
ctx.roundRect(x, y, w, Math.max(1, h), r);
|
|
ctx.fill();
|
|
}
|
|
|
|
function renderEntries() {
|
|
const container = $('entries');
|
|
const list = [...entries()].sort((a, b) => `${b.date} ${b.time}`.localeCompare(`${a.date} ${a.time}`));
|
|
if (!list.length) {
|
|
container.innerHTML = '<p class="muted">No meals logged yet.</p>';
|
|
return;
|
|
}
|
|
container.innerHTML = list.map(e => `
|
|
<article class="entry">
|
|
<strong>${escapeHtml(e.date)} ${escapeHtml(e.time)} - ${escapeHtml(e.mealName)}</strong>
|
|
<span>${e.calories} kcal | P ${e.proteinGrams}g | C ${e.carbsGrams}g | F ${e.fatGrams}g</span>
|
|
<small>Fruit ${Number(e.fruitServings || 0).toFixed(1)} | Vegetables ${Number(e.vegetableServings || 0).toFixed(1)}${e.imageIncluded ? ' | image analyzed' : ''}</small>
|
|
<small>${escapeHtml(e.description || '')}</small>
|
|
${e.notes ? `<small>${escapeHtml(e.notes)}</small>` : ''}
|
|
</article>`).join('');
|
|
}
|
|
|
|
function escapeHtml(value) {
|
|
return String(value).replace(/[&<>"]/g, ch => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[ch]));
|
|
}
|
|
|
|
function render() {
|
|
const today = totalsFor(todayKey());
|
|
$('todayCalories').textContent = `${today.calories} kcal`;
|
|
$('todayMacroText').textContent = `P ${today.protein}g | C ${today.carbs}g | F ${today.fat}g`;
|
|
$('todayProduceText').textContent = `Fruit ${today.fruit.toFixed(1)} | Veg ${today.vegetables.toFixed(1)}`;
|
|
const week = last7Days();
|
|
drawMacroChart(today);
|
|
drawBars('calorieChart', week, 'calories');
|
|
drawBars('produceChart', week, 'produce');
|
|
renderEntries();
|
|
}
|
|
|
|
function loadSettings() {
|
|
const cfg = settings();
|
|
$('baseUrl').value = cfg.baseUrl;
|
|
$('apiKey').value = cfg.apiKey;
|
|
$('visionModel').value = cfg.visionModel;
|
|
$('taskModel').value = cfg.taskModel;
|
|
}
|
|
|
|
$('saveSettings').addEventListener('click', () => {
|
|
saveJson(settingsKey, {
|
|
baseUrl: $('baseUrl').value.trim(),
|
|
apiKey: $('apiKey').value.trim(),
|
|
visionModel: $('visionModel').value.trim(),
|
|
taskModel: $('taskModel').value.trim(),
|
|
});
|
|
});
|
|
|
|
$('image').addEventListener('change', async (event) => {
|
|
const file = event.target.files[0];
|
|
selectedImageDataUrl = file ? await fileToDataUrl(file) : '';
|
|
selectedImageName = file ? file.name : '';
|
|
$('preview').hidden = !selectedImageDataUrl;
|
|
$('preview').src = selectedImageDataUrl || '';
|
|
});
|
|
|
|
$('mealForm').addEventListener('submit', async (event) => {
|
|
event.preventDefault();
|
|
const cfg = settings();
|
|
const description = $('description').value.trim();
|
|
const measure = $('measure').value.trim();
|
|
if (!description && !selectedImageDataUrl) return setStatus('Describe the meal or upload an image.', true);
|
|
if (!cfg.baseUrl || !cfg.taskModel) return setStatus('Save API base URL and tasking model first.', true);
|
|
|
|
const button = event.submitter;
|
|
button.disabled = true;
|
|
try {
|
|
setStatus(selectedImageDataUrl ? 'Uploading image to vision model...' : 'Analyzing meal text...');
|
|
const vision = selectedImageDataUrl ? await requestVisionEstimate(description, measure, selectedImageDataUrl) : '';
|
|
setStatus('Normalizing calories and macros...');
|
|
const estimate = parseEstimate(await requestTaskEstimate(description, measure, vision));
|
|
const now = new Date();
|
|
const next = [...entries(), {
|
|
...estimate,
|
|
date: todayKey(),
|
|
time: now.toTimeString().slice(0, 5),
|
|
description,
|
|
measure,
|
|
imageIncluded: Boolean(selectedImageDataUrl),
|
|
imageName: selectedImageName,
|
|
visionEstimate: vision,
|
|
}];
|
|
saveJson(entriesKey, next);
|
|
$('mealForm').reset();
|
|
selectedImageDataUrl = '';
|
|
selectedImageName = '';
|
|
$('preview').hidden = true;
|
|
setStatus('Saved.');
|
|
render();
|
|
} catch (error) {
|
|
setStatus(error.message, true);
|
|
} finally {
|
|
button.disabled = false;
|
|
}
|
|
});
|
|
|
|
$('clearData').addEventListener('click', () => {
|
|
if (!confirm('Clear the local diary on this browser?')) return;
|
|
saveJson(entriesKey, []);
|
|
render();
|
|
});
|
|
|
|
loadSettings();
|
|
render();
|