Add release APK workflow and server plans
All checks were successful
Android CI / build (push) Successful in 1m10s
Web CI / test (push) Successful in 51s
Android Release / release-apk (push) Successful in 1m5s

This commit is contained in:
Daniel 2026-05-20 03:27:25 +02:00
parent 5fa4b59335
commit f42e8c8784
7 changed files with 279 additions and 66 deletions

View file

@ -1,5 +1,8 @@
name: Android Release name: Android Release
permissions:
contents: write
on: on:
push: push:
tags: tags:
@ -31,6 +34,25 @@ jobs:
mkdir -p dist mkdir -p dist
cp app/build/outputs/apk/debug/app-debug.apk "dist/calorie-ai-${GITHUB_REF_NAME}.apk" cp app/build/outputs/apk/debug/app-debug.apk "dist/calorie-ai-${GITHUB_REF_NAME}.apk"
- name: Publish Forgejo release asset
env:
FORGEJO_API: http://forgejo:3000/api/v1
TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
apk="dist/calorie-ai-${GITHUB_REF_NAME}.apk"
auth_header="Authorization: token ${TOKEN}"
release_json="$(curl -fsS -H "$auth_header" "$FORGEJO_API/repos/${GITHUB_REPOSITORY}/releases/tags/${GITHUB_REF_NAME}" || true)"
if [ -z "$release_json" ]; then
release_json="$(node -e 'console.log(JSON.stringify({ tag_name: process.env.GITHUB_REF_NAME, name: `Calorie AI ${process.env.GITHUB_REF_NAME}`, body: "Installable Android debug APK for Calorie AI.", draft: false, prerelease: false }))' | curl -fsS -X POST -H "$auth_header" -H 'Content-Type: application/json' --data-binary @- "$FORGEJO_API/repos/${GITHUB_REPOSITORY}/releases")"
fi
release_id="$(printf '%s' "$release_json" | node -e 'let data=""; process.stdin.on("data", c => data += c); process.stdin.on("end", () => console.log(JSON.parse(data).id));')"
asset_id="$(printf '%s' "$release_json" | node -e 'let data=""; process.stdin.on("data", c => data += c); process.stdin.on("end", () => { const release = JSON.parse(data); const asset = (release.assets || []).find(item => item.name === `calorie-ai-${process.env.GITHUB_REF_NAME}.apk`); if (asset) console.log(asset.id); });')"
if [ -n "$asset_id" ]; then
curl -fsS -X DELETE -H "$auth_header" "$FORGEJO_API/repos/${GITHUB_REPOSITORY}/releases/${release_id}/assets/${asset_id}"
fi
curl -fsS -X POST -H "$auth_header" -F "attachment=@${apk}" "$FORGEJO_API/repos/${GITHUB_REPOSITORY}/releases/${release_id}/assets?name=calorie-ai-${GITHUB_REF_NAME}.apk"
- name: Upload APK artifact - name: Upload APK artifact
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:

View file

@ -1,16 +1,17 @@
# Calorie AI # Calorie AI
Native Android and Dockerized web calorie tracker with uploaded meal images and admin-configurable AI models. Native Android and Dockerized web calorie tracker with meal images, AI nutrition estimates, server-backed plans, and admin-configurable AI models.
## Features ## Features
- Add meals by description, portion estimate, and uploaded image. - Add meals by description, portion estimate, and uploaded image.
- Analyze meals through OpenAI-compatible chat completions. - Analyze meals through OpenAI-compatible chat completions.
- Uses a vision model for food image calorie and portion estimates. - Uses a vision model for food image calorie and portion estimates.
- Uses a tasking model to normalize calories, protein, carbs, fat, fruit servings, vegetable servings, food groups, and notes. - Uses an advice model to normalize calories, protein, carbs, fat, fruit servings, vegetable servings, food groups, and notes.
- Stores daily meal entries locally on Android or in browser local storage. - Stores daily meal entries locally on Android or in browser local storage.
- Stores generated web weight-loss plans on the server in `web/data/plans.json`.
- Shows daily totals plus charts for macros, 7-day calories, and fruit/vegetable intake. - Shows daily totals plus charts for macros, 7-day calories, and fruit/vegetable intake.
- Admin settings control API base URL, API key, vision model, and tasking model. The two model fields can contain the same model name. - Admin settings control API base URL, API key, image model, and nutrition/advice model. The two model fields can contain the same model name.
## Android Build ## Android Build
@ -22,7 +23,30 @@ gradle :app:assembleDebug
Forgejo Actions builds the debug APK on every push to `main` and uploads it as the `calorie-ai-debug-apk` artifact. Forgejo Actions builds the debug APK on every push to `main` and uploads it as the `calorie-ai-debug-apk` artifact.
Tagged versions also build an installable APK named `calorie-ai-vX.Y.Z.apk` through the Android Release workflow. Tagged versions also build an installable APK named `calorie-ai-vX.Y.Z.apk` and attach it to a Forgejo release through the Android Release workflow.
## Install With Obtainium
Use the Forgejo releases page as the source:
```text
https://git.danvics.com/danvics/calorie-ai-android/releases
```
If Obtainium asks for a direct APK URL, use the latest release asset URL pattern:
```text
https://git.danvics.com/danvics/calorie-ai-android/releases/download/v0.1.1/calorie-ai-v0.1.1.apk
```
Recommended Obtainium setup:
- App source: `HTML` or `Gitea/Forgejo` if your Obtainium version offers it.
- URL: `https://git.danvics.com/danvics/calorie-ai-android/releases`
- APK link filter: `calorie-ai-.*\.apk`
- Version extraction: release tag, for example `v0.1.1`.
The APK is a debug build, so Android may require allowing installs from Obtainium and accepting the debug signing certificate.
## Web Frontend ## Web Frontend
@ -58,4 +82,4 @@ Example base URLs:
Default admin PIN is `admin`. Change it in the Admin AI Settings panel after first launch. Default admin PIN is `admin`. Change it in the Admin AI Settings panel after first launch.
The web frontend stores AI settings in browser local storage after web login. The web login is separate from the Android admin PIN. The web frontend stores AI settings in browser local storage after web login, while generated plan versions are stored server-side. The web login is separate from the Android admin PIN.

View file

@ -11,6 +11,7 @@ const publicRoot = publicDir + path.sep;
const dataDir = process.env.CALORIE_AI_DATA_DIR || path.join(__dirname, 'data'); const dataDir = process.env.CALORIE_AI_DATA_DIR || path.join(__dirname, 'data');
const authFile = path.join(dataDir, 'auth.json'); const authFile = path.join(dataDir, 'auth.json');
const modelsFile = path.join(dataDir, 'models.json'); const modelsFile = path.join(dataDir, 'models.json');
const plansFile = path.join(dataDir, 'plans.json');
const sessionCookieName = 'calorie_ai_session'; const sessionCookieName = 'calorie_ai_session';
const sessionSeconds = 60 * 60 * 24 * 7; const sessionSeconds = 60 * 60 * 24 * 7;
let auth; let auth;
@ -136,7 +137,66 @@ function tokenHash(token) {
return crypto.createHash('sha256').update(token).digest('hex'); return crypto.createHash('sha256').update(token).digest('hex');
} }
async function sendMail(to, subject, text) { function escHtml(value) {
return String(value || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function emailWrapper(body) {
const siteName = 'Calorie AI';
const font = '-apple-system,BlinkMacSystemFont,\'Segoe UI\',Helvetica,Arial,sans-serif';
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>${escHtml(siteName)}</title>
<style>
body,table,td,p,a{font-family:${font};}
@media only screen and (max-width:600px){.outer{padding:24px 16px !important;}}
</style>
</head>
<body style="margin:0;padding:0;background:#ffffff;-webkit-text-size-adjust:100%;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="outer" align="center" style="padding:48px 24px;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="max-width:520px;width:100%;">
<tr><td style="padding-bottom:32px;border-bottom:1px solid #e5e7eb;">
<p style="margin:0;font-size:14px;font-weight:600;color:#111827;letter-spacing:-0.1px;">${escHtml(siteName)}</p>
</td></tr>
<tr><td style="padding:32px 0;">${body}</td></tr>
<tr><td style="padding-top:24px;border-top:1px solid #e5e7eb;">
<p style="margin:0;font-size:12px;color:#9ca3af;line-height:1.7;">
${escHtml(siteName)} &mdash; meal tracking and weight-loss planning<br>
If you didn&rsquo;t request this, you can safely ignore it.
</p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>`;
}
function buttonHtml(href, label) {
return `<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:24px 0;">
<tr><td style="border-radius:5px;background:#111827;">
<a href="${escHtml(href)}" target="_blank" style="display:inline-block;color:#ffffff;padding:11px 22px;border-radius:5px;text-decoration:none;font-weight:500;font-size:14px;line-height:1;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;">${escHtml(label)}</a>
</td></tr>
</table>`;
}
function linkFallback(url) {
return `<p style="margin:16px 0 4px;font-size:13px;color:#6b7280;line-height:1.5;">If the button doesn&rsquo;t work, copy this link:</p>
<p style="margin:0;padding:10px 12px;background:#f9fafb;border:1px solid #e5e7eb;border-radius:4px;font-size:12px;font-family:'SFMono-Regular',Consolas,'Liberation Mono',Menlo,monospace;word-break:break-all;color:#374151;line-height:1.6;">${escHtml(url)}</p>`;
}
function actionEmailHtml(title, body, link, label) {
return emailWrapper(`<p style="margin:0 0 8px;font-size:20px;font-weight:600;color:#111827;">${escHtml(title)}</p>
<p style="color:#4b5563;margin:12px 0 20px;line-height:1.6;font-size:14px;">${escHtml(body).replace(/\n/g, '<br>')}</p>
${buttonHtml(link, label)}
${linkFallback(link)}`);
}
async function sendMail(to, subject, text, html = '') {
const config = mailConfig(); const config = mailConfig();
if (!config.configured) throw new Error('SMTP is not configured'); if (!config.configured) throw new Error('SMTP is not configured');
const transporter = nodemailer.createTransport({ const transporter = nodemailer.createTransport({
@ -145,7 +205,7 @@ async function sendMail(to, subject, text) {
secure: config.secure, secure: config.secure,
auth: config.user || config.pass ? { user: config.user, pass: config.pass } : undefined, auth: config.user || config.pass ? { user: config.user, pass: config.pass } : undefined,
}); });
await transporter.sendMail({ from: config.from, to, subject, text }); await transporter.sendMail({ from: config.from, to, subject, text, html: html || undefined });
} }
function loadModelConfig() { function loadModelConfig() {
@ -163,6 +223,58 @@ function saveModelConfig(config = modelConfig) {
writePrivateJson(modelsFile, { ...config, updatedAt: new Date().toISOString() }); writePrivateJson(modelsFile, { ...config, updatedAt: new Date().toISOString() });
} }
function loadPlanConfig() {
const stored = readJsonFile(plansFile, {});
return {
plans: Array.isArray(stored.plans) ? stored.plans : [],
selectedPlanId: stored.selectedPlanId || '',
};
}
function savePlanConfig(config) {
writePrivateJson(plansFile, { ...config, updatedAt: new Date().toISOString() });
}
async function getPlans(req, res) {
send(res, 200, JSON.stringify(loadPlanConfig()));
}
async function createPlan(req, res) {
const payload = JSON.parse(await readBody(req) || '{}');
if (!payload.content) return send(res, 400, JSON.stringify({ error: 'Plan content required' }));
const config = loadPlanConfig();
const plan = {
id: payload.id || crypto.randomUUID(),
title: String(payload.title || `Weight-loss plan ${config.plans.length + 1}`),
version: Number(payload.version || Math.max(0, ...config.plans.map(item => Number(item.version || 0))) + 1),
content: String(payload.content),
previousPlanId: payload.previousPlanId || undefined,
createdAt: payload.createdAt || new Date().toISOString(),
};
config.plans = [plan, ...config.plans];
config.selectedPlanId = plan.id;
savePlanConfig(config);
send(res, 200, JSON.stringify(config));
}
async function selectPlanRoute(req, res) {
const payload = JSON.parse(await readBody(req) || '{}');
const config = loadPlanConfig();
if (payload.id && !config.plans.some(plan => plan.id === payload.id)) return send(res, 404, JSON.stringify({ error: 'Plan not found' }));
config.selectedPlanId = payload.id || '';
savePlanConfig(config);
send(res, 200, JSON.stringify(config));
}
async function deletePlanRoute(req, res) {
const payload = JSON.parse(await readBody(req) || '{}');
const config = loadPlanConfig();
config.plans = config.plans.filter(plan => plan.id !== payload.id);
if (config.selectedPlanId === payload.id) config.selectedPlanId = config.plans[0]?.id || '';
savePlanConfig(config);
send(res, 200, JSON.stringify(config));
}
function dedupeModels(models) { function dedupeModels(models) {
return Array.from(new Map((models || []).filter(model => model && model.id).map(model => [model.id, { return Array.from(new Map((models || []).filter(model => model && model.id).map(model => [model.id, {
id: String(model.id), id: String(model.id),
@ -380,7 +492,7 @@ async function changePassword(req, res) {
try { try {
const payload = JSON.parse(await readBody(req)); const payload = JSON.parse(await readBody(req));
if (!await argon2.verify(auth.passwordHash, payload.currentPassword || '')) return send(res, 401, JSON.stringify({ error: 'Current password is incorrect' })); if (!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' })); if (!payload.newPassword || payload.newPassword.length < 8) return send(res, 400, JSON.stringify({ error: 'Use at least 8 characters for the new password' }));
auth.passwordHash = await hashPassword(payload.newPassword); auth.passwordHash = await hashPassword(payload.newPassword);
saveAuthConfig({ passwordHash: auth.passwordHash, resetTokenHash: undefined, resetExpiresAt: undefined }); saveAuthConfig({ passwordHash: auth.passwordHash, resetTokenHash: undefined, resetExpiresAt: undefined });
send(res, 200, JSON.stringify({ ok: true })); send(res, 200, JSON.stringify({ ok: true }));
@ -395,7 +507,9 @@ async function resetPassword(req, res) {
const token = crypto.randomBytes(32).toString('base64url'); const token = crypto.randomBytes(32).toString('base64url');
saveAuthConfig({ resetTokenHash: tokenHash(token), resetExpiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString() }); saveAuthConfig({ resetTokenHash: tokenHash(token), resetExpiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString() });
const link = `${appOrigin(req)}/reset?token=${encodeURIComponent(token)}`; 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}`); const text = `Use this link to reset your Calorie AI password. It expires in 1 hour.\n\n${link}`;
const html = actionEmailHtml('Password reset request', 'Someone requested a password reset for your Calorie AI account. If that was you, click the button below to choose a new password. This link expires in 1 hour. If you did not request this, no action is needed.', link, 'Reset My Password');
await sendMail(auth.email, 'Reset your Calorie AI password', text, html);
send(res, 200, JSON.stringify({ ok: true, message: 'Reset email sent.' })); send(res, 200, JSON.stringify({ ok: true, message: 'Reset email sent.' }));
} catch (error) { } catch (error) {
send(res, 400, JSON.stringify({ error: error.message })); send(res, 400, JSON.stringify({ error: error.message }));
@ -413,7 +527,9 @@ async function requestPasswordReset(req, res) {
const token = crypto.randomBytes(32).toString('base64url'); const token = crypto.randomBytes(32).toString('base64url');
saveAuthConfig({ resetTokenHash: tokenHash(token), resetExpiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString() }); saveAuthConfig({ resetTokenHash: tokenHash(token), resetExpiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString() });
const link = `${appOrigin(req)}/reset?token=${encodeURIComponent(token)}`; 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}`); const text = `Use this link to reset your Calorie AI password. It expires in 1 hour.\n\n${link}`;
const html = actionEmailHtml('Password reset request', 'Someone requested a password reset for your Calorie AI account. If that was you, click the button below to choose a new password. This link expires in 1 hour. If you did not request this, no action is needed.', link, 'Reset My Password');
await sendMail(auth.email, 'Reset your Calorie AI password', text, html);
send(res, 200, JSON.stringify({ ok: true, message: 'If the account exists, a reset email was sent.' })); send(res, 200, JSON.stringify({ ok: true, message: 'If the account exists, a reset email was sent.' }));
} catch (error) { } catch (error) {
send(res, 400, JSON.stringify({ error: error.message })); send(res, 400, JSON.stringify({ error: error.message }));
@ -423,7 +539,7 @@ async function requestPasswordReset(req, res) {
async function confirmPasswordReset(req, res) { async function confirmPasswordReset(req, res) {
try { try {
const payload = JSON.parse(await readBody(req) || '{}'); 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' })); if (!payload.newPassword || payload.newPassword.length < 8) return send(res, 400, JSON.stringify({ error: 'Use at least 8 characters for the new password' }));
const valid = auth.resetTokenHash && auth.resetExpiresAt && new Date(auth.resetExpiresAt).getTime() > Date.now() && safeEqual(auth.resetTokenHash, tokenHash(payload.token || '')); 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' })); if (!valid) return send(res, 400, JSON.stringify({ error: 'Reset link is invalid or expired' }));
const passwordHash = await hashPassword(payload.newPassword); const passwordHash = await hashPassword(payload.newPassword);
@ -452,7 +568,9 @@ async function sendVerification(req, res) {
const token = crypto.randomBytes(32).toString('base64url'); const token = crypto.randomBytes(32).toString('base64url');
saveAuthConfig({ verificationTokenHash: tokenHash(token), verificationExpiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString() }); saveAuthConfig({ verificationTokenHash: tokenHash(token), verificationExpiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString() });
const link = `${appOrigin(req)}/verify?token=${encodeURIComponent(token)}`; 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}`); const text = `Use this link to verify your Calorie AI account email. It expires in 24 hours.\n\n${link}`;
const html = actionEmailHtml('Verify your email', 'Please verify your Calorie AI account email by clicking the button below. This link expires in 24 hours.', link, 'Verify My Email');
await sendMail(auth.email, 'Verify your Calorie AI account', text, html);
send(res, 200, JSON.stringify({ ok: true, message: 'Verification email sent.' })); send(res, 200, JSON.stringify({ ok: true, message: 'Verification email sent.' }));
} catch (error) { } catch (error) {
send(res, 400, JSON.stringify({ error: error.message })); send(res, 400, JSON.stringify({ error: error.message }));
@ -536,6 +654,10 @@ async function start() {
app.post('/api/models/search', wrap(searchModels)); app.post('/api/models/search', wrap(searchModels));
app.post('/api/models/add', wrap(addModel)); app.post('/api/models/add', wrap(addModel));
app.post('/api/models/remove', wrap(removeModel)); app.post('/api/models/remove', wrap(removeModel));
app.get('/api/plans', wrap(getPlans));
app.post('/api/plans', wrap(createPlan));
app.post('/api/plans/select', wrap(selectPlanRoute));
app.post('/api/plans/delete', wrap(deletePlanRoute));
app.post('/api/chat', wrap(proxyChat)); app.post('/api/chat', wrap(proxyChat));
app.use((req, res) => { app.use((req, res) => {
if (req.method === 'GET' || req.method === 'HEAD') return serveStatic(req, res); if (req.method === 'GET' || req.method === 'HEAD') return serveStatic(req, res);

View file

@ -10,8 +10,6 @@
const entriesKey = 'calorie-ai-web-entries'; const entriesKey = 'calorie-ai-web-entries';
const trashKey = 'calorie-ai-web-trash'; const trashKey = 'calorie-ai-web-trash';
const settingsKey = 'calorie-ai-web-settings'; const settingsKey = 'calorie-ai-web-settings';
const plansKey = 'calorie-ai-web-plans';
const selectedPlanKey = 'calorie-ai-web-selected-plan';
const pages = [ const pages = [
{ id: 'dashboard', label: 'Dashboard', icon: '⌂', group: 'Tracker' }, { id: 'dashboard', label: 'Dashboard', icon: '⌂', group: 'Tracker' },
{ id: 'log', label: 'Log meal', icon: '+', group: 'Tracker' }, { id: 'log', label: 'Log meal', icon: '+', group: 'Tracker' },
@ -115,14 +113,13 @@
onMount(async () => { onMount(async () => {
entries = readJson(entriesKey, []); entries = readJson(entriesKey, []);
trash = readJson(trashKey, []); trash = readJson(trashKey, []);
plans = readJson(plansKey, []);
selectedPlanId = localStorage.getItem(selectedPlanKey) || plans[0]?.id || '';
settings = { ...settings, ...readJson(settingsKey, settings) }; settings = { ...settings, ...readJson(settingsKey, settings) };
await loadSession(); await loadSession();
if (location.pathname === '/verify' && resetToken) await verifyAccountToken(); if (location.pathname === '/verify' && resetToken) await verifyAccountToken();
page = localStorage.getItem('calorie_ai_last_tab') || 'dashboard'; page = localStorage.getItem('calorie_ai_last_tab') || 'dashboard';
setDefaultMealDateTime(); setDefaultMealDateTime();
await loadModels(); await loadModels();
await loadPlans();
await drawCharts(); await drawCharts();
}); });
@ -190,11 +187,6 @@
localStorage.setItem('calorie_ai_last_tab', id); localStorage.setItem('calorie_ai_last_tab', id);
} }
function toggleMenu() {
if (window.matchMedia('(min-width: 1024px)').matches) sidebarCollapsed = !sidebarCollapsed;
else menuOpen = true;
}
async function login() { async function login() {
loginStatus = 'Checking credentials...'; loginStatus = 'Checking credentials...';
loginError = false; loginError = false;
@ -296,12 +288,26 @@
saveJson(settingsKey, settings); saveJson(settingsKey, settings);
} }
function savePlans(nextPlans = plans, nextSelectedPlanId = selectedPlanId) { function applyPlanConfig(config) {
plans = nextPlans; plans = config.plans || [];
selectedPlanId = nextSelectedPlanId || plans[0]?.id || ''; selectedPlanId = config.selectedPlanId || plans[0]?.id || '';
saveJson(plansKey, plans); }
if (selectedPlanId) localStorage.setItem(selectedPlanKey, selectedPlanId);
else localStorage.removeItem(selectedPlanKey); async function loadPlans() {
try {
const response = await fetch('/api/plans');
if (response.ok) applyPlanConfig(await response.json());
} catch {}
}
async function savePlan(plan) {
const response = await fetch('/api/plans', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(plan),
});
if (!response.ok) throw new Error((await response.json().catch(() => ({}))).error || 'Plan save failed.');
applyPlanConfig(await response.json());
} }
function emptyEstimate() { function emptyEstimate() {
@ -668,7 +674,7 @@ Image estimate: ${imageEstimate}` },
], ],
}); });
const plan = { id: entryId(), title: `Weight-loss plan ${plans.length + 1}`, version: plans.length + 1, content, createdAt: new Date().toISOString() }; 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); await savePlan(plan);
planStatus = 'Plan generated.'; planStatus = 'Plan generated.';
saveSettings(); saveSettings();
} catch (error) { } catch (error) {
@ -701,7 +707,7 @@ Image estimate: ${imageEstimate}` },
}); });
const version = Math.max(0, ...plans.map(plan => Number(plan.version || 0))) + 1; 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 }; const updatedPlan = { id: entryId(), title: `Updated plan ${version}`, version, content, createdAt: new Date().toISOString(), previousPlanId: currentPlan.id };
savePlans([updatedPlan, ...plans], updatedPlan.id); await savePlan(updatedPlan);
tuneNote = ''; tuneNote = '';
planStatus = 'Plan updated.'; planStatus = 'Plan updated.';
} catch (error) { } catch (error) {
@ -712,14 +718,23 @@ Image estimate: ${imageEstimate}` },
} }
} }
function selectPlan(planId) { async function selectPlan(planId) {
selectedPlanId = planId; selectedPlanId = planId;
localStorage.setItem(selectedPlanKey, planId); const response = await fetch('/api/plans/select', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id: planId }),
});
if (response.ok) applyPlanConfig(await response.json());
} }
function deletePlan(planId) { async function deletePlan(planId) {
const nextPlans = plans.filter(plan => plan.id !== planId); const response = await fetch('/api/plans/delete', {
savePlans(nextPlans, selectedPlanId === planId ? nextPlans[0]?.id || '' : selectedPlanId); method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id: planId }),
});
if (response.ok) applyPlanConfig(await response.json());
} }
async function drawCharts() { async function drawCharts() {
@ -808,20 +823,21 @@ Image estimate: ${imageEstimate}` },
<div class="mb-6 flex h-12 w-12 items-center justify-center rounded-2xl bg-blue-600 font-black text-white">CA</div> <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> <h2 class="text-2xl font-bold">Reset password</h2>
<label class="mt-6 block text-sm font-semibold text-slate-700">New password <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"> <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="8">
</label> </label>
<label class="mt-4 block text-sm font-semibold text-slate-700">Confirm password <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"> <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="8">
</label> </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> <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} {#if resetStatus}<p class:!text-red-600={resetError} class="mt-4 text-sm font-semibold text-blue-700">{resetStatus}</p>{/if}
{#if resetStatus && !resetError}<a class="mt-4 inline-block w-full rounded-xl border border-slate-300 px-4 py-3 text-center font-bold text-slate-700 hover:border-blue-500 hover:text-blue-700" href="/login">Go to sign in</a>{/if}
</form> </form>
{:else if location.pathname === '/verify'} {:else if location.pathname === '/verify'}
<section class="rounded-3xl border border-slate-200 bg-white p-8 shadow-xl lg:p-10"> <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> <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> <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} {#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> <a class="mt-6 inline-block w-full rounded-xl bg-blue-600 px-4 py-3 text-center font-bold text-white hover:bg-blue-700" href="/login">Go to sign in</a>
</section> </section>
{:else} {:else}
<form class="rounded-3xl border border-slate-200 bg-white p-8 shadow-xl lg:p-10" on:submit|preventDefault={login}> <form class="rounded-3xl border border-slate-200 bg-white p-8 shadow-xl lg:p-10" on:submit|preventDefault={login}>
@ -848,28 +864,18 @@ Image estimate: ${imageEstimate}` },
</div> </div>
</main> </main>
{:else} {:else}
<div class="min-h-screen bg-slate-50 text-slate-900"> <div class="min-h-screen bg-slate-50 text-slate-900 lg:grid lg:grid-cols-[auto_1fr]">
<header class="sticky top-0 z-30 bg-gradient-to-r from-blue-600 to-indigo-700 text-white shadow-sm"> <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-0 lg:h-screen lg:translate-x-0 lg:shadow-none lg:w-60">
<div class="flex items-center justify-between gap-3 px-4 py-3 lg:px-6"> <div class="sidebar-header flex h-[68px] items-center justify-between border-b border-slate-200 px-4">
<div class="flex min-w-0 items-center gap-3"> <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 hover:bg-white/25" type="button" on:click={toggleMenu} aria-label="Toggle menu"></button> <div class="sidebar-logo grid h-11 w-11 flex-none place-items-center rounded-2xl bg-blue-600 text-sm font-black text-white">CA</div>
<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="sidebar-brand min-w-0">
<div class="min-w-0"> <h1 class="truncate text-lg font-black text-slate-950">Calorie AI</h1>
<h1 class="truncate text-lg font-bold">Calorie AI</h1> <p class="text-xs font-semibold text-slate-500">Food diary</p>
<p class="text-xs text-blue-100">Food diary</p> </div>
</div> </div>
</div> <button class="collapse-button hidden h-10 w-10 place-items-center rounded-xl bg-slate-100 text-slate-700 hover:bg-slate-200 lg:grid" type="button" on:click={() => sidebarCollapsed = !sidebarCollapsed} aria-label="Collapse menu"></button>
<div class="flex items-center gap-2"> <button class="rounded-lg bg-slate-100 px-3 py-1.5 text-sm font-bold lg:hidden" type="button" on:click={() => menuOpen = false}>Close</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: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>
<nav class="p-2" aria-label="Main menu"> <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} {#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}
@ -877,8 +883,21 @@ Image estimate: ${imageEstimate}` },
{#if adminPages.length}<div class="sidebar-section-label px-3 py-2 text-xs font-bold uppercase tracking-wide text-slate-400">Admin</div>{/if} {#if adminPages.length}<div class="sidebar-section-label px-3 py-2 text-xs font-bold uppercase tracking-wide text-slate-400">Admin</div>{/if}
{#each adminPages as item}<button class:active-tab={page === item.id} class="tab-button" type="button" title={item.label} on:click={() => selectPage(item.id)}><span class="tab-icon">{item.icon}</span><span class="tab-label">{item.label}</span></button>{/each} {#each adminPages as item}<button class:active-tab={page === item.id} class="tab-button" type="button" title={item.label} on:click={() => selectPage(item.id)}><span class="tab-icon">{item.icon}</span><span class="tab-label">{item.label}</span></button>{/each}
</nav> </nav>
</aside> </aside>
{#if menuOpen}<button class="fixed inset-0 z-30 bg-slate-950/40 lg:hidden" type="button" aria-label="Close menu" on:click={() => menuOpen = false}></button>{/if} {#if menuOpen}<button class="fixed inset-0 z-30 bg-slate-950/40 lg:hidden" type="button" aria-label="Close menu" on:click={() => menuOpen = false}></button>{/if}
<div class="min-w-0">
<header class="sticky top-0 z-20 border-b border-slate-200 bg-white/95 shadow-sm backdrop-blur">
<div class="flex h-[68px] items-center justify-between gap-3 px-4 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-blue-600 text-white hover:bg-blue-700 lg:hidden" type="button" on:click={() => menuOpen = true} aria-label="Open menu"></button>
<div class="min-w-0">
<div class="truncate text-lg font-black text-slate-950">{pages.find(item => item.id === page)?.label || 'Dashboard'}</div>
</div>
</div>
<button class="rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm font-bold text-slate-700 hover:border-blue-300 hover:text-blue-700" type="button" on:click={logout}>Sign out</button>
</div>
</header>
<main class="min-w-0 flex-1 p-4 lg:p-6"> <main class="min-w-0 flex-1 p-4 lg:p-6">
{#if page === 'dashboard'} {#if page === 'dashboard'}
@ -1002,6 +1021,8 @@ Image estimate: ${imageEstimate}` },
.active-tab { border-left-color: #2563eb; background: #dbeafe; color: #2563eb; } .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; } .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) { width: 4.75rem !important; }
:global(.collapsed-sidebar .tab-label), :global(.collapsed-sidebar .sidebar-section-label) { display: none; } :global(.collapsed-sidebar .sidebar-header) { justify-content: center; padding-left: 0; padding-right: 0; }
:global(.collapsed-sidebar .tab-label), :global(.collapsed-sidebar .sidebar-section-label), :global(.collapsed-sidebar .sidebar-brand), :global(.collapsed-sidebar .sidebar-logo) { display: none; }
:global(.collapsed-sidebar .tab-button) { justify-content: center; padding-left: 0.75rem; padding-right: 0.75rem; } :global(.collapsed-sidebar .tab-button) { justify-content: center; padding-left: 0.75rem; padding-right: 0.75rem; }
:global(.collapsed-sidebar .collapse-button) { height: 2.5rem; width: 2.5rem; border-radius: 0.75rem; }
</style> </style>

View file

@ -22,7 +22,6 @@
<section class="space-y-4"> <section class="space-y-4">
<div> <div>
<h2 class="text-xl font-bold">{editingEntryId ? 'Edit meal' : 'Log meal'}</h2> <h2 class="text-xl font-bold">{editingEntryId ? 'Edit meal' : 'Log meal'}</h2>
<p class="text-sm text-slate-500">Backfill meals for any date and time. After analysis, edit the values or run analysis again.</p>
</div> </div>
<form class="card" on:submit|preventDefault={saveMeal}> <form class="card" on:submit|preventDefault={saveMeal}>
<h3 class="card-title">Meal details</h3> <h3 class="card-title">Meal details</h3>
@ -32,7 +31,7 @@
<Field label="Meal type"><select id="mealType" class="input" bind:value={mealType} required>{#each mealTypes as type}<option>{type}</option>{/each}</select></Field> <Field label="Meal type"><select id="mealType" class="input" bind:value={mealType} required>{#each mealTypes as type}<option>{type}</option>{/each}</select></Field>
<Field className="md:col-span-3" label="Description"><textarea id="description" class="input min-h-32" bind:value={description} placeholder="Example: grilled salmon, rice, broccoli, berries"></textarea></Field> <Field className="md:col-span-3" label="Description"><textarea id="description" class="input min-h-32" bind:value={description} placeholder="Example: grilled salmon, rice, broccoli, berries"></textarea></Field>
<Field label="Portion or measure"><input id="measure" class="input" bind:value={measure} placeholder="Example: one plate, 450g, 2 cups"></Field> <Field label="Portion or measure"><input id="measure" class="input" bind:value={measure} placeholder="Example: one plate, 450g, 2 cups"></Field>
<Field label="Meal image or camera"><input id="image" class="input" type="file" accept="image/*" capture="environment" on:change={chooseImage}></Field> <Field label="Meal image"><input id="image" class="input" type="file" accept="image/*" on:change={chooseImage}></Field>
</div> </div>
{#if selectedImageDataUrl}<img id="preview" class="mx-4 mb-4 max-h-80 w-[calc(100%-2rem)] rounded-2xl object-cover" src={selectedImageDataUrl} alt="Selected meal preview">{/if} {#if selectedImageDataUrl}<img id="preview" class="mx-4 mb-4 max-h-80 w-[calc(100%-2rem)] rounded-2xl object-cover" src={selectedImageDataUrl} alt="Selected meal preview">{/if}
{#if editingEntryId} {#if editingEntryId}

View file

@ -46,8 +46,8 @@
<h3 class="card-title">Password</h3> <h3 class="card-title">Password</h3>
<form class="grid gap-3 p-4" on:submit|preventDefault={changePassword}> <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="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="New password"><input id="newPassword" class="input" type="password" bind:value={newPassword} autocomplete="new-password" minlength="8"></Field>
<Field label="Confirm new password"><input id="confirmPassword" class="input" type="password" bind:value={confirmPassword} 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="8"></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}>Email reset link</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 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} {#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}
@ -73,7 +73,7 @@
<label class="mb-2 block text-sm font-bold text-slate-700" for="modelSearch">Search provider models</label> <label class="mb-2 block text-sm font-bold text-slate-700" for="modelSearch">Search provider models</label>
<div class="flex flex-col gap-2 sm:flex-row"><input id="modelSearch" class="input" bind:value={modelSearch} placeholder="Search current models, e.g. claude, gemini, gpt" on:keydown={(event) => event.key === 'Enter' && searchModels()}><button class="btn-primary sm:w-36" type="button" on:click={searchModels} disabled={modelSearching}>{modelSearching ? 'Searching...' : 'Search'}</button></div> <div class="flex flex-col gap-2 sm:flex-row"><input id="modelSearch" class="input" bind:value={modelSearch} placeholder="Search current models, e.g. claude, gemini, gpt" on:keydown={(event) => event.key === 'Enter' && searchModels()}><button class="btn-primary sm:w-36" type="button" on:click={searchModels} disabled={modelSearching}>{modelSearching ? 'Searching...' : 'Search'}</button></div>
<div class="mt-3 max-h-72 overflow-y-auto rounded-xl border border-slate-200 bg-white p-2"> <div class="mt-3 max-h-72 overflow-y-auto rounded-xl border border-slate-200 bg-white p-2">
{#if discoveredModels.length}{#each discoveredModels as model}<div class="mb-2 flex items-center gap-2 rounded-lg border border-slate-100 px-3 py-2 text-sm"><button class="rounded-md bg-blue-600 px-2.5 py-1 text-xs font-black text-white hover:bg-blue-700" type="button" on:click={() => addModel(model)}>+</button><span class="min-w-0 flex-1 truncate"><strong>{model.name}</strong> <span class="text-slate-400">{model.id}</span></span></div>{/each}{:else}<p class="px-2 py-6 text-center text-sm text-slate-500">Search to query the current provider. Results stay in this scroll panel.</p>{/if} {#each discoveredModels as model}<div class="mb-2 flex items-center gap-2 rounded-lg border border-slate-100 px-3 py-2 text-sm"><button class="rounded-md bg-blue-600 px-2.5 py-1 text-xs font-black text-white hover:bg-blue-700" type="button" on:click={() => addModel(model)}>+</button><span class="min-w-0 flex-1 truncate"><strong>{model.name}</strong> <span class="text-slate-400">{model.id}</span></span></div>{/each}
</div> </div>
</div> </div>
</div> </div>

View file

@ -50,8 +50,33 @@ async function mockModels(page) {
}); });
} }
async function mockPlans(page) {
let plans = [];
let selectedPlanId = '';
await page.route('**/api/plans', async (route) => {
if (route.request().method() === 'GET') {
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ plans, selectedPlanId }) });
}
const plan = route.request().postDataJSON();
plans = [plan, ...plans];
selectedPlanId = plan.id;
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ plans, selectedPlanId }) });
});
await page.route('**/api/plans/select', async (route) => {
selectedPlanId = route.request().postDataJSON().id || '';
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ plans, selectedPlanId }) });
});
await page.route('**/api/plans/delete', async (route) => {
const id = route.request().postDataJSON().id;
plans = plans.filter(plan => plan.id !== id);
if (selectedPlanId === id) selectedPlanId = plans[0]?.id || '';
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ plans, selectedPlanId }) });
});
}
async function login(page) { async function login(page) {
await mockModels(page); await mockModels(page);
await mockPlans(page);
await page.goto('/'); await page.goto('/');
await expect(page).toHaveURL(/\/login$/); await expect(page).toHaveURL(/\/login$/);
await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible();