Update web model management and meal diary
This commit is contained in:
parent
87102dbc5b
commit
2e2ba12cf3
9 changed files with 1852 additions and 123 deletions
1059
web/package-lock.json
generated
1059
web/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -16,5 +16,8 @@
|
|||
"tailwindcss": "^4.3.0",
|
||||
"vite": "^8.0.13"
|
||||
},
|
||||
"dependencies": {}
|
||||
"dependencies": {
|
||||
"argon2": "^0.44.0",
|
||||
"express": "^5.2.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
218
web/server.js
218
web/server.js
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
76
web/src/DiaryPage.svelte
Normal 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
57
web/src/MealForm.svelte
Normal 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>
|
||||
85
web/src/SettingsPage.svelte
Normal file
85
web/src/SettingsPage.svelte
Normal 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>
|
||||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue