Update web model management and meal diary
All checks were successful
Android CI / build (push) Successful in 1m6s
Web CI / test (push) Successful in 50s

This commit is contained in:
Daniel 2026-05-19 19:47:30 +02:00
parent 87102dbc5b
commit 2e2ba12cf3
9 changed files with 1852 additions and 123 deletions

1059
web/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -16,5 +16,8 @@
"tailwindcss": "^4.3.0", "tailwindcss": "^4.3.0",
"vite": "^8.0.13" "vite": "^8.0.13"
}, },
"dependencies": {} "dependencies": {
"argon2": "^0.44.0",
"express": "^5.2.1"
}
} }

View file

@ -2,15 +2,18 @@ const http = require('http');
const crypto = require('crypto'); const crypto = require('crypto');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const argon2 = require('argon2');
const port = Number(process.env.PORT || 8080); const port = Number(process.env.PORT || 8080);
const publicDir = fs.existsSync(path.join(__dirname, 'dist')) ? path.join(__dirname, 'dist') : 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 publicRoot = publicDir + path.sep;
const dataDir = process.env.CALORIE_AI_DATA_DIR || path.join(__dirname, 'data'); const dataDir = process.env.CALORIE_AI_DATA_DIR || path.join(__dirname, 'data');
const authFile = path.join(dataDir, 'auth.json'); const authFile = path.join(dataDir, 'auth.json');
const modelsFile = path.join(dataDir, 'models.json');
const sessionCookieName = 'calorie_ai_session'; const sessionCookieName = 'calorie_ai_session';
const sessionSeconds = 60 * 60 * 24 * 7; const sessionSeconds = 60 * 60 * 24 * 7;
const auth = loadAuthConfig(); let auth;
let modelConfig;
const aiConfig = { const aiConfig = {
baseUrl: (process.env.CALORIE_AI_API_BASE_URL || process.env.LITELLM_API_BASE || 'https://llm.danvics.com/v1').replace(/\/+$/, ''), 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 || '', apiKey: process.env.CALORIE_AI_API_KEY || process.env.LITELLM_API_KEY || process.env.OPENAI_API_KEY || '',
@ -41,26 +44,74 @@ function readBody(req) {
}); });
} }
function loadAuthConfig() { function readJsonFile(file, fallback) {
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return fallback; }
}
function writePrivateJson(file, value) {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, JSON.stringify(value, null, 2));
fs.chmodSync(file, 0o600);
}
async function hashPassword(password) {
return argon2.hash(password, { type: argon2.argon2id });
}
async function loadAuthConfig() {
let stored = {}; let stored = {};
try { stored = readJsonFile(authFile, {});
stored = JSON.parse(fs.readFileSync(authFile, 'utf8'));
} catch {
stored = {};
}
const user = process.env.CALORIE_AI_WEB_USER || stored.user || 'admin'; const user = process.env.CALORIE_AI_WEB_USER || stored.user || 'admin';
const password = process.env.CALORIE_AI_WEB_PASSWORD || stored.password || crypto.randomBytes(18).toString('base64url'); if (process.env.CALORIE_AI_WEB_PASSWORD && process.env.CALORIE_AI_SESSION_SECRET) {
return {
user,
passwordHash: await hashPassword(process.env.CALORIE_AI_WEB_PASSWORD),
sessionSecret: process.env.CALORIE_AI_SESSION_SECRET,
};
}
const generatedPassword = crypto.randomBytes(18).toString('base64url');
const plainPassword = stored.password || (!stored.passwordHash ? generatedPassword : '');
const passwordHash = stored.passwordHash || await hashPassword(plainPassword);
const sessionSecret = process.env.CALORIE_AI_SESSION_SECRET || stored.sessionSecret || crypto.randomBytes(32).toString('hex'); const sessionSecret = process.env.CALORIE_AI_SESSION_SECRET || stored.sessionSecret || crypto.randomBytes(32).toString('hex');
if (!process.env.CALORIE_AI_WEB_PASSWORD || !process.env.CALORIE_AI_SESSION_SECRET) { if (!stored.passwordHash || !stored.sessionSecret || stored.password) {
fs.mkdirSync(dataDir, { recursive: true }); writePrivateJson(authFile, {
fs.writeFileSync(authFile, JSON.stringify({ user, password, sessionSecret, createdAt: new Date().toISOString() }, null, 2)); user,
fs.chmodSync(authFile, 0o600); passwordHash,
sessionSecret,
createdAt: stored.createdAt || new Date().toISOString(),
updatedAt: new Date().toISOString(),
...(plainPassword && !stored.passwordHash ? { initialPassword: plainPassword } : {}),
});
console.log(`Calorie AI auth is stored at ${authFile}`); console.log(`Calorie AI auth is stored at ${authFile}`);
} }
return { user, password, sessionSecret }; return { user, passwordHash, sessionSecret };
}
function loadModelConfig() {
const stored = readJsonFile(modelsFile, {});
const defaultModel = stored.defaultModel || aiConfig.defaultModel;
const models = Array.isArray(stored.models) && stored.models.length
? stored.models
: [{ id: defaultModel, name: defaultModel, tag: 'DEFAULT' }];
const config = { defaultModel, models: dedupeModels(models) };
if (!fs.existsSync(modelsFile)) saveModelConfig(config);
return config;
}
function saveModelConfig(config = modelConfig) {
writePrivateJson(modelsFile, { ...config, updatedAt: new Date().toISOString() });
}
function dedupeModels(models) {
return Array.from(new Map((models || []).filter(model => model && model.id).map(model => [model.id, {
id: String(model.id),
name: String(model.name || model.id),
tag: model.tag || 'ADDED',
}])).values()).sort((a, b) => a.name.localeCompare(b.name));
} }
function send(res, status, body, contentType = 'application/json; charset=utf-8', headOnly = false, headers = {}) { function send(res, status, body, contentType = 'application/json; charset=utf-8', headOnly = false, headers = {}) {
@ -173,35 +224,78 @@ async function proxyChat(req, res) {
} }
} }
async function fetchProviderModels(search = '') {
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 query = search.trim().toLowerCase();
return dedupeModels(models)
.filter(model => !query || `${model.name} ${model.id}`.toLowerCase().includes(query))
.slice(0, 100);
}
async function proxyModels(req, res) { async function proxyModels(req, res) {
try { try {
if (!/^https?:\/\//.test(aiConfig.baseUrl)) throw new Error('AI endpoint is not configured'); send(res, 200, JSON.stringify({ models: modelConfig.models, defaultModel: modelConfig.defaultModel }));
} catch (error) {
send(res, 400, JSON.stringify({ error: error.message }));
}
}
const headers = {}; async function searchModels(req, res) {
if (aiConfig.apiKey) headers.authorization = `Bearer ${aiConfig.apiKey}`; try {
const infoBase = aiConfig.baseUrl.replace(/\/v1$/, ''); const payload = req.method === 'POST' ? JSON.parse(await readBody(req) || '{}') : {};
if (!payload.query || String(payload.query).trim().length < 2) {
let models = []; return send(res, 200, JSON.stringify({ models: [], count: 0 }));
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 models = await fetchProviderModels(payload.query || '');
send(res, 200, JSON.stringify({ models, count: models.length }));
} catch (error) {
send(res, 400, JSON.stringify({ error: error.message }));
}
}
const deduped = Array.from(new Map(models.filter(m => m.id).map(m => [m.id, m])).values()) async function addModel(req, res) {
.sort((a, b) => a.name.localeCompare(b.name)); try {
if (!deduped.some(model => model.id === aiConfig.defaultModel)) deduped.unshift({ id: aiConfig.defaultModel, name: aiConfig.defaultModel }); const payload = JSON.parse(await readBody(req));
send(res, 200, JSON.stringify({ models: deduped, defaultModel: aiConfig.defaultModel })); if (!payload.id) return send(res, 400, JSON.stringify({ error: 'Model ID required' }));
modelConfig.models = dedupeModels([...modelConfig.models, { id: payload.id, name: payload.name || payload.id, tag: 'ADDED' }]);
if (!modelConfig.defaultModel) modelConfig.defaultModel = payload.id;
saveModelConfig();
send(res, 200, JSON.stringify({ ok: true, models: modelConfig.models, defaultModel: modelConfig.defaultModel }));
} catch (error) {
send(res, 400, JSON.stringify({ error: error.message }));
}
}
async function removeModel(req, res) {
try {
const payload = JSON.parse(await readBody(req));
modelConfig.models = modelConfig.models.filter(model => model.id !== payload.id);
if (!modelConfig.models.length) modelConfig.models = [{ id: aiConfig.defaultModel, name: aiConfig.defaultModel, tag: 'DEFAULT' }];
if (!modelConfig.models.some(model => model.id === modelConfig.defaultModel)) modelConfig.defaultModel = modelConfig.models[0].id;
saveModelConfig();
send(res, 200, JSON.stringify({ ok: true, models: modelConfig.models, defaultModel: modelConfig.defaultModel }));
} catch (error) { } catch (error) {
send(res, 400, JSON.stringify({ error: error.message })); send(res, 400, JSON.stringify({ error: error.message }));
} }
@ -210,7 +304,9 @@ async function proxyModels(req, res) {
async function login(req, res) { async function login(req, res) {
try { try {
const payload = JSON.parse(await readBody(req)); const payload = JSON.parse(await readBody(req));
if (!safeEqual(payload.username || '', auth.user) || !safeEqual(payload.password || '', auth.password)) { const validUser = safeEqual(payload.username || '', auth.user);
const validPassword = validUser && await argon2.verify(auth.passwordHash, payload.password || '');
if (!validUser || !validPassword) {
return send(res, 401, JSON.stringify({ error: 'Invalid username or password' })); return send(res, 401, JSON.stringify({ error: 'Invalid username or password' }));
} }
@ -222,13 +318,37 @@ async function login(req, res) {
} }
} }
async function changePassword(req, res) {
try {
const payload = JSON.parse(await readBody(req));
if (!await argon2.verify(auth.passwordHash, payload.currentPassword || '')) return send(res, 401, JSON.stringify({ error: 'Current password is incorrect' }));
if (!payload.newPassword || payload.newPassword.length < 12) return send(res, 400, JSON.stringify({ error: 'Use at least 12 characters for the new password' }));
auth.passwordHash = await hashPassword(payload.newPassword);
writePrivateJson(authFile, { user: auth.user, passwordHash: auth.passwordHash, sessionSecret: auth.sessionSecret, updatedAt: new Date().toISOString() });
send(res, 200, JSON.stringify({ ok: true }));
} catch (error) {
send(res, 400, JSON.stringify({ error: error.message }));
}
}
async function resetPassword(req, res) {
try {
const generatedPassword = crypto.randomBytes(18).toString('base64url');
auth.passwordHash = await hashPassword(generatedPassword);
writePrivateJson(authFile, { user: auth.user, passwordHash: auth.passwordHash, sessionSecret: auth.sessionSecret, updatedAt: new Date().toISOString() });
send(res, 200, JSON.stringify({ ok: true, password: generatedPassword }));
} catch (error) {
send(res, 400, JSON.stringify({ error: error.message }));
}
}
function logout(req, res) { function logout(req, res) {
send(res, 200, JSON.stringify({ ok: true }), undefined, false, { send(res, 200, JSON.stringify({ ok: true }), undefined, false, {
'set-cookie': `${sessionCookieName}=; ${cookieOptions(req, 0)}`, 'set-cookie': `${sessionCookieName}=; ${cookieOptions(req, 0)}`,
}); });
} }
http.createServer((req, res) => { async function handleRequest(req, res) {
const url = req.url.split('?')[0]; const url = req.url.split('?')[0];
const authenticated = isAuthenticated(req); const authenticated = isAuthenticated(req);
@ -240,11 +360,27 @@ http.createServer((req, res) => {
if (authenticated && (req.method === 'GET' || req.method === 'HEAD') && url === '/login') 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/login') return login(req, res);
if (req.method === 'POST' && url === '/api/logout') return logout(req, res); if (req.method === 'POST' && url === '/api/logout') return logout(req, res);
if (req.method === 'POST' && url === '/api/password/change') return changePassword(req, res);
if (req.method === 'POST' && url === '/api/password/reset') return resetPassword(req, res);
if (req.method === 'GET' && url === '/api/session') return send(res, 200, JSON.stringify({ user: auth.user })); 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' && url === '/api/models') return proxyModels(req, res);
if (req.method === 'POST' && url === '/api/models/search') return searchModels(req, res);
if (req.method === 'POST' && url === '/api/models/add') return addModel(req, res);
if (req.method === 'POST' && url === '/api/models/remove') return removeModel(req, res);
if (req.method === 'POST' && req.url === '/api/chat') return proxyChat(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); if (req.method === 'GET' || req.method === 'HEAD') return serveStatic(req, res);
send(res, 405, 'Method not allowed', 'text/plain; charset=utf-8'); send(res, 405, 'Method not allowed', 'text/plain; charset=utf-8');
}).listen(port, () => { }
console.log(`Calorie AI web listening on ${port}`);
async function start() {
auth = await loadAuthConfig();
modelConfig = loadModelConfig();
http.createServer((req, res) => handleRequest(req, res).catch(error => send(res, 500, JSON.stringify({ error: error.message })))).listen(port, () => {
console.log(`Calorie AI web listening on ${port}`);
});
}
start().catch(error => {
console.error(error);
process.exit(1);
}); });

View file

@ -2,16 +2,20 @@
import { onMount, tick } from 'svelte'; import { onMount, tick } from 'svelte';
import Field from './Field.svelte'; import Field from './Field.svelte';
import Stat from './Stat.svelte'; import Stat from './Stat.svelte';
import MealForm from './MealForm.svelte';
import DiaryPage from './DiaryPage.svelte';
import SettingsPage from './SettingsPage.svelte';
const entriesKey = 'calorie-ai-web-entries'; const entriesKey = 'calorie-ai-web-entries';
const trashKey = 'calorie-ai-web-trash';
const settingsKey = 'calorie-ai-web-settings'; const settingsKey = 'calorie-ai-web-settings';
const pages = [ const pages = [
{ id: 'dashboard', label: 'Dashboard', group: 'Tracker' }, { id: 'dashboard', label: 'Dashboard', icon: '⌂', group: 'Tracker' },
{ id: 'log', label: 'Log meal', group: 'Tracker' }, { id: 'log', label: 'Log meal', icon: '+', group: 'Tracker' },
{ id: 'analytics', label: 'Analytics', group: 'Tracker' }, { id: 'analytics', label: 'Analytics', icon: '↗', group: 'Tracker' },
{ id: 'diary', label: 'Diary', group: 'Tracker' }, { id: 'diary', label: 'Diary', icon: '☰', group: 'Tracker' },
{ id: 'models', label: 'Models', group: 'Admin' }, { id: 'trash', label: 'Trash', icon: '×', group: 'Tracker' },
{ id: 'settings', label: 'Settings', group: 'Admin' }, { id: 'settings', label: 'Settings', icon: '⚙', group: 'Admin' },
]; ];
const mealTypes = ['Breakfast', 'Lunch', 'Dinner', 'Snack', 'Other']; const mealTypes = ['Breakfast', 'Lunch', 'Dinner', 'Snack', 'Other'];
const fallbackModels = [ const fallbackModels = [
@ -30,14 +34,21 @@
let page = 'dashboard'; let page = 'dashboard';
let menuSearch = ''; let menuSearch = '';
let menuOpen = false; let menuOpen = false;
let sidebarCollapsed = false;
let entries = []; let entries = [];
let models = fallbackModels; let models = fallbackModels;
let discoveredModels = [];
let modelSearch = ''; let modelSearch = '';
let modelStatus = ''; let modelStatus = '';
let modelError = false; let modelError = false;
let settings = { visionModel: fallbackModels[0].id, taskModel: fallbackModels[0].id }; let modelSearching = false;
let settings = { visionModel: fallbackModels[0].id, taskModel: fallbackModels[0].id, heightCm: '', weightKg: '', targetWeightKg: '', activityLevel: 'Moderate', pace: 'Steady' };
let currentPassword = '';
let newPassword = '';
let confirmPassword = '';
let passwordStatus = '';
let passwordError = false;
let resetPasswordValue = '';
let mealDate = ''; let mealDate = '';
let mealTime = ''; let mealTime = '';
@ -49,11 +60,18 @@
let status = ''; let status = '';
let statusError = false; let statusError = false;
let savingMeal = false; let savingMeal = false;
let editingEntryId = '';
let estimateDraft = emptyEstimate();
let planStatus = '';
let planError = false;
let planLoading = false;
let planAdvice = '';
let diarySearch = ''; let diarySearch = '';
let diaryType = ''; let diaryType = '';
let diaryDate = ''; let diaryDate = '';
let clearArmed = false; let selectedEntryIds = [];
let trash = [];
let macroCanvas; let macroCanvas;
let calorieCanvas; let calorieCanvas;
@ -66,7 +84,6 @@
$: week = last7Days(); $: week = last7Days();
$: weekAverage = Math.round(week.reduce((sum, day) => sum + day.calories, 0) / week.length); $: weekAverage = Math.round(week.reduce((sum, day) => sum + day.calories, 0) / week.length);
$: produceToday = today.fruit + today.vegetables; $: produceToday = today.fruit + today.vegetables;
$: filteredModels = models.filter(model => !modelSearch || `${model.name} ${model.id}`.toLowerCase().includes(modelSearch.toLowerCase()));
$: diaryRows = entries $: diaryRows = entries
.filter(entry => !diaryType || entry.mealType === diaryType) .filter(entry => !diaryType || entry.mealType === diaryType)
.filter(entry => !diaryDate || entry.date === diaryDate) .filter(entry => !diaryDate || entry.date === diaryDate)
@ -76,11 +93,12 @@
.some(value => String(value || '').toLowerCase().includes(query)); .some(value => String(value || '').toLowerCase().includes(query));
}) })
.sort((a, b) => `${b.date} ${b.time}`.localeCompare(`${a.date} ${a.time}`)); .sort((a, b) => `${b.date} ${b.time}`.localeCompare(`${a.date} ${a.time}`));
$: diaryDays = Array.from(new Set(diaryRows.map(entry => entry.date))).sort((a, b) => b.localeCompare(a));
onMount(async () => { onMount(async () => {
entries = readJson(entriesKey, []); entries = readJson(entriesKey, []);
trash = readJson(trashKey, []);
settings = { ...settings, ...readJson(settingsKey, settings) }; settings = { ...settings, ...readJson(settingsKey, settings) };
sidebarCollapsed = localStorage.getItem('calorie_ai_sidebar_collapsed') === '1';
page = localStorage.getItem('calorie_ai_last_tab') || 'dashboard'; page = localStorage.getItem('calorie_ai_last_tab') || 'dashboard';
setDefaultMealDateTime(); setDefaultMealDateTime();
await loadModels(); await loadModels();
@ -151,11 +169,6 @@
localStorage.setItem('calorie_ai_last_tab', id); localStorage.setItem('calorie_ai_last_tab', id);
} }
function toggleSidebarCollapse() {
sidebarCollapsed = !sidebarCollapsed;
localStorage.setItem('calorie_ai_sidebar_collapsed', sidebarCollapsed ? '1' : '0');
}
async function login() { async function login() {
loginStatus = 'Checking credentials...'; loginStatus = 'Checking credentials...';
loginError = false; loginError = false;
@ -190,10 +203,10 @@
if (!models.some(model => model.id === settings.visionModel)) settings.visionModel = defaultModel; if (!models.some(model => model.id === settings.visionModel)) settings.visionModel = defaultModel;
if (!models.some(model => model.id === settings.taskModel)) settings.taskModel = defaultModel; if (!models.some(model => model.id === settings.taskModel)) settings.taskModel = defaultModel;
saveSettings(); saveSettings();
modelStatus = `${models.length} models loaded.`; modelStatus = `${models.length} saved model${models.length === 1 ? '' : 's'} loaded.`;
} catch { } catch {
models = fallbackModels; models = fallbackModels;
modelStatus = 'Could not load server models. Showing defaults.'; modelStatus = 'Could not load saved models. Showing defaults.';
modelError = true; modelError = true;
} }
} }
@ -202,6 +215,10 @@
saveJson(settingsKey, settings); saveJson(settingsKey, settings);
} }
function emptyEstimate() {
return { mealName: '', calories: 0, proteinGrams: 0, carbsGrams: 0, fatGrams: 0, fruitServings: 0, vegetableServings: 0, foodGroups: '', notes: '' };
}
function saveSelectedModels() { function saveSelectedModels() {
saveSettings(); saveSettings();
modelStatus = 'Models saved.'; modelStatus = 'Models saved.';
@ -214,6 +231,116 @@
saveSelectedModels(); saveSelectedModels();
} }
async function searchModels() {
if (modelSearch.trim().length < 2) {
discoveredModels = [];
modelStatus = 'Type at least 2 characters to search provider models.';
modelError = true;
return;
}
modelSearching = true;
modelStatus = 'Searching provider...';
modelError = false;
try {
const response = await fetch('/api/models/search', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ query: modelSearch }),
});
if (!response.ok) throw new Error(await response.text());
const data = await response.json();
discoveredModels = data.models || [];
modelStatus = discoveredModels.length ? `Found ${data.count} model${data.count === 1 ? '' : 's'}.` : 'No models found.';
} catch (error) {
discoveredModels = [];
modelStatus = error.message || 'Model search failed.';
modelError = true;
} finally {
modelSearching = false;
}
}
async function addModel(model) {
const response = await fetch('/api/models/add', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(model),
});
if (!response.ok) {
modelStatus = await response.text();
modelError = true;
return;
}
const data = await response.json();
models = data.models?.length ? data.models : models;
settings.visionModel = models.some(item => item.id === settings.visionModel) ? settings.visionModel : models[0].id;
settings.taskModel = model.id;
saveSettings();
modelStatus = `Added ${model.name || model.id}.`;
modelError = false;
}
async function removeModel(modelId) {
const response = await fetch('/api/models/remove', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id: modelId }),
});
if (!response.ok) {
modelStatus = await response.text();
modelError = true;
return;
}
const data = await response.json();
models = data.models?.length ? data.models : fallbackModels;
if (!models.some(model => model.id === settings.visionModel)) settings.visionModel = data.defaultModel || models[0].id;
if (!models.some(model => model.id === settings.taskModel)) settings.taskModel = data.defaultModel || models[0].id;
saveSettings();
modelStatus = 'Model removed.';
modelError = false;
}
async function changePassword() {
passwordStatus = 'Updating password...';
passwordError = false;
resetPasswordValue = '';
if (newPassword !== confirmPassword) {
passwordStatus = 'New passwords do not match.';
passwordError = true;
return;
}
const response = await fetch('/api/password/change', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ currentPassword, newPassword }),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Password update failed.' }));
passwordStatus = error.error || 'Password update failed.';
passwordError = true;
return;
}
currentPassword = '';
newPassword = '';
confirmPassword = '';
passwordStatus = 'Password updated.';
}
async function resetPassword() {
passwordStatus = 'Generating a new password...';
passwordError = false;
const response = await fetch('/api/password/reset', { method: 'POST' });
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Password reset failed.' }));
passwordStatus = error.error || 'Password reset failed.';
passwordError = true;
return;
}
const data = await response.json();
resetPasswordValue = data.password || '';
passwordStatus = 'Password reset. Save the generated password now.';
}
async function fileToDataUrl(file) { async function fileToDataUrl(file) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const reader = new FileReader(); const reader = new FileReader();
@ -290,6 +417,10 @@ Image estimate: ${imageEstimate}` },
}; };
} }
function entryId() {
return crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
async function saveMeal() { async function saveMeal() {
if (!mealDate || !mealTime || !mealType) return setMealStatus('Choose a date, time, and meal type.', true); 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); if (!description.trim() && !selectedImageDataUrl) return setMealStatus('Describe the meal or upload an image.', true);
@ -298,8 +429,10 @@ Image estimate: ${imageEstimate}` },
setMealStatus(selectedImageDataUrl ? 'Analyzing image...' : 'Estimating meal...'); setMealStatus(selectedImageDataUrl ? 'Analyzing image...' : 'Estimating meal...');
const imageEstimate = await requestImageEstimate(); const imageEstimate = await requestImageEstimate();
const estimate = parseEstimate(await requestMealEstimate(imageEstimate)); const estimate = parseEstimate(await requestMealEstimate(imageEstimate));
entries = [...entries, { estimateDraft = estimate;
const meal = {
...estimate, ...estimate,
id: editingEntryId || entryId(),
date: mealDate, date: mealDate,
time: mealTime, time: mealTime,
mealType, mealType,
@ -307,14 +440,18 @@ Image estimate: ${imageEstimate}` },
measure, measure,
imageIncluded: Boolean(selectedImageDataUrl), imageIncluded: Boolean(selectedImageDataUrl),
imageName: selectedImageName, imageName: selectedImageName,
}]; updatedAt: new Date().toISOString(),
};
entries = editingEntryId ? entries.map(entry => entry.id === editingEntryId ? { ...entry, ...meal } : entry) : [...entries, meal];
saveJson(entriesKey, entries); saveJson(entriesKey, entries);
description = ''; description = '';
measure = ''; measure = '';
selectedImageDataUrl = ''; selectedImageDataUrl = '';
selectedImageName = ''; selectedImageName = '';
editingEntryId = '';
estimateDraft = emptyEstimate();
setDefaultMealDateTime(); setDefaultMealDateTime();
setMealStatus('Saved.'); setMealStatus('Saved. You can edit it from Diary or run analysis again.');
} catch (error) { } catch (error) {
setMealStatus(error.message, true); setMealStatus(error.message, true);
} finally { } finally {
@ -327,14 +464,100 @@ Image estimate: ${imageEstimate}` },
statusError = error; statusError = error;
} }
function clearDiary() { function editEntry(entry) {
if (!clearArmed) { editingEntryId = entry.id;
clearArmed = true; mealDate = entry.date;
return; mealTime = entry.time;
} mealType = entry.mealType;
entries = []; description = entry.description || '';
measure = entry.measure || '';
estimateDraft = { ...emptyEstimate(), ...entry };
setMealStatus('Editing saved meal. Change details, then save edits or analyze again.');
selectPage('log');
}
function saveMealEdits() {
if (!editingEntryId) return;
entries = entries.map(entry => entry.id === editingEntryId ? {
...entry,
...estimateDraft,
date: mealDate,
time: mealTime,
mealType,
description,
measure,
updatedAt: new Date().toISOString(),
} : entry);
saveJson(entriesKey, entries); saveJson(entriesKey, entries);
clearArmed = false; editingEntryId = '';
estimateDraft = emptyEstimate();
description = '';
measure = '';
setDefaultMealDateTime();
setMealStatus('Edits saved.');
}
function cancelEdit() {
editingEntryId = '';
estimateDraft = emptyEstimate();
description = '';
measure = '';
selectedImageDataUrl = '';
selectedImageName = '';
setDefaultMealDateTime();
setMealStatus('');
}
function toggleSelected(id) {
selectedEntryIds = selectedEntryIds.includes(id) ? selectedEntryIds.filter(item => item !== id) : [...selectedEntryIds, id];
}
function moveEntriesToTrash(ids) {
const idSet = new Set(ids);
const moving = entries.filter(entry => idSet.has(entry.id)).map(entry => ({ ...entry, trashedAt: new Date().toISOString() }));
if (!moving.length) return;
trash = [...moving, ...trash];
entries = entries.filter(entry => !idSet.has(entry.id));
selectedEntryIds = selectedEntryIds.filter(id => !idSet.has(id));
saveJson(entriesKey, entries);
saveJson(trashKey, trash);
}
function moveDayToTrash(date) {
moveEntriesToTrash(entries.filter(entry => entry.date === date).map(entry => entry.id));
}
function restoreEntry(id) {
const found = trash.find(entry => entry.id === id);
if (!found) return;
entries = [{ ...found, trashedAt: undefined }, ...entries];
trash = trash.filter(entry => entry.id !== id);
saveJson(entriesKey, entries);
saveJson(trashKey, trash);
}
async function generatePlan() {
planLoading = true;
planStatus = 'Generating plan...';
planError = false;
try {
if (!settings.heightCm || !settings.weightKg) throw new Error('Enter height and current weight first.');
planAdvice = await chatCompletion({
model: settings.taskModel,
temperature: 0.2,
messages: [
{ role: 'system', content: 'Give safe, concise nutrition and weight-loss guidance. Do not diagnose disease. Recommend medical review for pregnancy, eating disorder history, chronic disease, or aggressive goals.' },
{ role: 'user', content: `Create a personalized weight-loss plan using these details: height ${settings.heightCm} cm, current weight ${settings.weightKg} kg, target weight ${settings.targetWeightKg || 'not set'} kg, activity ${settings.activityLevel}, pace ${settings.pace}. Include daily calorie range, protein target, habits, and safety notes.` },
],
});
planStatus = 'Plan generated.';
saveSettings();
} catch (error) {
planStatus = error.message;
planError = true;
} finally {
planLoading = false;
}
} }
async function drawCharts() { async function drawCharts() {
@ -446,33 +669,29 @@ Image estimate: ${imageEstimate}` },
</div> </div>
</div> </div>
<div class="flex items-center gap-2"> <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"> <button class="hidden rounded-lg bg-white/15 px-3 py-2 text-sm font-semibold hover:bg-white/25 md:block" type="button" on:click={() => selectPage('settings')}>Settings</button>
{#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> <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>
</div> </div>
</header> </header>
<div class="flex"> <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"> <aside class:translate-x-0={menuOpen} class="sidebar-shell 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-transform 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"> <div class="flex items-center justify-between border-b border-slate-200 px-4 py-3 lg:hidden">
<strong>Menu</strong> <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> <button class="rounded-lg bg-slate-100 px-3 py-1.5 text-sm" type="button" on:click={() => menuOpen = false}>Close</button>
</div> </div>
<div class="border-b border-slate-200 p-3"> <div class="sidebar-expanded-only 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> <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..."> <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> </div>
<nav class="p-2" aria-label="Main menu"> <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} {#if trackerPages.length}<div class="sidebar-section-label 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} {#each trackerPages as item}<button class:active-tab={page === item.id} class="tab-button" type="button" title={item.label} on:click={() => selectPage(item.id)}><span class="tab-icon">{item.icon}</span><span class="tab-label">{item.label}</span></button>{/each}
{#if adminPages.length}<div class="px-3 py-2 text-xs font-bold uppercase tracking-wide text-slate-400">Admin</div>{/if} {#if adminPages.length}<div class="sidebar-section-label 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} {#each adminPages as item}<button class:active-tab={page === item.id} class="tab-button" type="button" title={item.label} on:click={() => selectPage(item.id)}><span class="tab-icon">{item.icon}</span><span class="tab-label">{item.label}</span></button>{/each}
</nav> </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> </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} {#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"> <main class="min-w-0 flex-1 p-4 lg:p-6">
@ -486,34 +705,92 @@ Image estimate: ${imageEstimate}` },
<Stat label="Produce" value={`${produceToday.toFixed(1)} / 5`} note="Fruit + vegetables" /> <Stat label="Produce" value={`${produceToday.toFixed(1)} / 5`} note="Fruit + vegetables" />
</div> </div>
<div class="grid gap-4 lg:grid-cols-[0.8fr_1.2fr]"> <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">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('settings')}>Settings</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> <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> </div>
</section> </section>
{:else if page === 'log'} {: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> <MealForm
<form class="card" on:submit|preventDefault={saveMeal}> {mealTypes}
<h3 class="card-title">Meal details</h3> bind:mealDate
<div class="grid gap-4 p-4 md:grid-cols-3"> bind:mealTime
<Field label="Date"><input id="mealDate" class="input" type="date" bind:value={mealDate} required></Field> bind:mealType
<Field label="Time of day"><input id="mealTime" class="input" type="time" bind:value={mealTime} required></Field> bind:description
<Field label="Meal type"><select id="mealType" class="input" bind:value={mealType} required>{#each mealTypes as type}<option>{type}</option>{/each}</select></Field> bind:measure
<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> bind:selectedImageDataUrl
<Field label="Portion or measure"><input id="measure" class="input" bind:value={measure} placeholder="Example: one plate, 450g, 2 cups"></Field> {status}
<Field label="Meal image"><input id="image" class="input" type="file" accept="image/*" on:change={chooseImage}></Field> {statusError}
</div> {savingMeal}
{#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} {editingEntryId}
<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> bind:estimateDraft
</form> {chooseImage}
</section> {saveMeal}
{saveMealEdits}
{cancelEdit}
/>
{:else if page === 'analytics'} {: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> <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'} {: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> <DiaryPage
{:else if page === 'models'} {mealTypes}
<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> {diaryRows}
{diaryDays}
bind:diarySearch
bind:diaryType
bind:diaryDate
bind:selectedEntryIds
{trash}
{editEntry}
{toggleSelected}
{moveEntriesToTrash}
{moveDayToTrash}
{restoreEntry}
/>
{:else if page === 'trash'}
<DiaryPage
{mealTypes}
diaryRows={[]}
diaryDays={[]}
bind:diarySearch
bind:diaryType
bind:diaryDate
bind:selectedEntryIds
{trash}
{editEntry}
{toggleSelected}
{moveEntriesToTrash}
{moveDayToTrash}
{restoreEntry}
trashMode={true}
/>
{:else if page === 'settings'} {: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> <SettingsPage
bind:settings
{models}
{discoveredModels}
bind:modelSearch
{modelStatus}
{modelError}
{modelSearching}
{passwordStatus}
{passwordError}
{resetPasswordValue}
bind:currentPassword
bind:newPassword
bind:confirmPassword
{planStatus}
{planError}
{planLoading}
{planAdvice}
{saveSelectedModels}
{loadModels}
{searchModels}
{addModel}
{removeModel}
{changePassword}
{resetPassword}
{generatePlan}
/>
{/if} {/if}
</main> </main>
</div> </div>
@ -521,17 +798,8 @@ Image estimate: ${imageEstimate}` },
{/if} {/if}
<style> <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 { display: flex; width: 100%; align-items: center; gap: 0.65rem; border-left: 3px solid transparent; border-radius: 0.55rem; padding: 0.65rem 1rem; text-align: left; font-size: 0.875rem; font-weight: 600; color: #475569; }
.tab-button:hover { background: #f8fafc; color: #0f172a; } .tab-button:hover { background: #f8fafc; color: #0f172a; }
.active-tab { border-left-color: #2563eb; background: #dbeafe; color: #2563eb; } .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; } .tab-icon { display: grid; width: 1.35rem; height: 1.35rem; flex: 0 0 1.35rem; place-items: center; font-weight: 900; }
.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> </style>

76
web/src/DiaryPage.svelte Normal file
View file

@ -0,0 +1,76 @@
<script>
import Field from './Field.svelte';
export let mealTypes = [];
export let diaryRows = [];
export let diaryDays = [];
export let diarySearch;
export let diaryType;
export let diaryDate;
export let selectedEntryIds = [];
export let trash = [];
export let editEntry;
export let toggleSelected;
export let moveEntriesToTrash;
export let moveDayToTrash;
export let restoreEntry;
export let trashMode = false;
</script>
<section class="space-y-4">
<div>
<h2 class="text-xl font-bold">{trashMode ? 'Trash' : 'Diary'}</h2>
<p class="text-sm text-slate-500">{trashMode ? 'Restore meals moved out of the diary.' : 'Search, edit, select, or move meals to trash.'}</p>
</div>
{#if trashMode}
<div id="trashEntries" class="grid gap-3">
{#if trash.length}
{#each trash 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>
<button class="btn-secondary" type="button" on:click={() => restoreEntry(entry.id)}>Restore</button>
</div>
</article>
{/each}
{:else}<div class="empty-state">Trash is empty.</div>{/if}
</div>
{:else}
<section class="card">
<div class="grid gap-3 p-4 md:grid-cols-[1fr_180px_170px_auto]">
<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="btn-secondary w-full" type="button" disabled={!selectedEntryIds.length} on:click={() => moveEntriesToTrash(selectedEntryIds)}>Move selected to trash</button></div>
</div>
</section>
<div id="entries" class="grid gap-3">
{#if diaryRows.length}
{#each diaryDays as day}
<section class="card">
<div class="flex items-center justify-between border-b border-slate-200 bg-slate-50 px-4 py-2">
<h3 class="font-bold">{day}</h3>
<button class="rounded-md bg-red-50 px-3 py-1.5 text-xs font-bold text-red-600 hover:bg-red-100" type="button" on:click={() => moveDayToTrash(day)}>Move day to trash</button>
</div>
<div class="grid gap-2 p-3">
{#each diaryRows.filter(entry => entry.date === day) as entry}
<article class="entry rounded-xl border border-slate-200 p-3">
<div class="flex flex-col justify-between gap-2 md:flex-row">
<label class="flex min-w-0 gap-3"><input type="checkbox" checked={selectedEntryIds.includes(entry.id)} on:change={() => toggleSelected(entry.id)}><span class="min-w-0"><strong>{entry.mealName}</strong><div class="text-sm text-slate-500">{entry.date} {entry.time} · {entry.mealType}</div></span></label>
<div class="flex items-center gap-2"><div class="font-bold">{entry.calories} kcal</div><button class="btn-secondary" type="button" on:click={() => editEntry(entry)}>Edit</button></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}
</div>
</section>
{/each}
{:else}<div class="empty-state">No meals match the current filters.</div>{/if}
</div>
{/if}
</section>

57
web/src/MealForm.svelte Normal file
View file

@ -0,0 +1,57 @@
<script>
import Field from './Field.svelte';
export let mealTypes = [];
export let mealDate;
export let mealTime;
export let mealType;
export let description;
export let measure;
export let selectedImageDataUrl;
export let status;
export let statusError;
export let savingMeal;
export let editingEntryId;
export let estimateDraft;
export let chooseImage;
export let saveMeal;
export let saveMealEdits;
export let cancelEdit;
</script>
<section class="space-y-4">
<div>
<h2 class="text-xl font-bold">{editingEntryId ? 'Edit meal' : 'Log meal'}</h2>
<p class="text-sm text-slate-500">Backfill meals for any date and time. After analysis, edit the values or run analysis again.</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 or camera"><input id="image" class="input" type="file" accept="image/*" capture="environment" 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}
{#if editingEntryId}
<div class="grid gap-3 border-t border-slate-200 p-4 md:grid-cols-4">
<Field className="md:col-span-2" label="Meal name"><input class="input" bind:value={estimateDraft.mealName}></Field>
<Field label="Calories"><input class="input" type="number" min="0" bind:value={estimateDraft.calories}></Field>
<Field label="Protein (g)"><input class="input" type="number" min="0" bind:value={estimateDraft.proteinGrams}></Field>
<Field label="Carbs (g)"><input class="input" type="number" min="0" bind:value={estimateDraft.carbsGrams}></Field>
<Field label="Fat (g)"><input class="input" type="number" min="0" bind:value={estimateDraft.fatGrams}></Field>
<Field label="Fruit servings"><input class="input" type="number" min="0" step="0.1" bind:value={estimateDraft.fruitServings}></Field>
<Field label="Vegetable servings"><input class="input" type="number" min="0" step="0.1" bind:value={estimateDraft.vegetableServings}></Field>
<Field className="md:col-span-2" label="Food groups"><input class="input" bind:value={estimateDraft.foodGroups}></Field>
<Field className="md:col-span-2" label="Notes"><input class="input" bind:value={estimateDraft.notes}></Field>
</div>
{/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...' : editingEntryId ? 'Analyze again' : 'Analyze and save'}</button>
{#if editingEntryId}<button class="btn-secondary" type="button" on:click={saveMealEdits}>Save edits</button><button class="btn-secondary" type="button" on:click={cancelEdit}>Cancel</button>{/if}
{#if status}<p id="status" class:text-red-600={statusError} class="text-sm font-semibold text-green-700">{status}</p>{/if}
</div>
</form>
</section>

View file

@ -0,0 +1,85 @@
<script>
import Field from './Field.svelte';
export let settings;
export let models = [];
export let discoveredModels = [];
export let modelSearch;
export let modelStatus;
export let modelError;
export let modelSearching;
export let passwordStatus;
export let passwordError;
export let resetPasswordValue;
export let currentPassword;
export let newPassword;
export let confirmPassword;
export let planStatus;
export let planError;
export let planLoading;
export let planAdvice;
export let saveSelectedModels;
export let loadModels;
export let searchModels;
export let addModel;
export let removeModel;
export let changePassword;
export let resetPassword;
export let generatePlan;
</script>
<section class="space-y-4">
<div><h2 class="text-xl font-bold">Settings</h2><p class="text-sm text-slate-500">Account, profile, and model controls.</p></div>
<div class="grid gap-4 xl:grid-cols-[0.85fr_1.15fr]">
<section class="card">
<h3 class="card-title">Password</h3>
<form class="grid gap-3 p-4" on:submit|preventDefault={changePassword}>
<Field label="Current password"><input id="currentPassword" class="input" type="password" bind:value={currentPassword} autocomplete="current-password"></Field>
<Field label="New password"><input id="newPassword" class="input" type="password" bind:value={newPassword} autocomplete="new-password" minlength="12"></Field>
<Field label="Confirm new password"><input id="confirmPassword" class="input" type="password" bind:value={confirmPassword} autocomplete="new-password" minlength="12"></Field>
<div class="flex flex-col gap-2 sm:flex-row"><button class="btn-primary" type="submit">Change password</button><button class="btn-secondary" type="button" on:click={resetPassword}>Reset password</button></div>
{#if passwordStatus}<p id="passwordStatus" class:text-red-600={passwordError} class="text-sm font-semibold text-green-700">{passwordStatus}</p>{/if}
{#if resetPasswordValue}<div class="rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm font-mono text-amber-900" id="resetPasswordValue">{resetPasswordValue}</div>{/if}
</form>
</section>
<section class="card">
<h3 class="card-title">Weight-loss plan</h3>
<div class="grid gap-3 p-4 md:grid-cols-2">
<Field label="Height (cm)"><input class="input" type="number" min="80" bind:value={settings.heightCm}></Field>
<Field label="Current weight (kg)"><input class="input" type="number" min="20" step="0.1" bind:value={settings.weightKg}></Field>
<Field label="Target weight (kg)"><input class="input" type="number" min="20" step="0.1" bind:value={settings.targetWeightKg}></Field>
<Field label="Activity"><select class="input" bind:value={settings.activityLevel}><option>Low</option><option>Moderate</option><option>High</option></select></Field>
<Field label="Pace"><select class="input" bind:value={settings.pace}><option>Gentle</option><option>Steady</option><option>Aggressive</option></select></Field>
<div class="flex items-end"><button class="btn-primary w-full" type="button" on:click={generatePlan} disabled={planLoading}>{planLoading ? 'Generating...' : 'Generate plan'}</button></div>
{#if planStatus}<p class:text-red-600={planError} class="text-sm font-semibold text-green-700 md:col-span-2">{planStatus}</p>{/if}
{#if planAdvice}<div class="whitespace-pre-wrap rounded-xl border border-blue-100 bg-blue-50 p-4 text-sm text-slate-800 md:col-span-2">{planAdvice}</div>{/if}
</div>
</section>
<section class="card xl:col-span-2">
<h3 class="card-title">Models</h3>
<div class="grid gap-4 p-4 lg:grid-cols-[0.8fr_1.2fr]">
<div class="grid gap-3 content-start">
<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 and advice model"><select id="taskModel" class="input" bind:value={settings.taskModel}>{#each models as model}<option value={model.id}>{model.name}</option>{/each}</select></Field>
<div class="flex flex-col gap-2 sm:flex-row"><button class="btn-primary" type="button" on:click={saveSelectedModels}>Save selected models</button><button class="btn-secondary" type="button" on:click={loadModels}>Refresh saved list</button></div>
{#if modelStatus}<p id="modelStatus" class:text-red-600={modelError} class="text-sm font-semibold text-green-700">{modelStatus}</p>{/if}
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3">
<div class="mb-2 text-xs font-bold uppercase tracking-wide text-slate-500">Saved models</div>
<div class="max-h-40 overflow-y-auto pr-1">
{#each models as model}<div class="mb-2 flex items-center gap-2 rounded-lg bg-white px-3 py-2 text-sm shadow-sm"><span class="min-w-0 flex-1 truncate"><strong>{model.name}</strong> <span class="text-slate-400">{model.id}</span></span><button class="rounded-md bg-red-50 px-2 py-1 text-xs font-bold text-red-600 hover:bg-red-100" type="button" on:click={() => removeModel(model.id)}>Remove</button></div>{/each}
</div>
</div>
</div>
<div>
<label class="mb-2 block text-sm font-bold text-slate-700" for="modelSearch">Search provider models</label>
<div class="flex flex-col gap-2 sm:flex-row"><input id="modelSearch" class="input" bind:value={modelSearch} placeholder="Search current models, e.g. claude, gemini, gpt" on:keydown={(event) => event.key === 'Enter' && searchModels()}><button class="btn-primary sm:w-36" type="button" on:click={searchModels} disabled={modelSearching}>{modelSearching ? 'Searching...' : 'Search'}</button></div>
<div class="mt-3 max-h-72 overflow-y-auto rounded-xl border border-slate-200 bg-white p-2">
{#if discoveredModels.length}{#each discoveredModels as model}<div class="mb-2 flex items-center gap-2 rounded-lg border border-slate-100 px-3 py-2 text-sm"><button class="rounded-md bg-blue-600 px-2.5 py-1 text-xs font-black text-white hover:bg-blue-700" type="button" on:click={() => addModel(model)}>+</button><span class="min-w-0 flex-1 truncate"><strong>{model.name}</strong> <span class="text-slate-400">{model.id}</span></span></div>{/each}{:else}<p class="px-2 py-6 text-center text-sm text-slate-500">Search to query the current provider. Results stay in this scroll panel.</p>{/if}
</div>
</div>
</div>
</section>
</div>
</section>

View file

@ -8,3 +8,14 @@ html { background: #f8fafc; }
body { margin: 0; min-height: 100vh; font-family: var(--font-sans); color: #0f172a; } body { margin: 0; min-height: 100vh; font-family: var(--font-sans); color: #0f172a; }
button, input, select, textarea { font: inherit; } button, input, select, textarea { font: inherit; }
canvas { max-width: 100%; } canvas { max-width: 100%; }
.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; }

View file

@ -14,12 +14,40 @@ async function mockModels(page) {
defaultModel: 'claude-haiku-4.5', defaultModel: 'claude-haiku-4.5',
models: [ models: [
{ id: 'claude-haiku-4.5', name: 'Claude Haiku 4.5' }, { id: 'claude-haiku-4.5', name: 'Claude Haiku 4.5' },
],
}),
});
});
await page.route('**/api/models/search', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
count: 2,
models: [
{ id: 'claude-sonnet-4.6', name: 'Claude Sonnet 4.6' }, { id: 'claude-sonnet-4.6', name: 'Claude Sonnet 4.6' },
{ id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash' }, { id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash' },
], ],
}), }),
}); });
}); });
await page.route('**/api/models/add', async (route) => {
const body = route.request().postDataJSON();
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
ok: true,
defaultModel: 'claude-haiku-4.5',
models: [
{ id: 'claude-haiku-4.5', name: 'Claude Haiku 4.5' },
{ id: body.id, name: body.name || body.id },
],
}),
});
});
} }
async function login(page) { async function login(page) {
@ -60,7 +88,7 @@ test.describe('authenticated app', () => {
await expect(page.locator('#status')).toContainText('Describe the meal or upload an image.'); await expect(page.locator('#status')).toContainText('Describe the meal or upload an image.');
}); });
test('selects models from dropdowns, backfills a meal, updates charts, and clears diary', async ({ page }) => { test('adds curated models in settings, backfills a meal, edits, trashes, and restores', async ({ page }) => {
let calls = 0; let calls = 0;
await page.route('**/api/chat', async (route) => { await page.route('**/api/chat', async (route) => {
calls += 1; calls += 1;
@ -75,9 +103,13 @@ test.describe('authenticated app', () => {
}); });
}); });
await page.getByRole('button', { name: 'Models', exact: true }).click(); await page.getByRole('banner').getByRole('button', { name: 'Settings' }).click();
await page.locator('#modelSearch').fill('gemini');
await page.getByRole('button', { name: 'Search' }).click();
await expect(page.getByText('Gemini 2.5 Flash')).toBeVisible();
await page.getByRole('button', { name: '+' }).last().click();
await page.locator('#visionModel').selectOption('gemini-2.5-flash'); await page.locator('#visionModel').selectOption('gemini-2.5-flash');
await page.locator('#taskModel').selectOption('claude-sonnet-4.6'); await page.locator('#taskModel').selectOption('gemini-2.5-flash');
await page.getByRole('button', { name: 'Save selected models' }).click(); await page.getByRole('button', { name: 'Save selected models' }).click();
await expect(page.locator('#modelStatus')).toContainText('Models saved.'); await expect(page.locator('#modelStatus')).toContainText('Models saved.');
@ -93,17 +125,25 @@ test.describe('authenticated app', () => {
await page.getByRole('button', { name: 'Analyze and save' }).click(); await page.getByRole('button', { name: 'Analyze and save' }).click();
await expect(page.locator('#status')).toContainText('Saved.'); await expect(page.locator('#status')).toContainText('Saved.');
await page.getByRole('button', { name: 'Diary', exact: true }).click(); await page.locator('nav').getByRole('button').filter({ hasText: 'Diary' }).click();
await expect(page.locator('.entry')).toContainText('Salmon rice bowl'); await expect(page.locator('.entry')).toContainText('Salmon rice bowl');
await expect(page.locator('.entry')).toContainText('2026-05-18 08:15 · Breakfast'); await expect(page.locator('.entry')).toContainText('2026-05-18 08:15 · Breakfast');
await page.getByRole('button', { name: 'Edit' }).click();
await expect(page.getByRole('heading', { name: 'Edit meal' })).toBeVisible();
await page.getByLabel('Calories').fill('610');
await page.getByRole('button', { name: 'Save edits' }).click();
await page.getByRole('button', { name: 'Analytics' }).click(); await page.locator('nav').getByRole('button').filter({ hasText: 'Analytics' }).click();
await expect(page.locator('#calorieChart')).toBeVisible(); await expect(page.locator('#calorieChart')).toBeVisible();
await expect(page.locator('#produceChart')).toBeVisible(); await expect(page.locator('#produceChart')).toBeVisible();
await page.getByRole('button', { name: 'Diary', exact: true }).click(); await page.locator('nav').getByRole('button').filter({ hasText: 'Diary' }).click();
await page.getByRole('button', { name: 'Clear diary' }).click(); await page.getByRole('checkbox').check();
await page.getByRole('button', { name: 'Click again' }).click(); await page.getByRole('button', { name: 'Move selected to trash' }).click();
await expect(page.locator('#entries')).toContainText('No meals match the current filters.'); await expect(page.locator('#entries')).toContainText('No meals match the current filters.');
await page.locator('nav').getByRole('button').filter({ hasText: 'Trash' }).click();
await expect(page.locator('#trashEntries')).toContainText('Salmon rice bowl');
await page.getByRole('button', { name: 'Restore' }).click();
await expect(page.locator('#trashEntries')).toContainText('Trash is empty.');
}); });
}); });