From 5fa4b59335130b8a507da11da02e13dd92154f15 Mon Sep 17 00:00:00 2001 From: Daniel Date: Wed, 20 May 2026 00:48:52 +0200 Subject: [PATCH] Update web plans and app capture --- .../com/danvics/calorieai/MainActivity.kt | 4 + .../java/com/danvics/calorieai/ai/AiClient.kt | 8 + .../com/danvics/calorieai/ui/CalorieAiApp.kt | 3 +- .../com/danvics/calorieai/ui/LogMealScreen.kt | 7 +- web/package-lock.json | 12 +- web/package.json | 3 +- web/server.js | 186 ++++++++++++- web/src/App.svelte | 256 ++++++++++++++++-- web/src/DiaryPage.svelte | 20 +- web/src/PlansPage.svelte | 128 +++++++++ web/src/SettingsPage.svelte | 37 ++- web/tests/app.spec.js | 72 ++++- 12 files changed, 664 insertions(+), 72 deletions(-) create mode 100644 web/src/PlansPage.svelte diff --git a/app/src/main/java/com/danvics/calorieai/MainActivity.kt b/app/src/main/java/com/danvics/calorieai/MainActivity.kt index c3199ad..5eca19d 100644 --- a/app/src/main/java/com/danvics/calorieai/MainActivity.kt +++ b/app/src/main/java/com/danvics/calorieai/MainActivity.kt @@ -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) { entries = next; repo.saveEntries(next) } fun persistTrash(next: List) { 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 }) diff --git a/app/src/main/java/com/danvics/calorieai/ai/AiClient.kt b/app/src/main/java/com/danvics/calorieai/ai/AiClient.kt index 6f64d7d..c6a1dd4 100644 --- a/app/src/main/java/com/danvics/calorieai/ai/AiClient.kt +++ b/app/src/main/java/com/danvics/calorieai/ai/AiClient.kt @@ -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()) + } } } diff --git a/app/src/main/java/com/danvics/calorieai/ui/CalorieAiApp.kt b/app/src/main/java/com/danvics/calorieai/ui/CalorieAiApp.kt index b0e6431..e039fd0 100644 --- a/app/src/main/java/com/danvics/calorieai/ui/CalorieAiApp.kt +++ b/app/src/main/java/com/danvics/calorieai/ui/CalorieAiApp.kt @@ -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) diff --git a/app/src/main/java/com/danvics/calorieai/ui/LogMealScreen.kt b/app/src/main/java/com/danvics/calorieai/ui/LogMealScreen.kt index e9375cf..efe2c9c 100644 --- a/app/src/main/java/com/danvics/calorieai/ui/LogMealScreen.kt +++ b/app/src/main/java/com/danvics/calorieai/ui/LogMealScreen.kt @@ -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)) }, diff --git a/web/package-lock.json b/web/package-lock.json index 8dcf3fb..7557eb9 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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", diff --git a/web/package.json b/web/package.json index 239239b..e59bdfe 100644 --- a/web/package.json +++ b/web/package.json @@ -18,6 +18,7 @@ }, "dependencies": { "argon2": "^0.44.0", - "express": "^5.2.1" + "express": "^5.2.1", + "nodemailer": "^7.0.11" } } diff --git a/web/server.js b/web/server.js index 02b2c1e..fa95b38 100644 --- a/web/server.js +++ b/web/server.js @@ -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}`); }); } diff --git a/web/src/App.svelte b/web/src/App.svelte index 388ad0d..ce61c29 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -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}` },

Calorie AI

Track meals, calories, macros, fruit, and vegetables across any day.

+ {#if location.pathname === '/reset'} +
+
CA
+

Reset password

+ + + + {#if resetStatus}

{resetStatus}

{/if} +
+ {:else if location.pathname === '/verify'} +
+
CA
+

Verify email

+ {#if verifyStatus}

{verifyStatus}

{/if} + Go to sign in +
+ {:else}
CA

Sign in

@@ -652,8 +835,16 @@ Image estimate: ${imageEstimate}` }, +
+ + + {#if resetStatus}

{resetStatus}

{/if} +
{#if loginStatus}

{loginStatus}

{/if}
+ {/if} {:else} @@ -661,7 +852,7 @@ Image estimate: ${imageEstimate}` },
- +
CA

Calorie AI

@@ -669,22 +860,17 @@ Image estimate: ${imageEstimate}` },
-
-
-

Quick actions

+

Quick actions

Macros today

@@ -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'} + {:else if page === 'settings'} {/if} @@ -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; } diff --git a/web/src/DiaryPage.svelte b/web/src/DiaryPage.svelte index aa6b923..cd114db 100644 --- a/web/src/DiaryPage.svelte +++ b/web/src/DiaryPage.svelte @@ -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;
@@ -38,12 +40,20 @@ {:else}
-
+
-
+ {#if selectedEntryIds.length} +
+
{selectedEntryIds.length} selected
+
+ {#if oneSelected}{/if} + +
+
+ {/if}
@@ -52,14 +62,14 @@

{day}

- +
{#each diaryRows.filter(entry => entry.date === day) as entry}
-
{entry.calories} kcal
+
{entry.calories} kcal
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)}
{#if entry.description}
{entry.description}
{/if} diff --git a/web/src/PlansPage.svelte b/web/src/PlansPage.svelte new file mode 100644 index 0000000..15c6239 --- /dev/null +++ b/web/src/PlansPage.svelte @@ -0,0 +1,128 @@ + + +
+
+

Plans

+

Generate and fine-tune weight-loss plans from your profile, meals, and activities. Notes are used only for the update and are not stored.

+
+ +
+
+

Profile

+
+ + + + + + + {#if planStatus}

{planStatus}

{/if} +
+
+ +
+

Current plan

+
+ {#if selectedPlan} +
+
+ {selectedPlan.title} +
Version {selectedPlan.version || 1} · {new Date(selectedPlan.createdAt).toLocaleString()}
+
+ +
+
+ {#each sections(selectedPlan.content) as section} +
+

{section.title}

+
    + {#each section.items as item}
  • {item}
  • {/each} +
+
+ {/each} +
+
+ + +
+ {:else} +
No plans yet. Enter your profile and generate your first plan.
+ {/if} +
+
+
+ +
+

Plan history

+
+ {#if plans.length} + {#each plans as plan} + + {/each} + {:else} +

Old plans will appear here and remain available after updates.

+ {/if} +
+
+
diff --git a/web/src/SettingsPage.svelte b/web/src/SettingsPage.svelte index 677b29e..de71b86 100644 --- a/web/src/SettingsPage.svelte +++ b/web/src/SettingsPage.svelte @@ -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;

Settings

Account, profile, and model controls.

+
+

Account

+
+ +
{accountVerified ? 'Email verified' : 'Email not verified'}
+
+ {#if accountStatus}

{accountStatus}

{/if} +
+
+

Password

-
+
{#if passwordStatus}

{passwordStatus}

{/if} {#if resetPasswordValue}
{resetPasswordValue}
{/if}
-
-

Weight-loss plan

-
- - - - - -
- {#if planStatus}

{planStatus}

{/if} - {#if planAdvice}
{planAdvice}
{/if} -
-
-

Models

diff --git a/web/tests/app.spec.js b/web/tests/app.spec.js index cfdb084..6b3bad6 100644 --- a/web/tests/app.spec.js +++ b/web/tests/app.spec.js @@ -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();