Update web plans and app capture
All checks were successful
Android CI / build (push) Successful in 1m12s
Web CI / test (push) Successful in 49s

This commit is contained in:
Daniel 2026-05-20 00:48:52 +02:00
parent 2e2ba12cf3
commit 5fa4b59335
12 changed files with 664 additions and 72 deletions

View file

@ -47,6 +47,9 @@ private fun AppRoot(repo: AppRepository, ai: AiClient) {
ImagePayload.fromBytes(uri.lastPathSegment ?: "meal image", context.contentResolver.getType(uri) ?: "image/jpeg", bytes)
}
}
val camera = rememberLauncherForActivityResult(ActivityResultContracts.TakePicturePreview()) { bitmap ->
if (bitmap != null) selectedImage = ImagePayload.fromBitmap("camera meal photo", bitmap)
}
fun persistEntries(next: List<MealEntry>) { entries = next; repo.saveEntries(next) }
fun persistTrash(next: List<MealEntry>) { trash = next; repo.saveTrash(next) }
@ -61,6 +64,7 @@ private fun AppRoot(repo: AppRepository, ai: AiClient) {
editing = editing,
selectedImageName = selectedImage?.name.orEmpty(),
onPickImage = { imagePicker.launch("image/*") },
onTakePhoto = { camera.launch(null) },
onCancelEdit = { editing = null; status = "" },
onSaveManualEdit = { updated ->
persistEntries(entries.map { if (it.id == updated.id) updated else it })

View file

@ -1,12 +1,14 @@
package com.danvics.calorieai.ai
import android.util.Base64
import android.graphics.Bitmap
import com.danvics.calorieai.data.AppSettings
import com.danvics.calorieai.data.ModelOption
import com.danvics.calorieai.data.NutritionEstimate
import org.json.JSONArray
import org.json.JSONObject
import java.io.InputStream
import java.io.ByteArrayOutputStream
import java.io.OutputStream
import java.net.HttpURLConnection
import java.net.URL
@ -90,6 +92,12 @@ data class ImagePayload(val name: String, val dataUri: String) {
name = name,
dataUri = "data:$mimeType;base64," + Base64.encodeToString(bytes, Base64.NO_WRAP)
)
fun fromBitmap(name: String, bitmap: Bitmap): ImagePayload {
val output = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.JPEG, 90, output)
return fromBytes(name, "image/jpeg", output.toByteArray())
}
}
}

View file

@ -21,6 +21,7 @@ fun CalorieAiApp(
editing: MealEntry?,
selectedImageName: String,
onPickImage: () -> Unit,
onTakePhoto: () -> Unit,
onCancelEdit: () -> Unit,
onSaveManualEdit: (MealEntry) -> Unit,
onAnalyze: (MealEntry) -> Unit,
@ -41,7 +42,7 @@ fun CalorieAiApp(
Box(Modifier.padding(padding).fillMaxSize()) {
when (screen) {
Screen.Dashboard -> DashboardScreen(entries, onLog = { screen = Screen.Log }, onSettings = { screen = Screen.Settings })
Screen.Log -> LogMealScreen(editing, selectedImageName, status, busy, onPickImage, onAnalyze, onSaveManualEdit, onCancelEdit)
Screen.Log -> LogMealScreen(editing, selectedImageName, status, busy, onPickImage, onTakePhoto, onAnalyze, onSaveManualEdit, onCancelEdit)
Screen.Diary -> DiaryScreen(entries, onEdit = { onEdit(it); screen = Screen.Log }, onMoveToTrash = onMoveToTrash)
Screen.Trash -> TrashScreen(trash, onRestore)
Screen.Settings -> SettingsScreen(settings, onSettingsChange, onSearchModels, onGeneratePlan)

View file

@ -18,6 +18,7 @@ fun LogMealScreen(
status: String,
busy: Boolean,
onPickImage: () -> Unit,
onTakePhoto: () -> Unit,
onAnalyze: (MealEntry) -> Unit,
onSaveManualEdit: (MealEntry) -> Unit,
onCancelEdit: () -> Unit
@ -36,7 +37,11 @@ fun LogMealScreen(
Field("Meal type", mealType, { mealType = it })
Field("Description", description, { description = it }, singleLine = false)
Field("Portion or measure", measure, { measure = it })
OutlinedButton(onClick = onPickImage, modifier = Modifier.fillMaxWidth()) { Text(if (selectedImageName.isBlank()) "Add image or camera photo" else selectedImageName) }
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
OutlinedButton(onClick = onTakePhoto, modifier = Modifier.weight(1f)) { Text("Take photo") }
OutlinedButton(onClick = onPickImage, modifier = Modifier.weight(1f)) { Text("Choose image") }
}
if (selectedImageName.isNotBlank()) Text("Selected: $selectedImageName")
Button(
enabled = !busy && (description.isNotBlank() || selectedImageName.isNotBlank()),
onClick = { onAnalyze(draft(editing, date, time, mealType, description, measure, estimate)) },

12
web/package-lock.json generated
View file

@ -9,7 +9,8 @@
"version": "0.1.0",
"dependencies": {
"argon2": "^0.44.0",
"express": "^5.2.1"
"express": "^5.2.1",
"nodemailer": "^7.0.11"
},
"devDependencies": {
"@playwright/test": "^1.56.1",
@ -1800,6 +1801,15 @@
"node-gyp-build-test": "build-test.js"
}
},
"node_modules/nodemailer": {
"version": "7.0.13",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz",
"integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",

View file

@ -18,6 +18,7 @@
},
"dependencies": {
"argon2": "^0.44.0",
"express": "^5.2.1"
"express": "^5.2.1",
"nodemailer": "^7.0.11"
}
}

View file

@ -1,8 +1,9 @@
const http = require('http');
const express = require('express');
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const argon2 = require('argon2');
const nodemailer = require('nodemailer');
const port = Number(process.env.PORT || 8080);
const publicDir = fs.existsSync(path.join(__dirname, 'dist')) ? path.join(__dirname, 'dist') : path.join(__dirname, 'public');
@ -30,6 +31,8 @@ const types = {
};
function readBody(req) {
if (typeof req.body === 'string') return Promise.resolve(req.body);
if (req.body && typeof req.body === 'object') return Promise.resolve(JSON.stringify(req.body));
return new Promise((resolve, reject) => {
let body = '';
req.on('data', chunk => {
@ -63,9 +66,12 @@ async function loadAuthConfig() {
stored = readJsonFile(authFile, {});
const user = process.env.CALORIE_AI_WEB_USER || stored.user || 'admin';
const email = process.env.CALORIE_AI_ADMIN_EMAIL || stored.email || process.env.MAIL_FROM || process.env.SMTP_FROM || '';
if (process.env.CALORIE_AI_WEB_PASSWORD && process.env.CALORIE_AI_SESSION_SECRET) {
return {
user,
email,
emailVerified: stored.emailVerified === true,
passwordHash: await hashPassword(process.env.CALORIE_AI_WEB_PASSWORD),
sessionSecret: process.env.CALORIE_AI_SESSION_SECRET,
};
@ -79,6 +85,8 @@ async function loadAuthConfig() {
if (!stored.passwordHash || !stored.sessionSecret || stored.password) {
writePrivateJson(authFile, {
user,
email,
emailVerified: stored.emailVerified === true,
passwordHash,
sessionSecret,
createdAt: stored.createdAt || new Date().toISOString(),
@ -88,7 +96,56 @@ async function loadAuthConfig() {
console.log(`Calorie AI auth is stored at ${authFile}`);
}
return { user, passwordHash, sessionSecret };
return { ...stored, user, email, emailVerified: stored.emailVerified === true, passwordHash, sessionSecret };
}
function saveAuthConfig(extra = {}) {
auth = { ...auth, ...extra, updatedAt: new Date().toISOString() };
writePrivateJson(authFile, {
user: auth.user,
email: auth.email || '',
emailVerified: auth.emailVerified === true,
passwordHash: auth.passwordHash,
sessionSecret: auth.sessionSecret,
resetTokenHash: auth.resetTokenHash,
resetExpiresAt: auth.resetExpiresAt,
verificationTokenHash: auth.verificationTokenHash,
verificationExpiresAt: auth.verificationExpiresAt,
createdAt: auth.createdAt || new Date().toISOString(),
updatedAt: auth.updatedAt,
});
}
function mailConfig() {
const host = process.env.CALORIE_AI_SMTP_HOST || process.env.MAIL_SERVER || process.env.SMTP_HOST || '';
const port = Number(process.env.CALORIE_AI_SMTP_PORT || process.env.MAIL_PORT || process.env.SMTP_PORT || 587);
const user = process.env.CALORIE_AI_SMTP_USER || process.env.MAIL_USERNAME || process.env.SMTP_USER || '';
const pass = process.env.CALORIE_AI_SMTP_PASSWORD || process.env.MAIL_PASSWORD || process.env.SMTP_PASSWORD || '';
const from = process.env.CALORIE_AI_MAIL_FROM || process.env.MAIL_FROM || process.env.SMTP_FROM || '';
const secure = String(process.env.CALORIE_AI_SMTP_SECURE || process.env.MAIL_SSL_TLS || 'false') === 'true';
return { host, port, user, pass, from, secure, configured: Boolean(host && port && from) };
}
function appOrigin(req) {
const proto = req.headers['x-forwarded-proto'] || (req.socket.encrypted ? 'https' : 'http');
const host = req.headers['x-forwarded-host'] || req.headers.host;
return `${proto}://${host}`;
}
function tokenHash(token) {
return crypto.createHash('sha256').update(token).digest('hex');
}
async function sendMail(to, subject, text) {
const config = mailConfig();
if (!config.configured) throw new Error('SMTP is not configured');
const transporter = nodemailer.createTransport({
host: config.host,
port: config.port,
secure: config.secure,
auth: config.user || config.pass ? { user: config.user, pass: config.pass } : undefined,
});
await transporter.sendMail({ from: config.from, to, subject, text });
}
function loadModelConfig() {
@ -181,7 +238,8 @@ function cookieOptions(req, maxAge = sessionSeconds) {
function publicRequest(req) {
const url = req.url.split('?')[0];
return (req.method === 'POST' && url === '/api/login')
|| ((req.method === 'GET' || req.method === 'HEAD') && (url === '/login' || url === '/favicon.svg' || url.startsWith('/assets/')));
|| (req.method === 'POST' && (url === '/api/password/request-reset' || url === '/api/password/confirm-reset' || url === '/api/account/verify'))
|| ((req.method === 'GET' || req.method === 'HEAD') && (url === '/login' || url === '/reset' || url === '/verify' || url === '/favicon.svg' || url.startsWith('/assets/')));
}
function sendFile(req, res, target) {
@ -324,7 +382,7 @@ async function changePassword(req, res) {
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() });
saveAuthConfig({ passwordHash: auth.passwordHash, resetTokenHash: undefined, resetExpiresAt: undefined });
send(res, 200, JSON.stringify({ ok: true }));
} catch (error) {
send(res, 400, JSON.stringify({ error: error.message }));
@ -333,10 +391,81 @@ async function changePassword(req, res) {
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 }));
if (!auth.email) return send(res, 400, JSON.stringify({ error: 'Set an account email first' }));
const token = crypto.randomBytes(32).toString('base64url');
saveAuthConfig({ resetTokenHash: tokenHash(token), resetExpiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString() });
const link = `${appOrigin(req)}/reset?token=${encodeURIComponent(token)}`;
await sendMail(auth.email, 'Calorie AI password reset', `Use this link to reset your Calorie AI password. It expires in 1 hour.\n\n${link}`);
send(res, 200, JSON.stringify({ ok: true, message: 'Reset email sent.' }));
} catch (error) {
send(res, 400, JSON.stringify({ error: error.message }));
}
}
async function requestPasswordReset(req, res) {
try {
const payload = JSON.parse(await readBody(req) || '{}');
const identity = String(payload.identity || '').trim().toLowerCase();
if (!identity || (identity !== auth.user.toLowerCase() && identity !== String(auth.email || '').toLowerCase())) {
return send(res, 200, JSON.stringify({ ok: true, message: 'If the account exists, a reset email was sent.' }));
}
if (!auth.email) return send(res, 200, JSON.stringify({ ok: true, message: 'If the account exists, a reset email was sent.' }));
const token = crypto.randomBytes(32).toString('base64url');
saveAuthConfig({ resetTokenHash: tokenHash(token), resetExpiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString() });
const link = `${appOrigin(req)}/reset?token=${encodeURIComponent(token)}`;
await sendMail(auth.email, 'Calorie AI password reset', `Use this link to reset your Calorie AI password. It expires in 1 hour.\n\n${link}`);
send(res, 200, JSON.stringify({ ok: true, message: 'If the account exists, a reset email was sent.' }));
} catch (error) {
send(res, 400, JSON.stringify({ error: error.message }));
}
}
async function confirmPasswordReset(req, res) {
try {
const payload = JSON.parse(await readBody(req) || '{}');
if (!payload.newPassword || payload.newPassword.length < 12) return send(res, 400, JSON.stringify({ error: 'Use at least 12 characters for the new password' }));
const valid = auth.resetTokenHash && auth.resetExpiresAt && new Date(auth.resetExpiresAt).getTime() > Date.now() && safeEqual(auth.resetTokenHash, tokenHash(payload.token || ''));
if (!valid) return send(res, 400, JSON.stringify({ error: 'Reset link is invalid or expired' }));
const passwordHash = await hashPassword(payload.newPassword);
saveAuthConfig({ passwordHash, resetTokenHash: undefined, resetExpiresAt: undefined });
send(res, 200, JSON.stringify({ ok: true }));
} catch (error) {
send(res, 400, JSON.stringify({ error: error.message }));
}
}
async function updateAccount(req, res) {
try {
const payload = JSON.parse(await readBody(req) || '{}');
const email = String(payload.email || '').trim();
if (!/^\S+@\S+\.\S+$/.test(email)) return send(res, 400, JSON.stringify({ error: 'Enter a valid email address' }));
saveAuthConfig({ email, emailVerified: email === auth.email ? auth.emailVerified : false });
send(res, 200, JSON.stringify({ ok: true, email: auth.email, emailVerified: auth.emailVerified }));
} catch (error) {
send(res, 400, JSON.stringify({ error: error.message }));
}
}
async function sendVerification(req, res) {
try {
if (!auth.email) return send(res, 400, JSON.stringify({ error: 'Set an account email first' }));
const token = crypto.randomBytes(32).toString('base64url');
saveAuthConfig({ verificationTokenHash: tokenHash(token), verificationExpiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString() });
const link = `${appOrigin(req)}/verify?token=${encodeURIComponent(token)}`;
await sendMail(auth.email, 'Verify your Calorie AI account', `Use this link to verify your Calorie AI account email. It expires in 24 hours.\n\n${link}`);
send(res, 200, JSON.stringify({ ok: true, message: 'Verification email sent.' }));
} catch (error) {
send(res, 400, JSON.stringify({ error: error.message }));
}
}
async function verifyAccount(req, res) {
try {
const payload = JSON.parse(await readBody(req) || '{}');
const valid = auth.verificationTokenHash && auth.verificationExpiresAt && new Date(auth.verificationExpiresAt).getTime() > Date.now() && safeEqual(auth.verificationTokenHash, tokenHash(payload.token || ''));
if (!valid) return send(res, 400, JSON.stringify({ error: 'Verification link is invalid or expired' }));
saveAuthConfig({ emailVerified: true, verificationTokenHash: undefined, verificationExpiresAt: undefined });
send(res, 200, JSON.stringify({ ok: true }));
} catch (error) {
send(res, 400, JSON.stringify({ error: error.message }));
}
@ -360,9 +489,14 @@ async function handleRequest(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/account') return updateAccount(req, res);
if (req.method === 'POST' && url === '/api/account/send-verification') return sendVerification(req, res);
if (req.method === 'POST' && url === '/api/account/verify') return verifyAccount(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/password/request-reset') return requestPasswordReset(req, res);
if (req.method === 'POST' && url === '/api/password/confirm-reset') return confirmPasswordReset(req, res);
if (req.method === 'GET' && url === '/api/session') return send(res, 200, JSON.stringify({ user: auth.user, email: auth.email || '', emailVerified: auth.emailVerified === true }));
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);
@ -375,7 +509,39 @@ async function handleRequest(req, res) {
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, () => {
const app = express();
app.use(express.text({ type: '*/*', limit: '12mb' }));
app.use((req, res, next) => {
const url = req.url.split('?')[0];
const authenticated = isAuthenticated(req);
if (!authenticated && !publicRequest(req)) {
if (url.startsWith('/api/')) return send(res, 401, JSON.stringify({ error: 'Authentication required' }));
return redirect(res, '/login');
}
if (authenticated && (req.method === 'GET' || req.method === 'HEAD') && url === '/login') return redirect(res, '/');
next();
});
const wrap = handler => (req, res) => handler(req, res).catch(error => send(res, 500, JSON.stringify({ error: error.message })));
app.post('/api/login', wrap(login));
app.post('/api/logout', logout);
app.post('/api/account', wrap(updateAccount));
app.post('/api/account/send-verification', wrap(sendVerification));
app.post('/api/account/verify', wrap(verifyAccount));
app.post('/api/password/change', wrap(changePassword));
app.post('/api/password/reset', wrap(resetPassword));
app.post('/api/password/request-reset', wrap(requestPasswordReset));
app.post('/api/password/confirm-reset', wrap(confirmPasswordReset));
app.get('/api/session', (req, res) => send(res, 200, JSON.stringify({ user: auth.user, email: auth.email || '', emailVerified: auth.emailVerified === true })));
app.post('/api/models', wrap(proxyModels));
app.post('/api/models/search', wrap(searchModels));
app.post('/api/models/add', wrap(addModel));
app.post('/api/models/remove', wrap(removeModel));
app.post('/api/chat', wrap(proxyChat));
app.use((req, res) => {
if (req.method === 'GET' || req.method === 'HEAD') return serveStatic(req, res);
send(res, 405, 'Method not allowed', 'text/plain; charset=utf-8');
});
app.listen(port, () => {
console.log(`Calorie AI web listening on ${port}`);
});
}

View file

@ -5,16 +5,20 @@
import MealForm from './MealForm.svelte';
import DiaryPage from './DiaryPage.svelte';
import SettingsPage from './SettingsPage.svelte';
import PlansPage from './PlansPage.svelte';
const entriesKey = 'calorie-ai-web-entries';
const trashKey = 'calorie-ai-web-trash';
const settingsKey = 'calorie-ai-web-settings';
const plansKey = 'calorie-ai-web-plans';
const selectedPlanKey = 'calorie-ai-web-selected-plan';
const pages = [
{ 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: 'plans', label: 'Plans', icon: '◇', group: 'Tracker' },
{ id: 'settings', label: 'Settings', icon: '⚙', group: 'Admin' },
];
const mealTypes = ['Breakfast', 'Lunch', 'Dinner', 'Snack', 'Other'];
@ -25,15 +29,23 @@
{ id: 'gpt-4o-mini', name: 'GPT-4o mini' },
];
let loginMode = location.pathname === '/login';
let loginMode = location.pathname === '/login' || location.pathname === '/reset' || location.pathname === '/verify';
let username = 'admin';
let password = '';
let loginStatus = '';
let loginError = false;
let resetIdentity = '';
let resetToken = new URLSearchParams(location.search).get('token') || '';
let resetNewPassword = '';
let resetConfirmPassword = '';
let resetStatus = '';
let resetError = false;
let verifyStatus = '';
let verifyError = false;
let page = 'dashboard';
let menuSearch = '';
let menuOpen = false;
let sidebarCollapsed = false;
let entries = [];
let models = fallbackModels;
@ -46,6 +58,10 @@
let currentPassword = '';
let newPassword = '';
let confirmPassword = '';
let accountEmail = '';
let accountVerified = false;
let accountStatus = '';
let accountError = false;
let passwordStatus = '';
let passwordError = false;
let resetPasswordValue = '';
@ -65,7 +81,9 @@
let planStatus = '';
let planError = false;
let planLoading = false;
let planAdvice = '';
let plans = [];
let selectedPlanId = '';
let tuneNote = '';
let diarySearch = '';
let diaryType = '';
@ -77,9 +95,8 @@
let calorieCanvas;
let produceCanvas;
$: filteredPages = pages.filter(item => !menuSearch || item.label.toLowerCase().includes(menuSearch.toLowerCase()));
$: trackerPages = filteredPages.filter(item => item.group === 'Tracker');
$: adminPages = filteredPages.filter(item => item.group === 'Admin');
$: trackerPages = pages.filter(item => item.group === 'Tracker');
$: adminPages = pages.filter(item => item.group === 'Admin');
$: today = totalsFor(todayKey());
$: week = last7Days();
$: weekAverage = Math.round(week.reduce((sum, day) => sum + day.calories, 0) / week.length);
@ -98,7 +115,11 @@
onMount(async () => {
entries = readJson(entriesKey, []);
trash = readJson(trashKey, []);
plans = readJson(plansKey, []);
selectedPlanId = localStorage.getItem(selectedPlanKey) || plans[0]?.id || '';
settings = { ...settings, ...readJson(settingsKey, settings) };
await loadSession();
if (location.pathname === '/verify' && resetToken) await verifyAccountToken();
page = localStorage.getItem('calorie_ai_last_tab') || 'dashboard';
setDefaultMealDateTime();
await loadModels();
@ -169,6 +190,11 @@
localStorage.setItem('calorie_ai_last_tab', id);
}
function toggleMenu() {
if (window.matchMedia('(min-width: 1024px)').matches) sidebarCollapsed = !sidebarCollapsed;
else menuOpen = true;
}
async function login() {
loginStatus = 'Checking credentials...';
loginError = false;
@ -191,6 +217,61 @@
location.href = '/login';
}
async function loadSession() {
if (location.pathname === '/reset' || location.pathname === '/verify') return;
try {
const response = await fetch('/api/session');
if (!response.ok) return;
const data = await response.json();
accountEmail = data.email || '';
accountVerified = data.emailVerified === true;
} catch {}
}
async function requestResetEmail() {
resetStatus = 'Sending reset email...';
resetError = false;
const response = await fetch('/api/password/request-reset', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ identity: resetIdentity || username }),
});
const data = await response.json().catch(() => ({ error: 'Reset request failed.' }));
resetStatus = data.message || data.error || 'If the account exists, a reset email was sent.';
resetError = !response.ok;
}
async function confirmResetPassword() {
resetStatus = 'Resetting password...';
resetError = false;
if (resetNewPassword !== resetConfirmPassword) {
resetStatus = 'New passwords do not match.';
resetError = true;
return;
}
const response = await fetch('/api/password/confirm-reset', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ token: resetToken, newPassword: resetNewPassword }),
});
const data = await response.json().catch(() => ({ error: 'Password reset failed.' }));
resetStatus = response.ok ? 'Password reset. You can sign in now.' : data.error;
resetError = !response.ok;
}
async function verifyAccountToken() {
verifyStatus = 'Verifying email...';
verifyError = false;
const response = await fetch('/api/account/verify', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ token: resetToken }),
});
const data = await response.json().catch(() => ({ error: 'Verification failed.' }));
verifyStatus = response.ok ? 'Email verified. You can sign in.' : data.error;
verifyError = !response.ok;
}
async function loadModels() {
modelStatus = 'Loading models...';
modelError = false;
@ -215,6 +296,14 @@
saveJson(settingsKey, settings);
}
function savePlans(nextPlans = plans, nextSelectedPlanId = selectedPlanId) {
plans = nextPlans;
selectedPlanId = nextSelectedPlanId || plans[0]?.id || '';
saveJson(plansKey, plans);
if (selectedPlanId) localStorage.setItem(selectedPlanKey, selectedPlanId);
else localStorage.removeItem(selectedPlanKey);
}
function emptyEstimate() {
return { mealName: '', calories: 0, proteinGrams: 0, carbsGrams: 0, fatGrams: 0, fruitServings: 0, vegetableServings: 0, foodGroups: '', notes: '' };
}
@ -327,7 +416,7 @@
}
async function resetPassword() {
passwordStatus = 'Generating a new password...';
passwordStatus = 'Sending reset email...';
passwordError = false;
const response = await fetch('/api/password/reset', { method: 'POST' });
if (!response.ok) {
@ -337,8 +426,36 @@
return;
}
const data = await response.json();
resetPasswordValue = data.password || '';
passwordStatus = 'Password reset. Save the generated password now.';
resetPasswordValue = '';
passwordStatus = data.message || 'Reset email sent.';
}
async function saveAccountEmail() {
accountStatus = 'Saving account email...';
accountError = false;
const response = await fetch('/api/account', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email: accountEmail }),
});
const data = await response.json().catch(() => ({ error: 'Email update failed.' }));
if (!response.ok) {
accountStatus = data.error || 'Email update failed.';
accountError = true;
return;
}
accountEmail = data.email || accountEmail;
accountVerified = data.emailVerified === true;
accountStatus = 'Account email saved.';
}
async function sendVerificationEmail() {
accountStatus = 'Sending verification email...';
accountError = false;
const response = await fetch('/api/account/send-verification', { method: 'POST' });
const data = await response.json().catch(() => ({ error: 'Verification email failed.' }));
accountStatus = data.message || data.error || 'Verification email sent.';
accountError = !response.ok;
}
async function fileToDataUrl(file) {
@ -542,14 +659,16 @@ Image estimate: ${imageEstimate}` },
planError = false;
try {
if (!settings.heightCm || !settings.weightKg) throw new Error('Enter height and current weight first.');
planAdvice = await chatCompletion({
const content = 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.` },
{ role: 'system', content: 'Create safe, concise nutrition and weight-loss guidance. Do not diagnose disease. Recommend medical review for pregnancy, eating disorder history, chronic disease, or aggressive goals. Use plain text section titles and simple lines. Do not use markdown symbols.' },
{ 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, weekly review steps, and safety notes. Recent meals: ${entries.slice(-10).map(entry => `${entry.date} ${entry.mealType}: ${entry.mealName || entry.description}, ${entry.calories || 0} kcal`).join('; ') || 'none logged yet'}.` },
],
});
const plan = { id: entryId(), title: `Weight-loss plan ${plans.length + 1}`, version: plans.length + 1, content, createdAt: new Date().toISOString() };
savePlans([plan, ...plans], plan.id);
planStatus = 'Plan generated.';
saveSettings();
} catch (error) {
@ -560,6 +679,49 @@ Image estimate: ${imageEstimate}` },
}
}
async function tunePlan(planId) {
const currentPlan = plans.find(plan => plan.id === planId);
if (!currentPlan) return;
if (!tuneNote.trim()) {
planStatus = 'Add a note before updating the plan.';
planError = true;
return;
}
planLoading = true;
planStatus = 'Updating plan...';
planError = false;
try {
const content = await chatCompletion({
model: settings.taskModel,
temperature: 0.2,
messages: [
{ role: 'system', content: 'Update the weight-loss plan using the provided notes and logged meals. Use plain text section titles and simple lines. Do not use markdown symbols. Do not mention chat history.' },
{ role: 'user', content: `Current plan:\n${currentPlan.content}\n\nUpdate note:\n${tuneNote}\n\nRecent meals:\n${entries.slice(-15).map(entry => `${entry.date} ${entry.mealType}: ${entry.mealName || entry.description}, ${entry.calories || 0} kcal`).join('; ') || 'none logged yet'}` },
],
});
const version = Math.max(0, ...plans.map(plan => Number(plan.version || 0))) + 1;
const updatedPlan = { id: entryId(), title: `Updated plan ${version}`, version, content, createdAt: new Date().toISOString(), previousPlanId: currentPlan.id };
savePlans([updatedPlan, ...plans], updatedPlan.id);
tuneNote = '';
planStatus = 'Plan updated.';
} catch (error) {
planStatus = error.message;
planError = true;
} finally {
planLoading = false;
}
}
function selectPlan(planId) {
selectedPlanId = planId;
localStorage.setItem(selectedPlanKey, planId);
}
function deletePlan(planId) {
const nextPlans = plans.filter(plan => plan.id !== planId);
savePlans(nextPlans, selectedPlanId === planId ? nextPlans[0]?.id || '' : selectedPlanId);
}
async function drawCharts() {
await tick();
drawMacroChart();
@ -641,6 +803,27 @@ Image estimate: ${imageEstimate}` },
<h1 class="mt-3 text-5xl font-black tracking-tight lg:text-7xl">Calorie AI</h1>
<p class="mt-4 max-w-xl text-blue-100">Track meals, calories, macros, fruit, and vegetables across any day.</p>
</section>
{#if location.pathname === '/reset'}
<form class="rounded-3xl border border-slate-200 bg-white p-8 shadow-xl lg:p-10" on:submit|preventDefault={confirmResetPassword}>
<div class="mb-6 flex h-12 w-12 items-center justify-center rounded-2xl bg-blue-600 font-black text-white">CA</div>
<h2 class="text-2xl font-bold">Reset password</h2>
<label class="mt-6 block text-sm font-semibold text-slate-700">New password
<input class="mt-2 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-blue-500 focus:ring-4 focus:ring-blue-100" bind:value={resetNewPassword} type="password" autocomplete="new-password" minlength="12">
</label>
<label class="mt-4 block text-sm font-semibold text-slate-700">Confirm password
<input class="mt-2 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-blue-500 focus:ring-4 focus:ring-blue-100" bind:value={resetConfirmPassword} type="password" autocomplete="new-password" minlength="12">
</label>
<button class="mt-6 w-full rounded-xl bg-blue-600 px-4 py-3 font-bold text-white hover:bg-blue-700" type="submit">Set new password</button>
{#if resetStatus}<p class:!text-red-600={resetError} class="mt-4 text-sm font-semibold text-blue-700">{resetStatus}</p>{/if}
</form>
{:else if location.pathname === '/verify'}
<section class="rounded-3xl border border-slate-200 bg-white p-8 shadow-xl lg:p-10">
<div class="mb-6 flex h-12 w-12 items-center justify-center rounded-2xl bg-blue-600 font-black text-white">CA</div>
<h2 class="text-2xl font-bold">Verify email</h2>
{#if verifyStatus}<p class:!text-red-600={verifyError} class="mt-4 text-sm font-semibold text-blue-700">{verifyStatus}</p>{/if}
<a class="mt-6 inline-block rounded-xl bg-blue-600 px-4 py-3 font-bold text-white hover:bg-blue-700" href="/login">Go to sign in</a>
</section>
{:else}
<form class="rounded-3xl border border-slate-200 bg-white p-8 shadow-xl lg:p-10" on:submit|preventDefault={login}>
<div class="mb-6 flex h-12 w-12 items-center justify-center rounded-2xl bg-blue-600 font-black text-white">CA</div>
<h2 class="text-2xl font-bold">Sign in</h2>
@ -652,8 +835,16 @@ Image estimate: ${imageEstimate}` },
<input id="password" class="mt-2 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-blue-500 focus:ring-4 focus:ring-blue-100" bind:value={password} type="password" autocomplete="current-password">
</label>
<button class="mt-6 w-full rounded-xl bg-blue-600 px-4 py-3 font-bold text-white hover:bg-blue-700" type="submit">Sign in</button>
<div class="mt-4 border-t border-slate-200 pt-4">
<label class="block text-sm font-semibold text-slate-700">Email or username for reset
<input class="mt-2 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-blue-500 focus:ring-4 focus:ring-blue-100" bind:value={resetIdentity} placeholder="admin or email">
</label>
<button class="mt-3 w-full rounded-xl border border-slate-300 px-4 py-3 font-bold text-slate-700 hover:border-blue-500 hover:text-blue-700" type="button" on:click={requestResetEmail}>Email password reset link</button>
{#if resetStatus}<p class:!text-red-600={resetError} class="mt-3 text-sm font-semibold text-blue-700">{resetStatus}</p>{/if}
</div>
{#if loginStatus}<p class:!text-red-600={loginError} class="mt-4 text-sm font-semibold text-blue-700">{loginStatus}</p>{/if}
</form>
{/if}
</div>
</main>
{:else}
@ -661,7 +852,7 @@ Image estimate: ${imageEstimate}` },
<header class="sticky top-0 z-30 bg-gradient-to-r from-blue-600 to-indigo-700 text-white shadow-sm">
<div class="flex items-center justify-between gap-3 px-4 py-3 lg:px-6">
<div class="flex min-w-0 items-center gap-3">
<button class="grid h-10 w-10 place-items-center rounded-xl bg-white/15 lg:hidden" type="button" on:click={() => menuOpen = true} aria-label="Open menu"></button>
<button class="grid h-10 w-10 place-items-center rounded-xl bg-white/15 hover:bg-white/25" type="button" on:click={toggleMenu} aria-label="Toggle menu"></button>
<div class="grid h-11 w-11 place-items-center rounded-2xl bg-white text-sm font-black text-blue-700">CA</div>
<div class="min-w-0">
<h1 class="truncate text-lg font-bold">Calorie AI</h1>
@ -669,22 +860,17 @@ Image estimate: ${imageEstimate}` },
</div>
</div>
<div class="flex items-center gap-2">
<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="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">
<aside class:translate-x-0={menuOpen} class:collapsed-sidebar={sidebarCollapsed} 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-all duration-200 lg:sticky lg:top-[68px] lg:h-[calc(100vh-68px)] lg:translate-x-0 lg:shadow-none lg:w-60">
<div class="flex items-center justify-between border-b border-slate-200 px-4 py-3 lg:hidden">
<strong>Menu</strong>
<button class="rounded-lg bg-slate-100 px-3 py-1.5 text-sm" type="button" on:click={() => menuOpen = false}>Close</button>
</div>
<div class="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="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}
@ -705,7 +891,7 @@ 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('settings')}>Settings</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('plans')}>Plans</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>
@ -743,7 +929,6 @@ Image estimate: ${imageEstimate}` },
{editEntry}
{toggleSelected}
{moveEntriesToTrash}
{moveDayToTrash}
{restoreEntry}
/>
{:else if page === 'trash'}
@ -759,10 +944,23 @@ Image estimate: ${imageEstimate}` },
{editEntry}
{toggleSelected}
{moveEntriesToTrash}
{moveDayToTrash}
{restoreEntry}
trashMode={true}
/>
{:else if page === 'plans'}
<PlansPage
bind:settings
{plans}
{selectedPlanId}
{planStatus}
{planError}
{planLoading}
bind:tuneNote
{generatePlan}
{tunePlan}
{selectPlan}
{deletePlan}
/>
{:else if page === 'settings'}
<SettingsPage
bind:settings
@ -772,24 +970,25 @@ Image estimate: ${imageEstimate}` },
{modelStatus}
{modelError}
{modelSearching}
bind:accountEmail
{accountVerified}
{accountStatus}
{accountError}
{passwordStatus}
{passwordError}
{resetPasswordValue}
bind:currentPassword
bind:newPassword
bind:confirmPassword
{planStatus}
{planError}
{planLoading}
{planAdvice}
{saveSelectedModels}
{loadModels}
{searchModels}
{addModel}
{removeModel}
{saveAccountEmail}
{sendVerificationEmail}
{changePassword}
{resetPassword}
{generatePlan}
/>
{/if}
</main>
@ -802,4 +1001,7 @@ Image estimate: ${imageEstimate}` },
.tab-button:hover { background: #f8fafc; color: #0f172a; }
.active-tab { border-left-color: #2563eb; background: #dbeafe; color: #2563eb; }
.tab-icon { display: grid; width: 1.35rem; height: 1.35rem; flex: 0 0 1.35rem; place-items: center; font-weight: 900; }
:global(.collapsed-sidebar) { width: 4.75rem !important; }
:global(.collapsed-sidebar .tab-label), :global(.collapsed-sidebar .sidebar-section-label) { display: none; }
:global(.collapsed-sidebar .tab-button) { justify-content: center; padding-left: 0.75rem; padding-right: 0.75rem; }
</style>

View file

@ -12,9 +12,11 @@
export let editEntry;
export let toggleSelected;
export let moveEntriesToTrash;
export let moveDayToTrash;
export let restoreEntry;
export let trashMode = false;
$: selectedRows = diaryRows.filter(entry => selectedEntryIds.includes(entry.id));
$: oneSelected = selectedRows.length === 1;
</script>
<section class="space-y-4">
@ -38,12 +40,20 @@
</div>
{:else}
<section class="card">
<div class="grid gap-3 p-4 md:grid-cols-[1fr_180px_170px_auto]">
<div class="grid gap-3 p-4 md:grid-cols-[1fr_180px_170px]">
<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>
{#if selectedEntryIds.length}
<div class="flex flex-col gap-2 border-t border-slate-200 bg-blue-50 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
<div class="text-sm font-bold text-blue-900">{selectedEntryIds.length} selected</div>
<div class="flex flex-col gap-2 sm:flex-row">
{#if oneSelected}<button class="btn-secondary" type="button" on:click={() => editEntry(selectedRows[0])}>Edit selected</button>{/if}
<button class="rounded-lg bg-red-600 px-4 py-2 text-sm font-black text-white hover:bg-red-700" type="button" on:click={() => moveEntriesToTrash(selectedEntryIds)}>Move selected to trash</button>
</div>
</div>
{/if}
</section>
<div id="entries" class="grid gap-3">
@ -52,14 +62,14 @@
<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>
<button class="rounded-md border border-slate-200 bg-white px-3 py-1.5 text-xs font-bold text-slate-600 hover:border-blue-300 hover:text-blue-700" type="button" on:click={() => diaryDate = day}>Filter day</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 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}

128
web/src/PlansPage.svelte Normal file
View file

@ -0,0 +1,128 @@
<script>
import Field from './Field.svelte';
export let settings;
export let plans = [];
export let selectedPlanId;
export let planStatus;
export let planError;
export let planLoading;
export let tuneNote;
export let generatePlan;
export let tunePlan;
export let selectPlan;
export let deletePlan;
$: selectedPlan = plans.find(plan => plan.id === selectedPlanId) || plans[0];
const knownHeadings = new Set([
'plan',
'personalized weight-loss plan',
'your details',
'daily calorie range',
'protein target',
'healthy habits',
'weekly review steps',
'safety notes',
'getting started',
'targets',
'habits',
]);
function cleanLine(line) {
return line.replace(/^#{1,6}\s*/, '').replace(/^[-*]\s+/, '').replace(/\*\*/g, '').replace(/^---+$/, '').trim();
}
function isHeading(line) {
const normalized = line.replace(/:$/, '').toLowerCase();
if (knownHeadings.has(normalized)) return true;
const words = line.replace(/:$/, '').split(/\s+/).filter(Boolean);
return words.length > 1 && words.length <= 6 && words.every(word => /^[A-Z][A-Za-z-]*$/.test(word));
}
function sections(text) {
const lines = String(text || '').split('\n').map(cleanLine).filter(Boolean);
const output = [];
if (lines[0]?.toLowerCase() === 'plan') lines.shift();
let current = { title: 'Plan', items: [] };
for (const line of lines) {
if (isHeading(line)) {
if (current.items.length) output.push(current);
current = { title: line.replace(/^\d+\.\s+/, '').replace(/:$/, ''), items: [] };
} else {
current.items.push(line);
}
}
output.push(current);
return output.filter(section => section.items.length || section.title !== 'Plan');
}
</script>
<section class="space-y-4">
<div>
<h2 class="text-xl font-bold">Plans</h2>
<p class="text-sm text-slate-500">Generate and fine-tune weight-loss plans from your profile, meals, and activities. Notes are used only for the update and are not stored.</p>
</div>
<div class="grid gap-4 xl:grid-cols-[0.8fr_1.2fr]">
<section class="card">
<h3 class="card-title">Profile</h3>
<div class="grid gap-3 p-4 md:grid-cols-2 xl:grid-cols-1">
<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>
<button class="btn-primary" type="button" on:click={generatePlan} disabled={planLoading}>{planLoading ? 'Generating...' : 'Generate new plan'}</button>
{#if planStatus}<p class:text-red-600={planError} class="text-sm font-semibold text-green-700">{planStatus}</p>{/if}
</div>
</section>
<section class="card">
<h3 class="card-title">Current plan</h3>
<div class="grid gap-4 p-4">
{#if selectedPlan}
<div class="flex flex-col justify-between gap-2 sm:flex-row sm:items-center">
<div>
<strong>{selectedPlan.title}</strong>
<div class="text-xs text-slate-500">Version {selectedPlan.version || 1} · {new Date(selectedPlan.createdAt).toLocaleString()}</div>
</div>
<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={() => deletePlan(selectedPlan.id)}>Delete</button>
</div>
<div class="grid gap-3">
{#each sections(selectedPlan.content) as section}
<article class="rounded-xl border border-slate-200 bg-slate-50 p-4">
<h4 class="border-b border-slate-200 pb-2 text-base font-black text-slate-950">{section.title}</h4>
<ul class="mt-3 grid gap-2 text-sm leading-6 text-slate-700">
{#each section.items as item}<li>{item}</li>{/each}
</ul>
</article>
{/each}
</div>
<div class="border-t border-slate-200 pt-4">
<Field label="Fine-tune with meals, activities, or progress notes"><textarea class="input min-h-28" bind:value={tuneNote} placeholder="Example: I walked 7,000 steps daily this week, felt hungry at night, and ate two higher-calorie dinners."></textarea></Field>
<button class="btn-primary mt-3" type="button" on:click={() => tunePlan(selectedPlan.id)} disabled={planLoading}>{planLoading ? 'Updating...' : 'Update plan with AI'}</button>
</div>
{:else}
<div class="empty-state">No plans yet. Enter your profile and generate your first plan.</div>
{/if}
</div>
</section>
</div>
<section class="card">
<h3 class="card-title">Plan history</h3>
<div class="grid gap-2 p-4 md:grid-cols-2 xl:grid-cols-3">
{#if plans.length}
{#each plans as plan}
<button class="rounded-xl border px-3 py-2 text-left text-sm hover:border-blue-300 hover:bg-blue-50" class:border-blue-500={plan.id === selectedPlanId} type="button" on:click={() => selectPlan(plan.id)}>
<strong>{plan.title}</strong>
<div class="text-xs text-slate-500">Version {plan.version || 1} · {new Date(plan.createdAt).toLocaleString()}</div>
</button>
{/each}
{:else}
<p class="text-sm text-slate-500">Old plans will appear here and remain available after updates.</p>
{/if}
</div>
</section>
</section>

View file

@ -8,55 +8,52 @@
export let modelStatus;
export let modelError;
export let modelSearching;
export let accountEmail;
export let accountVerified;
export let accountStatus;
export let accountError;
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 saveAccountEmail;
export let sendVerificationEmail;
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">Account</h3>
<div class="grid gap-3 p-4">
<Field label="Account email"><input id="accountEmail" class="input" type="email" bind:value={accountEmail} autocomplete="email"></Field>
<div class="text-sm font-semibold" class:text-green-700={accountVerified} class:text-amber-700={!accountVerified}>{accountVerified ? 'Email verified' : 'Email not verified'}</div>
<div class="flex flex-col gap-2 sm:flex-row"><button class="btn-primary" type="button" on:click={saveAccountEmail}>Save email</button><button class="btn-secondary" type="button" on:click={sendVerificationEmail}>Send verification email</button></div>
{#if accountStatus}<p id="accountStatus" class:text-red-600={accountError} class="text-sm font-semibold text-green-700">{accountStatus}</p>{/if}
</div>
</section>
<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>
<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}>Email reset link</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]">

View file

@ -77,17 +77,74 @@ test.describe('authenticated app', () => {
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});
test('uses searchable page navigation and validates missing meal input', async ({ page }) => {
test('uses direct page navigation and validates missing meal input', async ({ page }) => {
await expect(page.getByRole('navigation', { name: 'Main menu' })).toBeVisible();
await expect(page.locator('#menu-search')).toHaveCount(0);
await expect(page.locator('#todayCalories')).toHaveText('0 kcal');
await page.locator('#menu-search').fill('log');
await expect(page.getByRole('button', { name: 'Log meal' })).toBeVisible();
await page.getByRole('button', { name: 'Log meal' }).click();
await page.locator('nav').getByRole('button').filter({ hasText: 'Log meal' }).click();
await page.getByRole('button', { name: 'Analyze and save' }).click();
await expect(page.locator('#status')).toContainText('Describe the meal or upload an image.');
});
test('generates and fine-tunes versioned plans without raw markdown', async ({ page }) => {
let planCalls = 0;
await page.route('**/api/chat', async (route) => {
planCalls += 1;
const content = planCalls === 1
? '**Targets**\n- Calories: 1,700-1,900 daily\n- Protein: 120 g daily\n---\nHabits\n- Walk after dinner'
: 'Targets\nCalories: 1,650-1,850 daily\nProtein: 125 g daily\nHabits\nWalk 7,000 steps daily';
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ choices: [{ message: { content } }] }),
});
});
await page.locator('nav').getByRole('button').filter({ hasText: 'Plans' }).click();
await expect(page.getByRole('heading', { name: 'Plans' })).toBeVisible();
await page.getByLabel('Height (cm)').fill('178');
await page.getByLabel('Current weight (kg)').fill('91');
await page.getByLabel('Target weight (kg)').fill('82');
await page.getByRole('button', { name: 'Generate new plan' }).click();
await expect(page.getByText('Plan generated.')).toBeVisible();
await expect(page.getByText('Targets')).toBeVisible();
await expect(page.getByText('**Targets**')).toHaveCount(0);
await expect(page.getByText('---')).toHaveCount(0);
await page.getByLabel('Fine-tune with meals, activities, or progress notes').fill('I walked 7,000 steps and felt hungry at night.');
await page.getByRole('button', { name: 'Update plan with AI' }).click();
await expect(page.getByText('Plan updated.')).toBeVisible();
await expect(page.getByText('Updated plan 2').first()).toBeVisible();
await expect(page.getByText('Weight-loss plan 1')).toBeVisible();
});
test('saves account email and requests verification from settings', async ({ page }) => {
await page.route('**/api/account', async (route) => {
const body = route.request().postDataJSON();
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ ok: true, email: body.email, emailVerified: false }),
});
});
await page.route('**/api/account/send-verification', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ ok: true, message: 'Verification email sent.' }),
});
});
await page.locator('nav').getByRole('button').filter({ hasText: 'Settings' }).click();
await page.getByLabel('Account email').fill('tester@example.com');
await page.getByRole('button', { name: 'Save email' }).click();
await expect(page.locator('#accountStatus')).toContainText('Account email saved.');
await expect(page.getByText('Email not verified')).toBeVisible();
await page.getByRole('button', { name: 'Send verification email' }).click();
await expect(page.locator('#accountStatus')).toContainText('Verification email sent.');
});
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) => {
@ -103,7 +160,7 @@ test.describe('authenticated app', () => {
});
});
await page.getByRole('banner').getByRole('button', { name: 'Settings' }).click();
await page.locator('nav').getByRole('button').filter({ hasText: 'Settings' }).click();
await page.locator('#modelSearch').fill('gemini');
await page.getByRole('button', { name: 'Search' }).click();
await expect(page.getByText('Gemini 2.5 Flash')).toBeVisible();
@ -128,7 +185,10 @@ test.describe('authenticated app', () => {
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('button', { name: 'Move selected to trash' })).toHaveCount(0);
await page.getByRole('checkbox').check();
await expect(page.getByRole('button', { name: 'Edit selected' })).toBeVisible();
await page.getByRole('button', { name: 'Edit selected' }).click();
await expect(page.getByRole('heading', { name: 'Edit meal' })).toBeVisible();
await page.getByLabel('Calories').fill('610');
await page.getByRole('button', { name: 'Save edits' }).click();