Revamp web app with Svelte and Tailwind
All checks were successful
Android CI / build (push) Successful in 28s
Web CI / test (push) Successful in 48s

This commit is contained in:
Daniel 2026-05-19 18:53:03 +02:00
parent f57c04c989
commit 5837b1c8dc
20 changed files with 2099 additions and 844 deletions

View file

@ -1,3 +1,6 @@
CALORIE_AI_WEB_USER=admin
CALORIE_AI_WEB_PASSWORD=change-this-password
CALORIE_AI_SESSION_SECRET=change-this-session-secret
LITELLM_API_BASE=http://litellm:4000/v1
LITELLM_API_KEY=change-this-litellm-key
LITELLM_MODEL=openrouter-claude-haiku-4.5

View file

@ -1,8 +1,12 @@
FROM node:22-alpine
WORKDIR /app
COPY package.json server.js ./
COPY package*.json ./
RUN npm ci
COPY index.html vite.config.js server.js ./
COPY src ./src
COPY public ./public
RUN npm run build && npm prune --omit=dev
ENV PORT=8080
EXPOSE 8080

View file

@ -3,7 +3,19 @@ services:
build: .
container_name: calorie-ai-web
restart: unless-stopped
env_file:
- /home/danvics/docker/quiz/backend/.env
environment:
LITELLM_API_BASE: http://litellm:4000/v1
LITELLM_MODEL: openrouter-claude-haiku-4.5
ports:
- "127.0.0.1:8095:8080"
volumes:
- ./data:/app/data
networks:
- default
- litellm_default
networks:
litellm_default:
external: true

13
web/index.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Calorie AI</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

1370
web/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -3,10 +3,18 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"start": "node server.js",
"test": "playwright test"
},
"devDependencies": {
"@playwright/test": "^1.56.1"
}
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@tailwindcss/vite": "^4.3.0",
"@playwright/test": "^1.56.1",
"svelte": "^5.55.8",
"tailwindcss": "^4.3.0",
"vite": "^8.0.13"
},
"dependencies": {}
}

View file

@ -4,7 +4,7 @@ module.exports = defineConfig({
testDir: './tests',
timeout: 30000,
webServer: {
command: 'node server.js',
command: 'npm run build && node server.js',
url: 'http://127.0.0.1:8096',
reuseExistingServer: !process.env.CI,
env: {

View file

@ -1,338 +0,0 @@
const $ = (id) => document.getElementById(id);
const settingsKey = 'calorie-ai-web-settings';
const entriesKey = 'calorie-ai-web-entries';
let selectedImageDataUrl = '';
let selectedImageName = '';
function setText(id, value) {
const element = $(id);
if (element) element.textContent = value;
}
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 = '#17251d';
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 = '#17251d';
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.foodGroups ? `<small>Groups: ${escapeHtml(e.foodGroups)}</small>` : ''}
${e.notes ? `<small>${escapeHtml(e.notes)}</small>` : ''}
</article>`).join('');
}
function escapeHtml(value) {
return String(value).replace(/[&<>"]/g, ch => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[ch]));
}
function render() {
const today = totalsFor(todayKey());
const week = last7Days();
const weekAverage = Math.round(week.reduce((sum, day) => sum + day.calories, 0) / week.length);
const produce = today.fruit + today.vegetables;
setText('todayCalories', `${today.calories} kcal`);
setText('todayMacroText', `P ${today.protein}g | C ${today.carbs}g | F ${today.fat}g`);
setText('todayProduceText', `Fruit ${today.fruit.toFixed(1)} | Veg ${today.vegetables.toFixed(1)}`);
setText('mealCountText', String(today.meals));
setText('weekAverageText', `${weekAverage} kcal`);
setText('produceGoalText', `${produce.toFixed(1)} / 5`);
drawMacroChart(today);
drawBars('calorieChart', week, 'calories');
drawBars('produceChart', week, 'produce');
renderEntries();
}
function showSection(sectionName) {
document.querySelectorAll('.section-page').forEach(section => {
section.hidden = section.dataset.section !== sectionName;
});
document.querySelectorAll('[data-nav]').forEach(button => {
button.classList.toggle('active', button.dataset.nav === sectionName);
});
document.body.classList.remove('menu-open');
}
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(),
});
});
document.querySelectorAll('[data-nav]').forEach(button => {
button.addEventListener('click', () => showSection(button.dataset.nav));
});
$('menuToggle').addEventListener('click', () => {
document.body.classList.toggle('menu-open');
});
$('logout').addEventListener('click', async () => {
await fetch('/api/logout', { method: 'POST' });
window.location.href = '/login.html';
});
$('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();

View file

@ -1,162 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Calorie AI</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<div class="app-frame">
<aside class="sidebar" id="sidebar">
<div class="brand-block">
<span class="brand-mark">CA</span>
<div>
<strong>Calorie AI</strong>
<small>Private food diary</small>
</div>
</div>
<nav class="main-nav" aria-label="Main menu">
<button class="nav-item active" type="button" data-nav="dashboard">Dashboard</button>
<button class="nav-item" type="button" data-nav="log">Log meal</button>
<button class="nav-item" type="button" data-nav="analytics">Analytics</button>
<button class="nav-item" type="button" data-nav="diary">Diary</button>
<button class="nav-item" type="button" data-nav="settings">AI settings</button>
</nav>
<div class="sidebar-footer">
<small>Protected by local web auth</small>
<button id="logout" class="ghost" type="button">Sign out</button>
</div>
</aside>
<div class="content-shell">
<header class="topbar">
<button id="menuToggle" class="menu-toggle" type="button" aria-label="Open menu">Menu</button>
<div>
<p class="eyebrow">AI nutrition cockpit</p>
<h1>Private food tracking with image-backed nutrition estimates</h1>
</div>
<button class="primary-action" type="button" data-nav="log">Add meal</button>
</header>
<main class="content">
<section class="section-page" data-section="dashboard">
<div class="hero-grid">
<article class="hero-card">
<p class="eyebrow">Today</p>
<strong id="todayCalories">0 kcal</strong>
<span id="todayMacroText">P 0g | C 0g | F 0g</span>
<span id="todayProduceText">Fruit 0.0 | Veg 0.0</span>
</article>
<article class="metric-card">
<small>Meals logged</small>
<strong id="mealCountText">0</strong>
<span>Entries saved locally</span>
</article>
<article class="metric-card warm">
<small>7-day average</small>
<strong id="weekAverageText">0 kcal</strong>
<span>Calories per day</span>
</article>
<article class="metric-card cool">
<small>Produce today</small>
<strong id="produceGoalText">0.0 / 5</strong>
<span>Fruit + vegetable servings</span>
</article>
</div>
<section class="panel split-panel">
<div>
<p class="eyebrow">Quick log</p>
<h2>Capture the meal while it is still in front of you.</h2>
<p>Upload a plate photo, add a portion note, and let the vision model hand context to the tasking model for clean nutrition JSON.</p>
<button type="button" data-nav="log">Open meal logger</button>
</div>
<canvas id="macroChart" width="420" height="260"></canvas>
</section>
</section>
<section class="section-page" data-section="log" hidden>
<form id="mealForm" class="panel meal-panel">
<div class="panel-heading">
<p class="eyebrow">Meal intake</p>
<h2>Add meal</h2>
<p>Text-only works, but images give the vision model more evidence for portion and produce estimates.</p>
</div>
<div class="form-grid">
<label class="span-2">
Description
<textarea id="description" rows="5" placeholder="Example: grilled salmon, rice, broccoli, berries"></textarea>
</label>
<label>
Portion or measure
<input id="measure" placeholder="Example: one plate, 450g, 2 cups">
</label>
<label>
Meal image
<input id="image" type="file" accept="image/*">
</label>
</div>
<img id="preview" class="preview" alt="Selected meal preview" hidden>
<div class="form-actions">
<button type="submit">Analyze and save</button>
<p id="status" class="status"></p>
</div>
</form>
</section>
<section class="section-page" data-section="analytics" hidden>
<div class="grid two analytics-grid">
<section class="panel chart-panel wide">
<div class="panel-heading compact">
<p class="eyebrow">Trend</p>
<h2>Calories, last 7 days</h2>
</div>
<canvas id="calorieChart" width="720" height="260"></canvas>
</section>
<section class="panel chart-panel wide">
<div class="panel-heading compact">
<p class="eyebrow">Produce</p>
<h2>Fruit and vegetables</h2>
</div>
<canvas id="produceChart" width="720" height="240"></canvas>
</section>
</div>
</section>
<section class="section-page" data-section="diary" hidden>
<section class="panel">
<div class="row-title">
<div>
<p class="eyebrow">Local history</p>
<h2>Food diary</h2>
</div>
<button id="clearData" class="secondary" type="button">Clear local diary</button>
</div>
<div id="entries" class="entries"></div>
</section>
</section>
<section class="section-page" data-section="settings" hidden>
<section class="panel settings-panel">
<div class="panel-heading">
<p class="eyebrow">Admin</p>
<h2>AI settings</h2>
<p>These settings stay in browser local storage. The API key is sent only to this server-side proxy, not directly to the model endpoint from the browser.</p>
</div>
<div class="form-grid">
<label class="span-2">API base URL<input id="baseUrl" placeholder="https://api.openai.com/v1"></label>
<label class="span-2">API key<input id="apiKey" type="password" placeholder="Optional for local endpoints"></label>
<label>Vision model<input id="visionModel" placeholder="gpt-4o-mini"></label>
<label>Tasking model<input id="taskModel" placeholder="gpt-4o-mini"></label>
</div>
<button id="saveSettings" type="button">Save settings</button>
</section>
</section>
</main>
</div>
</div>
<script src="/app.js"></script>
</body>
</html>

View file

@ -1,29 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sign in - Calorie AI</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/styles.css">
</head>
<body class="login-body">
<main class="login-shell">
<section class="login-brand">
<p class="eyebrow">Private nutrition console</p>
<h1>Calorie AI</h1>
<p>Protected meal logging for calories, macros, fruit, vegetables, and AI image estimates.</p>
</section>
<form id="loginForm" class="login-card">
<span class="brand-mark">CA</span>
<h2>Sign in to Calorie AI</h2>
<p class="muted">Use the local web credentials configured on this host.</p>
<label>Username<input id="username" autocomplete="username" value="admin"></label>
<label>Password<input id="password" type="password" autocomplete="current-password" autofocus></label>
<button type="submit">Sign in</button>
<p id="loginStatus" class="status"></p>
</form>
</main>
<script src="/login.js"></script>
</body>
</html>

View file

@ -1,31 +0,0 @@
const loginForm = document.getElementById('loginForm');
const loginStatus = document.getElementById('loginStatus');
loginForm.addEventListener('submit', async (event) => {
event.preventDefault();
const button = loginForm.querySelector('button');
button.disabled = true;
loginStatus.textContent = 'Checking credentials...';
loginStatus.className = 'status ok';
try {
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
username: document.getElementById('username').value.trim(),
password: document.getElementById('password').value,
}),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Sign in failed' }));
throw new Error(error.error || 'Sign in failed');
}
window.location.href = '/';
} catch (error) {
loginStatus.textContent = error.message;
loginStatus.className = 'status error';
} finally {
button.disabled = false;
}
});

View file

@ -1,246 +0,0 @@
:root {
color-scheme: light;
--bg: #f3efe3;
--panel: rgba(255, 253, 247, .94);
--panel-strong: #fffaf0;
--ink: #17251d;
--muted: #667268;
--line: #e3d8c4;
--green: #236b4b;
--green-2: #39a36d;
--orange: #d5873a;
--blue: #5168bc;
--nav: #112018;
--nav-soft: #1c3427;
--danger: #a43434;
--shadow: 0 28px 80px rgba(41, 31, 17, .12);
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background:
radial-gradient(circle at 15% 10%, rgba(255, 226, 154, .65), transparent 28rem),
radial-gradient(circle at 80% 0%, rgba(79, 138, 104, .22), transparent 30rem),
var(--bg);
color: var(--ink);
}
button, input, textarea { font: inherit; }
button {
border: 0;
border-radius: 16px;
background: linear-gradient(135deg, var(--green), var(--green-2));
color: white;
cursor: pointer;
font-weight: 850;
padding: 13px 17px;
transition: transform .18s ease, box-shadow .18s ease, opacity .18s ease;
}
button:hover { transform: translateY(-1px); box-shadow: 0 12px 28px rgba(35, 107, 75, .22); }
button:disabled { cursor: wait; opacity: .55; transform: none; box-shadow: none; }
button.secondary { background: #ebe1d0; color: var(--ink); }
button.ghost { width: 100%; background: rgba(255,255,255,.09); color: #f4ead6; box-shadow: none; }
input, textarea {
width: 100%;
border: 1px solid #d9ceb8;
border-radius: 16px;
background: #fffaf2;
color: var(--ink);
outline: none;
padding: 13px 14px;
}
input:focus, textarea:focus { border-color: var(--green-2); box-shadow: 0 0 0 4px rgba(57, 163, 109, .14); }
textarea { resize: vertical; }
label { display: grid; gap: 8px; color: var(--muted); font-weight: 800; }
h1, h2, p { margin-top: 0; }
h1 { max-width: 820px; margin-bottom: 0; font-size: clamp(2rem, 4vw, 4.2rem); line-height: .94; letter-spacing: -.06em; }
h2 { margin-bottom: 12px; font-size: clamp(1.35rem, 2vw, 2rem); letter-spacing: -.035em; }
p { color: var(--muted); line-height: 1.6; }
canvas { max-width: 100%; }
.app-frame { display: grid; grid-template-columns: 280px 1fr; min-height: 100vh; }
.sidebar {
position: sticky;
top: 0;
height: 100vh;
padding: 22px;
display: grid;
grid-template-rows: auto 1fr auto;
gap: 28px;
background: linear-gradient(180deg, var(--nav), #0b140f);
color: #f8ecd7;
}
.brand-block { display: flex; align-items: center; gap: 12px; }
.brand-block strong { display: block; font-size: 1.05rem; }
.brand-block small, .sidebar-footer small { color: rgba(248, 236, 215, .62); }
.brand-mark {
width: 48px;
height: 48px;
display: inline-grid;
place-items: center;
border-radius: 17px;
background: linear-gradient(135deg, #e7a24e, #3fb473);
color: #102017;
font-weight: 950;
letter-spacing: -.04em;
}
.main-nav { display: grid; align-content: start; gap: 9px; }
.nav-item {
width: 100%;
padding: 13px 14px;
border-radius: 15px;
background: transparent;
color: rgba(248, 236, 215, .72);
text-align: left;
box-shadow: none;
}
.nav-item:hover, .nav-item.active { background: var(--nav-soft); color: #fff4df; box-shadow: none; }
.sidebar-footer { display: grid; gap: 12px; }
.content-shell { min-width: 0; }
.topbar {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
padding: 26px clamp(18px, 4vw, 48px);
border-bottom: 1px solid rgba(227, 216, 196, .7);
background: rgba(243, 239, 227, .82);
backdrop-filter: blur(20px);
}
.menu-toggle { display: none; background: #e9dfcc; color: var(--ink); box-shadow: none; }
.primary-action { white-space: nowrap; }
.content { width: min(1180px, calc(100% - 36px)); margin: 0 auto; padding: 28px 0 44px; }
.section-page { display: grid; gap: 22px; }
.eyebrow {
margin: 0 0 8px;
color: var(--green);
font-size: .76rem;
font-weight: 950;
letter-spacing: .14em;
text-transform: uppercase;
}
.hero-grid { display: grid; grid-template-columns: minmax(300px, 1.4fr) repeat(3, minmax(150px, .62fr)); gap: 18px; }
.hero-card, .metric-card, .panel, .login-card, .login-brand {
border: 1px solid rgba(227, 216, 196, .9);
border-radius: 28px;
background: var(--panel);
box-shadow: var(--shadow);
}
.hero-card {
min-height: 220px;
padding: 28px;
display: grid;
align-content: end;
background:
linear-gradient(135deg, rgba(22, 42, 30, .9), rgba(35, 107, 75, .84)),
radial-gradient(circle at top right, rgba(235, 173, 79, .6), transparent 20rem);
color: white;
}
.hero-card .eyebrow, .hero-card span { color: rgba(255,255,255,.78); }
.hero-card strong { display: block; margin-bottom: 8px; font-size: clamp(3rem, 8vw, 5.6rem); line-height: .9; letter-spacing: -.07em; }
.hero-card span { display: block; margin-top: 6px; }
.metric-card { padding: 22px; display: grid; align-content: end; gap: 8px; background: linear-gradient(180deg, #fffdf8, #f7efde); }
.metric-card small { color: var(--muted); font-weight: 850; text-transform: uppercase; letter-spacing: .08em; }
.metric-card strong { font-size: 2.2rem; line-height: 1; letter-spacing: -.05em; }
.metric-card span { color: var(--muted); font-size: .92rem; }
.metric-card.warm strong { color: var(--orange); }
.metric-card.cool strong { color: var(--blue); }
.panel { padding: clamp(20px, 3vw, 32px); }
.split-panel { display: grid; grid-template-columns: minmax(280px, .85fr) minmax(320px, 1fr); gap: 24px; align-items: center; }
.panel-heading { max-width: 760px; margin-bottom: 20px; }
.panel-heading.compact { margin-bottom: 8px; }
.grid { display: grid; gap: 20px; }
.grid.two { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.span-2 { grid-column: span 2; }
.form-actions { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; margin-top: 18px; }
.meal-panel { background: linear-gradient(135deg, rgba(255,253,247,.96), rgba(244, 233, 210, .94)); }
.preview { width: 100%; max-height: 360px; object-fit: cover; border: 1px solid var(--line); border-radius: 24px; margin-top: 18px; }
.status { min-height: 1.5em; margin: 0; font-weight: 800; }
.status.error { color: var(--danger); }
.status.ok { color: var(--green); }
.muted { color: var(--muted); font-size: .93rem; }
.chart-panel canvas, .split-panel canvas { width: 100%; height: auto; }
.row-title { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 18px; }
.row-title h2 { margin-bottom: 0; }
.entries { display: grid; gap: 12px; }
.entry {
display: grid;
gap: 7px;
border: 1px solid var(--line);
border-radius: 20px;
padding: 16px;
background: var(--panel-strong);
}
.entry strong { font-size: 1.02rem; }
.entry small { color: var(--muted); }
.login-body { display: grid; place-items: center; padding: 24px; }
.login-shell { width: min(980px, 100%); display: grid; grid-template-columns: 1.1fr .9fr; gap: 22px; align-items: stretch; }
.login-brand {
min-height: 560px;
padding: clamp(28px, 5vw, 54px);
display: grid;
align-content: end;
background:
linear-gradient(135deg, rgba(15, 29, 21, .94), rgba(29, 88, 61, .86)),
radial-gradient(circle at top right, rgba(218, 150, 72, .82), transparent 22rem);
color: #fff4df;
}
.login-brand h1 { color: white; }
.login-brand p, .login-brand .eyebrow { color: rgba(255,244,223,.78); }
.login-card { padding: clamp(24px, 4vw, 40px); align-self: center; display: grid; gap: 16px; }
.login-card h2 { margin-bottom: 0; }
@media (max-width: 1050px) {
.app-frame { grid-template-columns: 1fr; }
.sidebar {
position: fixed;
left: 14px;
top: 14px;
bottom: 14px;
z-index: 30;
width: min(320px, calc(100vw - 28px));
height: auto;
border-radius: 26px;
transform: translateX(calc(-100% - 18px));
transition: transform .2s ease;
box-shadow: 0 30px 90px rgba(0,0,0,.34);
}
body.menu-open .sidebar { transform: translateX(0); }
.menu-toggle { display: inline-flex; }
.hero-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.hero-card { grid-column: span 2; }
.split-panel, .grid.two, .login-shell { grid-template-columns: 1fr; }
.login-brand { min-height: 360px; }
}
@media (max-width: 700px) {
.topbar { align-items: flex-start; padding: 18px; }
.primary-action { display: none; }
.content { width: min(100% - 22px, 1180px); padding-top: 18px; }
.hero-grid, .form-grid { grid-template-columns: 1fr; }
.hero-card, .span-2 { grid-column: auto; }
.row-title, .form-actions { align-items: stretch; flex-direction: column; }
.row-title button, .form-actions button { width: 100%; }
.login-body { padding: 12px; }
.login-brand { min-height: 260px; }
}

View file

@ -4,13 +4,18 @@ const fs = require('fs');
const path = require('path');
const port = Number(process.env.PORT || 8080);
const publicDir = path.join(__dirname, 'public');
const publicDir = fs.existsSync(path.join(__dirname, 'dist')) ? path.join(__dirname, 'dist') : path.join(__dirname, 'public');
const publicRoot = publicDir + path.sep;
const dataDir = process.env.CALORIE_AI_DATA_DIR || path.join(__dirname, 'data');
const authFile = path.join(dataDir, 'auth.json');
const sessionCookieName = 'calorie_ai_session';
const sessionSeconds = 60 * 60 * 24 * 7;
const auth = loadAuthConfig();
const aiConfig = {
baseUrl: (process.env.CALORIE_AI_API_BASE_URL || process.env.LITELLM_API_BASE || 'https://llm.danvics.com/v1').replace(/\/+$/, ''),
apiKey: process.env.CALORIE_AI_API_KEY || process.env.LITELLM_API_KEY || process.env.OPENAI_API_KEY || '',
defaultModel: process.env.CALORIE_AI_DEFAULT_MODEL || process.env.LITELLM_MODEL || 'gpt-4o-mini',
};
const types = {
'.html': 'text/html; charset=utf-8',
@ -18,6 +23,7 @@ const types = {
'.js': 'application/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.svg': 'image/svg+xml',
'.woff2': 'font/woff2',
};
function readBody(req) {
@ -124,7 +130,14 @@ function cookieOptions(req, maxAge = sessionSeconds) {
function publicRequest(req) {
const url = req.url.split('?')[0];
return (req.method === 'POST' && url === '/api/login')
|| ((req.method === 'GET' || req.method === 'HEAD') && ['/login.html', '/login.js', '/styles.css', '/favicon.svg'].includes(url));
|| ((req.method === 'GET' || req.method === 'HEAD') && (url === '/login' || url === '/favicon.svg' || url.startsWith('/assets/')));
}
function sendFile(req, res, target) {
fs.readFile(target, (err, data) => {
if (err) return send(res, 404, 'Not found', 'text/plain; charset=utf-8');
send(res, 200, data, types[path.extname(target)] || 'application/octet-stream', req.method === 'HEAD');
});
}
function serveStatic(req, res) {
@ -132,24 +145,23 @@ function serveStatic(req, res) {
const file = cleanUrl === '/' ? '/index.html' : cleanUrl;
const target = path.normalize(path.join(publicDir, file));
if (!target.startsWith(publicRoot)) return send(res, 403, 'Forbidden', 'text/plain; charset=utf-8');
fs.readFile(target, (err, data) => {
if (err) return send(res, 404, 'Not found', 'text/plain; charset=utf-8');
send(res, 200, data, types[path.extname(target)] || 'application/octet-stream', req.method === 'HEAD');
if (!err) return send(res, 200, data, types[path.extname(target)] || 'application/octet-stream', req.method === 'HEAD');
if (req.method === 'GET' || req.method === 'HEAD') return sendFile(req, res, path.join(publicDir, 'index.html'));
send(res, 404, 'Not found', 'text/plain; charset=utf-8');
});
}
async function proxyChat(req, res) {
try {
const payload = JSON.parse(await readBody(req));
const baseUrl = String(payload.baseUrl || '').replace(/\/+$/, '');
if (!/^https?:\/\//.test(baseUrl)) throw new Error('A valid API base URL is required');
if (!payload.body || typeof payload.body !== 'object') throw new Error('Missing chat completion body');
if (!/^https?:\/\//.test(aiConfig.baseUrl)) throw new Error('AI endpoint is not configured');
if (!payload.body || typeof payload.body !== 'object') throw new Error('Missing request body');
const headers = { 'content-type': 'application/json' };
if (payload.apiKey) headers.authorization = `Bearer ${payload.apiKey}`;
if (aiConfig.apiKey) headers.authorization = `Bearer ${aiConfig.apiKey}`;
const upstream = await fetch(`${baseUrl}/chat/completions`, {
const upstream = await fetch(`${aiConfig.baseUrl}/chat/completions`, {
method: 'POST',
headers,
body: JSON.stringify(payload.body),
@ -161,6 +173,40 @@ async function proxyChat(req, res) {
}
}
async function proxyModels(req, res) {
try {
if (!/^https?:\/\//.test(aiConfig.baseUrl)) throw new Error('AI endpoint is not configured');
const headers = {};
if (aiConfig.apiKey) headers.authorization = `Bearer ${aiConfig.apiKey}`;
const infoBase = aiConfig.baseUrl.replace(/\/v1$/, '');
let models = [];
try {
const info = await fetch(`${infoBase}/model/info`, { headers });
if (info.ok) {
const data = await info.json();
models = (data.data || [])
.filter(item => item.model_name && (!item.model_info || !item.model_info.mode || item.model_info.mode === 'chat'))
.map(item => ({ id: item.model_name, name: item.model_name }));
}
} catch {}
if (!models.length) {
const upstream = await fetch(`${aiConfig.baseUrl}/models`, { headers });
const data = await upstream.json();
models = (data.data || []).map(item => ({ id: item.id, name: item.id }));
}
const deduped = Array.from(new Map(models.filter(m => m.id).map(m => [m.id, m])).values())
.sort((a, b) => a.name.localeCompare(b.name));
if (!deduped.some(model => model.id === aiConfig.defaultModel)) deduped.unshift({ id: aiConfig.defaultModel, name: aiConfig.defaultModel });
send(res, 200, JSON.stringify({ models: deduped, defaultModel: aiConfig.defaultModel }));
} catch (error) {
send(res, 400, JSON.stringify({ error: error.message }));
}
}
async function login(req, res) {
try {
const payload = JSON.parse(await readBody(req));
@ -188,13 +234,14 @@ http.createServer((req, res) => {
if (!authenticated && !publicRequest(req)) {
if (url.startsWith('/api/')) return send(res, 401, JSON.stringify({ error: 'Authentication required' }));
return redirect(res, '/login.html');
return redirect(res, '/login');
}
if (authenticated && (req.method === 'GET' || req.method === 'HEAD') && url === '/login.html') return redirect(res, '/');
if (authenticated && (req.method === 'GET' || req.method === 'HEAD') && url === '/login') return redirect(res, '/');
if (req.method === 'POST' && url === '/api/login') return login(req, res);
if (req.method === 'POST' && url === '/api/logout') return logout(req, res);
if (req.method === 'GET' && url === '/api/session') return send(res, 200, JSON.stringify({ user: auth.user }));
if (req.method === 'POST' && url === '/api/models') return proxyModels(req, res);
if (req.method === 'POST' && req.url === '/api/chat') return proxyChat(req, res);
if (req.method === 'GET' || req.method === 'HEAD') return serveStatic(req, res);
send(res, 405, 'Method not allowed', 'text/plain; charset=utf-8');

537
web/src/App.svelte Normal file
View file

@ -0,0 +1,537 @@
<script>
import { onMount, tick } from 'svelte';
import Field from './Field.svelte';
import Stat from './Stat.svelte';
const entriesKey = 'calorie-ai-web-entries';
const settingsKey = 'calorie-ai-web-settings';
const pages = [
{ id: 'dashboard', label: 'Dashboard', group: 'Tracker' },
{ id: 'log', label: 'Log meal', group: 'Tracker' },
{ id: 'analytics', label: 'Analytics', group: 'Tracker' },
{ id: 'diary', label: 'Diary', group: 'Tracker' },
{ id: 'models', label: 'Models', group: 'Admin' },
{ id: 'settings', label: 'Settings', group: 'Admin' },
];
const mealTypes = ['Breakfast', 'Lunch', 'Dinner', 'Snack', 'Other'];
const fallbackModels = [
{ id: 'claude-haiku-4.5', name: 'Claude Haiku 4.5' },
{ id: 'claude-sonnet-4.6', name: 'Claude Sonnet 4.6' },
{ id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash' },
{ id: 'gpt-4o-mini', name: 'GPT-4o mini' },
];
let loginMode = location.pathname === '/login';
let username = 'admin';
let password = '';
let loginStatus = '';
let loginError = false;
let page = 'dashboard';
let menuSearch = '';
let menuOpen = false;
let sidebarCollapsed = false;
let entries = [];
let models = fallbackModels;
let modelSearch = '';
let modelStatus = '';
let modelError = false;
let settings = { visionModel: fallbackModels[0].id, taskModel: fallbackModels[0].id };
let mealDate = '';
let mealTime = '';
let mealType = 'Breakfast';
let description = '';
let measure = '';
let selectedImageDataUrl = '';
let selectedImageName = '';
let status = '';
let statusError = false;
let savingMeal = false;
let diarySearch = '';
let diaryType = '';
let diaryDate = '';
let clearArmed = false;
let macroCanvas;
let calorieCanvas;
let produceCanvas;
$: filteredPages = pages.filter(item => !menuSearch || item.label.toLowerCase().includes(menuSearch.toLowerCase()));
$: trackerPages = filteredPages.filter(item => item.group === 'Tracker');
$: adminPages = filteredPages.filter(item => item.group === 'Admin');
$: today = totalsFor(todayKey());
$: week = last7Days();
$: weekAverage = Math.round(week.reduce((sum, day) => sum + day.calories, 0) / week.length);
$: produceToday = today.fruit + today.vegetables;
$: filteredModels = models.filter(model => !modelSearch || `${model.name} ${model.id}`.toLowerCase().includes(modelSearch.toLowerCase()));
$: diaryRows = entries
.filter(entry => !diaryType || entry.mealType === diaryType)
.filter(entry => !diaryDate || entry.date === diaryDate)
.filter(entry => {
const query = diarySearch.trim().toLowerCase();
return !query || [entry.mealName, entry.description, entry.notes, entry.foodGroups, entry.mealType]
.some(value => String(value || '').toLowerCase().includes(query));
})
.sort((a, b) => `${b.date} ${b.time}`.localeCompare(`${a.date} ${a.time}`));
onMount(async () => {
entries = readJson(entriesKey, []);
settings = { ...settings, ...readJson(settingsKey, settings) };
sidebarCollapsed = localStorage.getItem('calorie_ai_sidebar_collapsed') === '1';
page = localStorage.getItem('calorie_ai_last_tab') || 'dashboard';
setDefaultMealDateTime();
await loadModels();
await drawCharts();
});
$: if (!loginMode) scheduleCharts(entries, page);
let chartTimer;
function scheduleCharts() {
clearTimeout(chartTimer);
chartTimer = setTimeout(drawCharts, 0);
}
function dateKey(date) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
function timeKey(date) {
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
}
function todayKey() {
return dateKey(new Date());
}
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 setDefaultMealDateTime() {
const now = new Date();
mealDate = dateKey(now);
mealTime = timeKey(now);
const hour = now.getHours();
mealType = hour < 11 ? 'Breakfast' : hour < 15 ? 'Lunch' : hour < 21 ? 'Dinner' : 'Snack';
}
function totalsFor(date) {
return entries.filter(entry => entry.date === date).reduce((sum, entry) => ({
meals: sum.meals + 1,
calories: sum.calories + (entry.calories || 0),
protein: sum.protein + (entry.proteinGrams || 0),
carbs: sum.carbs + (entry.carbsGrams || 0),
fat: sum.fat + (entry.fatGrams || 0),
fruit: sum.fruit + (entry.fruitServings || 0),
vegetables: sum.vegetables + (entry.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 selectPage(id) {
page = id;
menuOpen = false;
localStorage.setItem('calorie_ai_last_tab', id);
}
function toggleSidebarCollapse() {
sidebarCollapsed = !sidebarCollapsed;
localStorage.setItem('calorie_ai_sidebar_collapsed', sidebarCollapsed ? '1' : '0');
}
async function login() {
loginStatus = 'Checking credentials...';
loginError = false;
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username, password }),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Sign in failed' }));
loginStatus = error.error || 'Sign in failed';
loginError = true;
return;
}
location.href = '/';
}
async function logout() {
await fetch('/api/logout', { method: 'POST' });
location.href = '/login';
}
async function loadModels() {
modelStatus = 'Loading models...';
modelError = false;
try {
const response = await fetch('/api/models', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });
if (!response.ok) throw new Error(await response.text());
const data = await response.json();
models = data.models?.length ? data.models : fallbackModels;
const defaultModel = data.defaultModel || models[0].id;
if (!models.some(model => model.id === settings.visionModel)) settings.visionModel = defaultModel;
if (!models.some(model => model.id === settings.taskModel)) settings.taskModel = defaultModel;
saveSettings();
modelStatus = `${models.length} models loaded.`;
} catch {
models = fallbackModels;
modelStatus = 'Could not load server models. Showing defaults.';
modelError = true;
}
}
function saveSettings() {
saveJson(settingsKey, settings);
}
function saveSelectedModels() {
saveSettings();
modelStatus = 'Models saved.';
modelError = false;
}
function selectModel(modelId, target = 'task') {
if (target === 'vision') settings.visionModel = modelId;
else settings.taskModel = modelId;
saveSelectedModels();
}
async 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 chooseImage(event) {
const file = event.currentTarget.files[0];
selectedImageDataUrl = file ? await fileToDataUrl(file) : '';
selectedImageName = file?.name || '';
}
async function chatCompletion(body) {
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ 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 requestImageEstimate() {
if (!selectedImageDataUrl) return '';
return chatCompletion({
model: settings.visionModel,
temperature: 0.15,
messages: [{
role: 'user',
content: [
{ type: 'text', text: `Estimate meal contents, likely portions, calories, protein grams, carbs grams, fat grams, fruit servings, and vegetable servings. User note: ${description}. Portion note: ${measure}` },
{ type: 'image_url', image_url: { url: selectedImageDataUrl } },
],
}],
});
}
async function requestMealEstimate(imageEstimate) {
return chatCompletion({
model: settings.taskModel,
temperature: 0.1,
messages: [
{ role: 'system', content: 'Return strict JSON only. Do not wrap the response in markdown.' },
{ role: 'user', content: `Estimate nutrition for one meal. 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.
Description: ${description}
Portion: ${measure}
Image estimate: ${imageEstimate}` },
],
});
}
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 || '',
};
}
async function saveMeal() {
if (!mealDate || !mealTime || !mealType) return setMealStatus('Choose a date, time, and meal type.', true);
if (!description.trim() && !selectedImageDataUrl) return setMealStatus('Describe the meal or upload an image.', true);
savingMeal = true;
try {
setMealStatus(selectedImageDataUrl ? 'Analyzing image...' : 'Estimating meal...');
const imageEstimate = await requestImageEstimate();
const estimate = parseEstimate(await requestMealEstimate(imageEstimate));
entries = [...entries, {
...estimate,
date: mealDate,
time: mealTime,
mealType,
description,
measure,
imageIncluded: Boolean(selectedImageDataUrl),
imageName: selectedImageName,
}];
saveJson(entriesKey, entries);
description = '';
measure = '';
selectedImageDataUrl = '';
selectedImageName = '';
setDefaultMealDateTime();
setMealStatus('Saved.');
} catch (error) {
setMealStatus(error.message, true);
} finally {
savingMeal = false;
}
}
function setMealStatus(message, error = false) {
status = message;
statusError = error;
}
function clearDiary() {
if (!clearArmed) {
clearArmed = true;
return;
}
entries = [];
saveJson(entriesKey, entries);
clearArmed = false;
}
async function drawCharts() {
await tick();
drawMacroChart();
drawBars(calorieCanvas, week, 'calories');
drawBars(produceCanvas, week, 'produce');
}
function drawMacroChart() {
if (!macroCanvas) return;
const ctx = macroCanvas.getContext('2d');
ctx.clearRect(0, 0, macroCanvas.width, macroCanvas.height);
const values = [today.protein, today.carbs, today.fat];
const colors = ['#2563eb', '#f59e0b', '#7c3aed'];
const labels = [`Protein ${today.protein}g`, `Carbs ${today.carbs}g`, `Fat ${today.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 = 30;
ctx.strokeStyle = colors[index];
ctx.arc(120, 130, 70, start, start + arc);
ctx.stroke();
start += arc;
});
ctx.fillStyle = '#1f2937';
ctx.font = '700 20px Inter, sans-serif';
ctx.fillText('Today', 240, 88);
ctx.font = '15px Inter, sans-serif';
labels.forEach((label, index) => {
ctx.fillStyle = colors[index];
ctx.fillRect(240, 112 + index * 34, 14, 14);
ctx.fillStyle = '#1f2937';
ctx.fillText(label, 264, 125 + index * 34);
});
}
function drawBars(canvas, days, mode) {
if (!canvas) return;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
const values = days.map(day => mode === 'calories' ? day.calories : day.fruit + day.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 = '#e0e7ff';
roundRect(ctx, x, bottom - height, bar, height, 10);
ctx.fillStyle = mode === 'calories' ? '#2563eb' : '#f59e0b';
roundRect(ctx, x, bottom - h, bar, h, 10);
if (mode === 'produce') {
const vegH = day.vegetables / max * height;
ctx.fillStyle = '#10b981';
roundRect(ctx, x + bar * .52, bottom - vegH, bar * .48, vegH, 8);
}
ctx.fillStyle = '#6b7280';
ctx.font = '13px Inter, 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();
}
</script>
{#if loginMode}
<main class="min-h-screen bg-slate-100 px-4 py-8 grid place-items-center">
<div class="grid w-full max-w-5xl gap-6 lg:grid-cols-[1.1fr_0.9fr]">
<section class="min-h-80 rounded-3xl bg-gradient-to-br from-blue-700 to-indigo-900 p-8 text-white shadow-xl lg:p-12 flex flex-col justify-end">
<p class="text-sm font-semibold uppercase tracking-[0.25em] text-blue-100">Food diary</p>
<h1 class="mt-3 text-5xl font-black tracking-tight lg:text-7xl">Calorie AI</h1>
<p class="mt-4 max-w-xl text-blue-100">Track meals, calories, macros, fruit, and vegetables across any day.</p>
</section>
<form class="rounded-3xl border border-slate-200 bg-white p-8 shadow-xl lg:p-10" on:submit|preventDefault={login}>
<div class="mb-6 flex h-12 w-12 items-center justify-center rounded-2xl bg-blue-600 font-black text-white">CA</div>
<h2 class="text-2xl font-bold">Sign in</h2>
<p class="mt-1 text-sm text-slate-500">Use the local account configured on this server.</p>
<label class="mt-6 block text-sm font-semibold text-slate-700">Username
<input id="username" class="mt-2 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-blue-500 focus:ring-4 focus:ring-blue-100" bind:value={username} autocomplete="username">
</label>
<label class="mt-4 block text-sm font-semibold text-slate-700">Password
<input id="password" class="mt-2 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-blue-500 focus:ring-4 focus:ring-blue-100" bind:value={password} type="password" autocomplete="current-password">
</label>
<button class="mt-6 w-full rounded-xl bg-blue-600 px-4 py-3 font-bold text-white hover:bg-blue-700" type="submit">Sign in</button>
{#if loginStatus}<p class:!text-red-600={loginError} class="mt-4 text-sm font-semibold text-blue-700">{loginStatus}</p>{/if}
</form>
</div>
</main>
{:else}
<div class="min-h-screen bg-slate-50 text-slate-900">
<header class="sticky top-0 z-30 bg-gradient-to-r from-blue-600 to-indigo-700 text-white shadow-sm">
<div class="flex items-center justify-between gap-3 px-4 py-3 lg:px-6">
<div class="flex min-w-0 items-center gap-3">
<button class="grid h-10 w-10 place-items-center rounded-xl bg-white/15 lg:hidden" type="button" on:click={() => menuOpen = true} aria-label="Open menu"></button>
<div class="grid h-11 w-11 place-items-center rounded-2xl bg-white text-sm font-black text-blue-700">CA</div>
<div class="min-w-0">
<h1 class="truncate text-lg font-bold">Calorie AI</h1>
<p class="text-xs text-blue-100">Food diary</p>
</div>
</div>
<div class="flex items-center gap-2">
<select class="hidden max-w-64 rounded-lg border border-white/20 bg-white/15 px-3 py-2 text-sm text-white outline-none md:block" bind:value={settings.taskModel} on:change={saveSelectedModels} aria-label="Nutrition estimate model">
{#each models as model}<option class="text-slate-900" value={model.id}>{model.name}</option>{/each}
</select>
<button class="rounded-lg bg-white/15 px-3 py-2 text-sm font-semibold hover:bg-white/25" type="button" on:click={logout}>Sign out</button>
</div>
</div>
</header>
<div class="flex">
<aside class:translate-x-0={menuOpen} class:lg:w-0={sidebarCollapsed} class="fixed inset-y-0 left-0 z-40 w-72 -translate-x-full overflow-hidden border-r border-slate-200 bg-white shadow-xl transition-all duration-200 lg:sticky lg:top-[68px] lg:h-[calc(100vh-68px)] lg:translate-x-0 lg:shadow-none lg:w-60">
<div class="flex items-center justify-between border-b border-slate-200 px-4 py-3 lg:hidden">
<strong>Menu</strong>
<button class="rounded-lg bg-slate-100 px-3 py-1.5 text-sm" type="button" on:click={() => menuOpen = false}>Close</button>
</div>
<div class="border-b border-slate-200 p-3">
<label for="menu-search" class="mb-1 block text-xs font-bold uppercase tracking-wide text-slate-500">Search pages</label>
<input id="menu-search" class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm outline-none focus:border-blue-500 focus:ring-4 focus:ring-blue-100" bind:value={menuSearch} placeholder="Search menu...">
</div>
<nav class="p-2" aria-label="Main menu">
{#if trackerPages.length}<div class="px-3 py-2 text-xs font-bold uppercase tracking-wide text-slate-400">Tracker</div>{/if}
{#each trackerPages as item}<button class:active-tab={page === item.id} class="tab-button" type="button" on:click={() => selectPage(item.id)}>{item.label}</button>{/each}
{#if adminPages.length}<div class="px-3 py-2 text-xs font-bold uppercase tracking-wide text-slate-400">Admin</div>{/if}
{#each adminPages as item}<button class:active-tab={page === item.id} class="tab-button" type="button" on:click={() => selectPage(item.id)}>{item.label}</button>{/each}
</nav>
<button class="absolute bottom-0 hidden w-full border-t border-slate-200 px-4 py-3 text-sm font-semibold text-slate-500 hover:text-blue-600 lg:block" type="button" on:click={toggleSidebarCollapse}>Collapse menu</button>
</aside>
{#if sidebarCollapsed}<button class="fixed left-0 top-1/2 z-30 hidden rounded-r-xl border border-l-0 border-slate-200 bg-white px-2 py-3 shadow lg:block" type="button" on:click={toggleSidebarCollapse}></button>{/if}
{#if menuOpen}<button class="fixed inset-0 z-30 bg-slate-950/40 lg:hidden" type="button" aria-label="Close menu" on:click={() => menuOpen = false}></button>{/if}
<main class="min-w-0 flex-1 p-4 lg:p-6">
{#if page === 'dashboard'}
<section class="space-y-4">
<div><h2 class="text-xl font-bold">Dashboard</h2><p class="text-sm text-slate-500">Todays totals and recent trends.</p></div>
<div class="grid gap-3 xl:grid-cols-[1.4fr_0.6fr_0.6fr_0.8fr]">
<article class="rounded-2xl bg-gradient-to-br from-blue-600 to-indigo-700 p-6 text-white shadow-sm"><div class="text-xs font-bold uppercase tracking-wide text-blue-100">Today</div><div id="todayCalories" class="mt-2 text-5xl font-black">{today.calories} kcal</div><div id="todayMacroText" class="mt-3">P {today.protein}g | C {today.carbs}g | F {today.fat}g</div><div id="todayProduceText" class="text-sm text-blue-100">Fruit {today.fruit.toFixed(1)} | Veg {today.vegetables.toFixed(1)}</div></article>
<Stat label="Meals" value={today.meals} note="Today" />
<Stat label="7-day avg" value={`${weekAverage} kcal`} note="Calories/day" />
<Stat label="Produce" value={`${produceToday.toFixed(1)} / 5`} note="Fruit + vegetables" />
</div>
<div class="grid gap-4 lg:grid-cols-[0.8fr_1.2fr]">
<section class="card"><h3 class="card-title">Quick actions</h3><div class="grid gap-2 p-4"><button class="btn-primary" type="button" on:click={() => selectPage('log')}>Log a meal</button><button class="btn-secondary" type="button" on:click={() => selectPage('diary')}>Review diary</button><button class="btn-secondary" type="button" on:click={() => selectPage('models')}>Choose models</button></div></section>
<section class="card"><h3 class="card-title">Macros today</h3><div class="p-4"><canvas id="macroChart" bind:this={macroCanvas} width="520" height="260"></canvas></div></section>
</div>
</section>
{:else if page === 'log'}
<section class="space-y-4"><div><h2 class="text-xl font-bold">Log meal</h2><p class="text-sm text-slate-500">Backfill meals for any date and time.</p></div>
<form class="card" on:submit|preventDefault={saveMeal}>
<h3 class="card-title">Meal details</h3>
<div class="grid gap-4 p-4 md:grid-cols-3">
<Field label="Date"><input id="mealDate" class="input" type="date" bind:value={mealDate} required></Field>
<Field label="Time of day"><input id="mealTime" class="input" type="time" bind:value={mealTime} required></Field>
<Field label="Meal type"><select id="mealType" class="input" bind:value={mealType} required>{#each mealTypes as type}<option>{type}</option>{/each}</select></Field>
<Field className="md:col-span-3" label="Description"><textarea id="description" class="input min-h-32" bind:value={description} placeholder="Example: grilled salmon, rice, broccoli, berries"></textarea></Field>
<Field label="Portion or measure"><input id="measure" class="input" bind:value={measure} placeholder="Example: one plate, 450g, 2 cups"></Field>
<Field label="Meal image"><input id="image" class="input" type="file" accept="image/*" on:change={chooseImage}></Field>
</div>
{#if selectedImageDataUrl}<img id="preview" class="mx-4 mb-4 max-h-80 w-[calc(100%-2rem)] rounded-2xl object-cover" src={selectedImageDataUrl} alt="Selected meal preview">{/if}
<div class="flex flex-col gap-3 border-t border-slate-200 p-4 md:flex-row md:items-center"><button class="btn-primary" type="submit" disabled={savingMeal}>{savingMeal ? 'Saving...' : 'Analyze and save'}</button>{#if status}<p id="status" class:text-red-600={statusError} class="text-sm font-semibold text-green-700">{status}</p>{/if}</div>
</form>
</section>
{:else if page === 'analytics'}
<section class="space-y-4"><div><h2 class="text-xl font-bold">Analytics</h2><p class="text-sm text-slate-500">Seven-day calorie and produce trends.</p></div><div class="grid gap-4 xl:grid-cols-2"><section class="card"><h3 class="card-title">Calories, last 7 days</h3><div class="p-4"><canvas id="calorieChart" bind:this={calorieCanvas} width="720" height="260"></canvas></div></section><section class="card"><h3 class="card-title">Fruit and vegetables</h3><div class="p-4"><canvas id="produceChart" bind:this={produceCanvas} width="720" height="240"></canvas></div></section></div></section>
{:else if page === 'diary'}
<section class="space-y-4"><div><h2 class="text-xl font-bold">Diary</h2><p class="text-sm text-slate-500">Search and filter local meal entries.</p></div><section class="card"><div class="grid gap-3 p-4 md:grid-cols-[1fr_180px_170px_150px]"><Field label="Search"><input id="diarySearch" class="input" bind:value={diarySearch} placeholder="Search meals, notes, groups..."></Field><Field label="Meal type"><select id="diaryTypeFilter" class="input" bind:value={diaryType}><option value="">All types</option>{#each mealTypes as type}<option>{type}</option>{/each}</select></Field><Field label="Date"><input id="diaryDateFilter" class="input" type="date" bind:value={diaryDate}></Field><div class="flex items-end"><button class="w-full rounded-lg border border-red-200 px-4 py-2 text-sm font-bold text-red-600 hover:bg-red-50" type="button" on:click={clearDiary}>{clearArmed ? 'Click again' : 'Clear diary'}</button></div></div></section><div id="entries" class="grid gap-3">{#if diaryRows.length}{#each diaryRows as entry}<article class="entry card p-4"><div class="flex flex-col justify-between gap-2 md:flex-row"><div><strong>{entry.mealName}</strong><div class="text-sm text-slate-500">{entry.date} {entry.time} · {entry.mealType}</div></div><div class="font-bold">{entry.calories} kcal</div></div><div class="mt-2 text-sm">P {entry.proteinGrams}g · C {entry.carbsGrams}g · F {entry.fatGrams}g · Fruit {Number(entry.fruitServings || 0).toFixed(1)} · Vegetables {Number(entry.vegetableServings || 0).toFixed(1)}</div>{#if entry.description}<div class="mt-2 text-sm text-slate-500">{entry.description}</div>{/if}{#if entry.foodGroups}<div class="text-sm text-slate-500">Groups: {entry.foodGroups}</div>{/if}{#if entry.notes}<div class="text-sm text-slate-500">{entry.notes}</div>{/if}</article>{/each}{:else}<div class="empty-state">No meals match the current filters.</div>{/if}</div></section>
{:else if page === 'models'}
<section class="space-y-4"><div><h2 class="text-xl font-bold">Models</h2><p class="text-sm text-slate-500">Choose from the models configured on the server. Model IDs are not typed manually.</p></div><div class="grid gap-4 lg:grid-cols-[0.8fr_1.2fr]"><section class="card"><h3 class="card-title">Selected models</h3><div class="grid gap-3 p-4"><Field label="Image analysis model"><select id="visionModel" class="input" bind:value={settings.visionModel}>{#each models as model}<option value={model.id}>{model.name}</option>{/each}</select></Field><Field label="Nutrition estimate model"><select id="taskModel" class="input" bind:value={settings.taskModel}>{#each models as model}<option value={model.id}>{model.name}</option>{/each}</select></Field><button class="btn-primary" type="button" on:click={saveSelectedModels}>Save selected models</button>{#if modelStatus}<p id="modelStatus" class:text-red-600={modelError} class="text-sm font-semibold text-green-700">{modelStatus}</p>{/if}</div></section><section class="card"><div class="flex flex-col justify-between gap-2 border-b border-slate-200 p-4 md:flex-row md:items-center"><h3 class="font-bold">Available models</h3><button class="btn-secondary" type="button" on:click={loadModels}>Refresh models</button></div><div class="p-4"><input id="modelSearch" class="input mb-3" bind:value={modelSearch} placeholder="Search models..."><div class="grid gap-2">{#each filteredModels as model}<button class="flex items-center justify-between rounded-lg border border-slate-200 px-3 py-2 text-left text-sm hover:border-blue-300 hover:bg-blue-50" type="button" on:click={() => selectModel(model.id)}><span class="truncate">{model.name}</span><small class="ml-3 shrink-0 text-slate-500">{model.id === settings.visionModel ? 'Image' : ''}{model.id === settings.visionModel && model.id === settings.taskModel ? ' + ' : ''}{model.id === settings.taskModel ? 'Nutrition' : ''}</small></button>{/each}</div></div></section></div></section>
{:else if page === 'settings'}
<section class="space-y-4"><div><h2 class="text-xl font-bold">Settings</h2><p class="text-sm text-slate-500">Connection settings are managed on the server.</p></div><section class="card"><h3 class="card-title">AI connection</h3><div class="p-4 text-sm text-slate-600"><p>API base URL and API key are read from environment variables.</p><p class="mt-2">Use the Models page to refresh and select available models.</p></div></section></section>
{/if}
</main>
</div>
</div>
{/if}
<style>
.tab-button { display: flex; width: 100%; align-items: center; border-left: 3px solid transparent; padding: 0.65rem 1rem; text-align: left; font-size: 0.875rem; font-weight: 600; color: #475569; }
.tab-button:hover { background: #f8fafc; color: #0f172a; }
.active-tab { border-left-color: #2563eb; background: #dbeafe; color: #2563eb; }
.card { border-radius: 0.9rem; border: 1px solid #e2e8f0; background: white; box-shadow: 0 1px 3px rgba(15,23,42,.06); overflow: hidden; }
.card-title { border-bottom: 1px solid #e2e8f0; background: #f8fafc; padding: 0.75rem 1rem; font-size: 0.9rem; font-weight: 800; color: #334155; }
.input { width: 100%; border-radius: 0.65rem; border: 1px solid #cbd5e1; padding: 0.6rem 0.75rem; font-size: 0.9rem; outline: none; }
.input:focus { border-color: #2563eb; box-shadow: 0 0 0 4px #dbeafe; }
.btn-primary { border-radius: 0.65rem; background: #2563eb; padding: 0.65rem 1rem; font-size: 0.9rem; font-weight: 800; color: white; }
.btn-primary:hover { background: #1d4ed8; }
.btn-primary:disabled { opacity: .55; }
.btn-secondary { border-radius: 0.65rem; border: 1px solid #cbd5e1; background: white; padding: 0.65rem 1rem; font-size: 0.9rem; font-weight: 800; color: #334155; }
.btn-secondary:hover { border-color: #2563eb; color: #2563eb; }
.empty-state { border-radius: 0.9rem; border: 1px dashed #cbd5e1; background: white; padding: 2rem; text-align: center; color: #64748b; }
</style>

9
web/src/Field.svelte Normal file
View file

@ -0,0 +1,9 @@
<script>
export let label;
export let className = '';
</script>
<label class={`block text-sm font-semibold text-slate-700 ${className}`}>
<span class="mb-1 block">{label}</span>
<slot />
</label>

11
web/src/Stat.svelte Normal file
View file

@ -0,0 +1,11 @@
<script>
export let label;
export let value;
export let note;
</script>
<article class="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
<div class="text-xs font-bold uppercase tracking-wide text-slate-500">{label}</div>
<div class="mt-3 text-2xl font-black tracking-tight text-slate-900">{value}</div>
<div class="mt-1 text-sm text-slate-500">{note}</div>
</article>

10
web/src/app.css Normal file
View file

@ -0,0 +1,10 @@
@import "tailwindcss";
@theme {
--font-sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
html { background: #f8fafc; }
body { margin: 0; min-height: 100vh; font-family: var(--font-sans); color: #0f172a; }
button, input, select, textarea { font: inherit; }
canvas { max-width: 100%; }

7
web/src/main.js Normal file
View file

@ -0,0 +1,7 @@
import './app.css';
import { mount } from 'svelte';
import App from './App.svelte';
const app = mount(App, { target: document.getElementById('app') });
export default app;

View file

@ -5,19 +5,37 @@ const png = Buffer.from(
'base64',
);
async function mockModels(page) {
await page.route('**/api/models', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
defaultModel: 'claude-haiku-4.5',
models: [
{ id: 'claude-haiku-4.5', name: 'Claude Haiku 4.5' },
{ id: 'claude-sonnet-4.6', name: 'Claude Sonnet 4.6' },
{ id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash' },
],
}),
});
});
}
async function login(page) {
await mockModels(page);
await page.goto('/');
await expect(page).toHaveURL(/login\.html/);
await expect(page.getByRole('heading', { name: 'Sign in to Calorie AI' })).toBeVisible();
await expect(page).toHaveURL(/\/login$/);
await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible();
await page.locator('#password').fill('test-password');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByRole('heading', { name: /Private food tracking/ })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
}
test('requires authentication before loading the app', async ({ page, request }) => {
await page.goto('/');
await expect(page).toHaveURL(/login\.html/);
await expect(page.getByRole('heading', { name: 'Sign in to Calorie AI' })).toBeVisible();
await expect(page).toHaveURL(/\/login$/);
await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible();
const response = await request.post('/api/chat', { data: {} });
expect(response.status()).toBe(401);
@ -28,23 +46,27 @@ test.describe('authenticated app', () => {
await login(page);
await page.evaluate(() => localStorage.clear());
await page.reload();
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});
test('loads the dashboard, menu, and validates missing meal input', async ({ page }) => {
test('uses searchable page navigation and validates missing meal input', async ({ page }) => {
await expect(page.getByRole('navigation', { name: 'Main menu' })).toBeVisible();
await expect(page.locator('#todayCalories')).toHaveText('0 kcal');
await page.locator('#menu-search').fill('log');
await expect(page.getByRole('button', { name: 'Log meal' })).toBeVisible();
await page.getByRole('button', { name: 'Log meal' }).click();
await page.getByRole('button', { name: 'Analyze and save' }).click();
await expect(page.locator('#status')).toContainText('Describe the meal or upload an image.');
});
test('saves settings, uploads an image, logs nutrition, updates charts, and clears diary', async ({ page }) => {
test('selects models from dropdowns, backfills a meal, updates charts, and clears diary', async ({ page }) => {
let calls = 0;
await page.route('**/api/chat', async (route) => {
calls += 1;
const content = calls === 1
? '{"foods":["salmon","rice","broccoli","berries"],"calories":620,"proteinGrams":42,"carbsGrams":58,"fatGrams":22,"fruitServings":0.5,"vegetableServings":1.5}'
: '{"mealName":"Salmon rice bowl","calories":640,"proteinGrams":44,"carbsGrams":60,"fatGrams":23,"fruitServings":0.5,"vegetableServings":1.5,"foodGroups":"fish, grains, fruit, vegetables","notes":"Estimated from uploaded image and portion note."}';
: '{"mealName":"Salmon rice bowl","calories":640,"proteinGrams":44,"carbsGrams":60,"fatGrams":23,"fruitServings":0.5,"vegetableServings":1.5,"foodGroups":"fish, grains, fruit, vegetables","notes":"Estimated from image and portion note."}';
await route.fulfill({
status: 200,
@ -53,13 +75,16 @@ test.describe('authenticated app', () => {
});
});
await page.getByRole('button', { name: 'AI settings' }).click();
await page.locator('#baseUrl').fill('https://api.example.test/v1');
await page.locator('#visionModel').fill('vision-test-model');
await page.locator('#taskModel').fill('task-test-model');
await page.getByRole('button', { name: 'Save settings' }).click();
await page.getByRole('button', { name: 'Models', exact: true }).click();
await page.locator('#visionModel').selectOption('gemini-2.5-flash');
await page.locator('#taskModel').selectOption('claude-sonnet-4.6');
await page.getByRole('button', { name: 'Save selected models' }).click();
await expect(page.locator('#modelStatus')).toContainText('Models saved.');
await page.getByRole('button', { name: 'Log meal' }).click();
await page.locator('#mealDate').fill('2026-05-18');
await page.locator('#mealTime').fill('08:15');
await page.locator('#mealType').selectOption('Breakfast');
await page.locator('#description').fill('salmon rice bowl with broccoli and berries');
await page.locator('#measure').fill('one dinner bowl');
await page.locator('#image').setInputFiles({ name: 'meal.png', mimeType: 'image/png', buffer: png });
@ -67,20 +92,18 @@ test.describe('authenticated app', () => {
await page.getByRole('button', { name: 'Analyze and save' }).click();
await expect(page.locator('#status')).toContainText('Saved.');
await expect(page.locator('#todayCalories')).toHaveText('640 kcal');
await expect(page.locator('#todayMacroText')).toHaveText('P 44g | C 60g | F 23g');
await expect(page.locator('#todayProduceText')).toHaveText('Fruit 0.5 | Veg 1.5');
await page.getByRole('button', { name: 'Diary', exact: true }).click();
await expect(page.locator('.entry')).toContainText('Salmon rice bowl');
await expect(page.locator('.entry')).toContainText('2026-05-18 08:15 · Breakfast');
await page.getByRole('button', { name: 'Analytics' }).click();
await expect(page.locator('#calorieChart')).toBeVisible();
await expect(page.locator('#produceChart')).toBeVisible();
await page.getByRole('button', { name: 'Diary', exact: true }).click();
await expect(page.locator('.entry')).toContainText('Salmon rice bowl');
page.once('dialog', dialog => dialog.accept());
await page.getByRole('button', { name: 'Clear local diary' }).click();
await expect(page.locator('#todayCalories')).toHaveText('0 kcal');
await expect(page.locator('#entries')).toContainText('No meals logged yet.');
await page.getByRole('button', { name: 'Clear diary' }).click();
await page.getByRole('button', { name: 'Click again' }).click();
await expect(page.locator('#entries')).toContainText('No meals match the current filters.');
});
});

7
web/vite.config.js Normal file
View file

@ -0,0 +1,7 @@
import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
plugins: [svelte(), tailwindcss()],
});