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",
"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 fs = require('fs');
const path = require('path');
const argon2 = require('argon2');
const port = Number(process.env.PORT || 8080);
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 modelsFile = path.join(dataDir, 'models.json');
const sessionCookieName = 'calorie_ai_session';
const sessionSeconds = 60 * 60 * 24 * 7;
const auth = loadAuthConfig();
let auth;
let modelConfig;
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 || '',
@ -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 = {};
try {
stored = JSON.parse(fs.readFileSync(authFile, 'utf8'));
} catch {
stored = {};
}
stored = readJsonFile(authFile, {});
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');
if (!process.env.CALORIE_AI_WEB_PASSWORD || !process.env.CALORIE_AI_SESSION_SECRET) {
fs.mkdirSync(dataDir, { recursive: true });
fs.writeFileSync(authFile, JSON.stringify({ user, password, sessionSecret, createdAt: new Date().toISOString() }, null, 2));
fs.chmodSync(authFile, 0o600);
if (!stored.passwordHash || !stored.sessionSecret || stored.password) {
writePrivateJson(authFile, {
user,
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}`);
}
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 = {}) {
@ -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) {
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 = {};
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 }));
async function searchModels(req, res) {
try {
const payload = req.method === 'POST' ? JSON.parse(await readBody(req) || '{}') : {};
if (!payload.query || String(payload.query).trim().length < 2) {
return send(res, 200, JSON.stringify({ models: [], count: 0 }));
}
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())
.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 }));
async function addModel(req, res) {
try {
const payload = JSON.parse(await readBody(req));
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) {
send(res, 400, JSON.stringify({ error: error.message }));
}
@ -210,7 +304,9 @@ async function proxyModels(req, res) {
async function login(req, res) {
try {
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' }));
}
@ -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) {
send(res, 200, JSON.stringify({ ok: true }), undefined, false, {
'set-cookie': `${sessionCookieName}=; ${cookieOptions(req, 0)}`,
});
}
http.createServer((req, res) => {
async function handleRequest(req, res) {
const url = req.url.split('?')[0];
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 (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/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 === '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 === 'GET' || req.method === 'HEAD') return serveStatic(req, res);
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 Field from './Field.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 trashKey = 'calorie-ai-web-trash';
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' },
{ id: 'dashboard', label: 'Dashboard', icon: '⌂', group: 'Tracker' },
{ id: 'log', label: 'Log meal', icon: '+', group: 'Tracker' },
{ id: 'analytics', label: 'Analytics', icon: '↗', group: 'Tracker' },
{ id: 'diary', label: 'Diary', icon: '☰', group: 'Tracker' },
{ id: 'trash', label: 'Trash', icon: '×', group: 'Tracker' },
{ id: 'settings', label: 'Settings', icon: '⚙', group: 'Admin' },
];
const mealTypes = ['Breakfast', 'Lunch', 'Dinner', 'Snack', 'Other'];
const fallbackModels = [
@ -30,14 +34,21 @@
let page = 'dashboard';
let menuSearch = '';
let menuOpen = false;
let sidebarCollapsed = false;
let entries = [];
let models = fallbackModels;
let discoveredModels = [];
let modelSearch = '';
let modelStatus = '';
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 mealTime = '';
@ -49,11 +60,18 @@
let status = '';
let statusError = false;
let savingMeal = false;
let editingEntryId = '';
let estimateDraft = emptyEstimate();
let planStatus = '';
let planError = false;
let planLoading = false;
let planAdvice = '';
let diarySearch = '';
let diaryType = '';
let diaryDate = '';
let clearArmed = false;
let selectedEntryIds = [];
let trash = [];
let macroCanvas;
let calorieCanvas;
@ -66,7 +84,6 @@
$: 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)
@ -76,11 +93,12 @@
.some(value => String(value || '').toLowerCase().includes(query));
})
.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 () => {
entries = readJson(entriesKey, []);
trash = readJson(trashKey, []);
settings = { ...settings, ...readJson(settingsKey, settings) };
sidebarCollapsed = localStorage.getItem('calorie_ai_sidebar_collapsed') === '1';
page = localStorage.getItem('calorie_ai_last_tab') || 'dashboard';
setDefaultMealDateTime();
await loadModels();
@ -151,11 +169,6 @@
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;
@ -190,10 +203,10 @@
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.`;
modelStatus = `${models.length} saved model${models.length === 1 ? '' : 's'} loaded.`;
} catch {
models = fallbackModels;
modelStatus = 'Could not load server models. Showing defaults.';
modelStatus = 'Could not load saved models. Showing defaults.';
modelError = true;
}
}
@ -202,6 +215,10 @@
saveJson(settingsKey, settings);
}
function emptyEstimate() {
return { mealName: '', calories: 0, proteinGrams: 0, carbsGrams: 0, fatGrams: 0, fruitServings: 0, vegetableServings: 0, foodGroups: '', notes: '' };
}
function saveSelectedModels() {
saveSettings();
modelStatus = 'Models saved.';
@ -214,6 +231,116 @@
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) {
return new Promise((resolve, reject) => {
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() {
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);
@ -298,8 +429,10 @@ Image estimate: ${imageEstimate}` },
setMealStatus(selectedImageDataUrl ? 'Analyzing image...' : 'Estimating meal...');
const imageEstimate = await requestImageEstimate();
const estimate = parseEstimate(await requestMealEstimate(imageEstimate));
entries = [...entries, {
estimateDraft = estimate;
const meal = {
...estimate,
id: editingEntryId || entryId(),
date: mealDate,
time: mealTime,
mealType,
@ -307,14 +440,18 @@ Image estimate: ${imageEstimate}` },
measure,
imageIncluded: Boolean(selectedImageDataUrl),
imageName: selectedImageName,
}];
updatedAt: new Date().toISOString(),
};
entries = editingEntryId ? entries.map(entry => entry.id === editingEntryId ? { ...entry, ...meal } : entry) : [...entries, meal];
saveJson(entriesKey, entries);
description = '';
measure = '';
selectedImageDataUrl = '';
selectedImageName = '';
editingEntryId = '';
estimateDraft = emptyEstimate();
setDefaultMealDateTime();
setMealStatus('Saved.');
setMealStatus('Saved. You can edit it from Diary or run analysis again.');
} catch (error) {
setMealStatus(error.message, true);
} finally {
@ -327,14 +464,100 @@ Image estimate: ${imageEstimate}` },
statusError = error;
}
function clearDiary() {
if (!clearArmed) {
clearArmed = true;
return;
}
entries = [];
function editEntry(entry) {
editingEntryId = entry.id;
mealDate = entry.date;
mealTime = entry.time;
mealType = entry.mealType;
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);
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() {
@ -446,33 +669,29 @@ Image estimate: ${imageEstimate}` },
</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="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>
<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">
<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">
<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">
<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>
<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}
{#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" 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="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" 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>
<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">
@ -486,34 +705,92 @@ Image estimate: ${imageEstimate}` },
<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">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>
</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>
<MealForm
{mealTypes}
bind:mealDate
bind:mealTime
bind:mealType
bind:description
bind:measure
bind:selectedImageDataUrl
{status}
{statusError}
{savingMeal}
{editingEntryId}
bind:estimateDraft
{chooseImage}
{saveMeal}
{saveMealEdits}
{cancelEdit}
/>
{: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>
<DiaryPage
{mealTypes}
{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'}
<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}
</main>
</div>
@ -521,17 +798,8 @@ Image estimate: ${imageEstimate}` },
{/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 { 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; }
.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; }
.tab-icon { display: grid; width: 1.35rem; height: 1.35rem; flex: 0 0 1.35rem; place-items: center; font-weight: 900; }
</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; }
button, input, select, textarea { font: inherit; }
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',
models: [
{ 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: '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) {
@ -60,7 +88,7 @@ test.describe('authenticated app', () => {
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;
await page.route('**/api/chat', async (route) => {
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('#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 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 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('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('#produceChart')).toBeVisible();
await page.getByRole('button', { name: 'Diary', exact: true }).click();
await page.getByRole('button', { name: 'Clear diary' }).click();
await page.getByRole('button', { name: 'Click again' }).click();
await page.locator('nav').getByRole('button').filter({ hasText: 'Diary' }).click();
await page.getByRole('checkbox').check();
await page.getByRole('button', { name: 'Move selected to trash' }).click();
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.');
});
});