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
permissions:
contents: write
on:
push:
tags:
@ -31,6 +34,25 @@ jobs:
mkdir -p dist
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
uses: actions/upload-artifact@v3
with:

View file

@ -1,16 +1,17 @@
# 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
- Add meals by description, portion estimate, and uploaded image.
- Analyze meals through OpenAI-compatible chat completions.
- 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 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.
- 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
@ -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.
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
@ -58,4 +82,4 @@ Example base URLs:
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 authFile = path.join(dataDir, 'auth.json');
const modelsFile = path.join(dataDir, 'models.json');
const plansFile = path.join(dataDir, 'plans.json');
const sessionCookieName = 'calorie_ai_session';
const sessionSeconds = 60 * 60 * 24 * 7;
let auth;
@ -136,7 +137,66 @@ function tokenHash(token) {
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();
if (!config.configured) throw new Error('SMTP is not configured');
const transporter = nodemailer.createTransport({
@ -145,7 +205,7 @@ async function sendMail(to, subject, text) {
secure: config.secure,
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() {
@ -163,6 +223,58 @@ function saveModelConfig(config = modelConfig) {
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) {
return Array.from(new Map((models || []).filter(model => model && model.id).map(model => [model.id, {
id: String(model.id),
@ -380,7 +492,7 @@ async function changePassword(req, res) {
try {
const payload = JSON.parse(await readBody(req));
if (!await argon2.verify(auth.passwordHash, payload.currentPassword || '')) return send(res, 401, JSON.stringify({ error: 'Current password is incorrect' }));
if (!payload.newPassword || payload.newPassword.length < 12) return send(res, 400, JSON.stringify({ error: 'Use at least 12 characters for the new password' }));
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);
saveAuthConfig({ passwordHash: auth.passwordHash, resetTokenHash: undefined, resetExpiresAt: undefined });
send(res, 200, JSON.stringify({ ok: true }));
@ -395,7 +507,9 @@ async function resetPassword(req, res) {
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}`);
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.' }));
} catch (error) {
send(res, 400, JSON.stringify({ error: error.message }));
@ -413,7 +527,9 @@ async function requestPasswordReset(req, res) {
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}`);
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.' }));
} catch (error) {
send(res, 400, JSON.stringify({ error: error.message }));
@ -423,7 +539,7 @@ async function requestPasswordReset(req, res) {
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' }));
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 || ''));
if (!valid) return send(res, 400, JSON.stringify({ error: 'Reset link is invalid or expired' }));
const passwordHash = await hashPassword(payload.newPassword);
@ -452,7 +568,9 @@ async function sendVerification(req, res) {
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}`);
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.' }));
} catch (error) {
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/add', wrap(addModel));
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.use((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 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' },
@ -115,14 +113,13 @@
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();
await loadPlans();
await drawCharts();
});
@ -190,11 +187,6 @@
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;
@ -296,12 +288,26 @@
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 applyPlanConfig(config) {
plans = config.plans || [];
selectedPlanId = config.selectedPlanId || plans[0]?.id || '';
}
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() {
@ -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() };
savePlans([plan, ...plans], plan.id);
await savePlan(plan);
planStatus = 'Plan generated.';
saveSettings();
} catch (error) {
@ -701,7 +707,7 @@ Image estimate: ${imageEstimate}` },
});
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);
await savePlan(updatedPlan);
tuneNote = '';
planStatus = 'Plan updated.';
} catch (error) {
@ -712,14 +718,23 @@ Image estimate: ${imageEstimate}` },
}
}
function selectPlan(planId) {
async function selectPlan(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) {
const nextPlans = plans.filter(plan => plan.id !== planId);
savePlans(nextPlans, selectedPlanId === planId ? nextPlans[0]?.id || '' : selectedPlanId);
async function deletePlan(planId) {
const response = await fetch('/api/plans/delete', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id: planId }),
});
if (response.ok) applyPlanConfig(await response.json());
}
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>
<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">
<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 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>
<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 && !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>
{: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>
<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>
{:else}
<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>
</main>
{:else}
<div class="min-h-screen bg-slate-50 text-slate-900">
<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 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>
<p class="text-xs text-blue-100">Food diary</p>
<div class="min-h-screen bg-slate-50 text-slate-900 lg:grid lg:grid-cols-[auto_1fr]">
<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="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="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="sidebar-brand min-w-0">
<h1 class="truncate text-lg font-black text-slate-950">Calorie AI</h1>
<p class="text-xs font-semibold text-slate-500">Food diary</p>
</div>
</div>
</div>
<div class="flex items-center gap-2">
<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>
<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>
<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>
</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}
@ -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}
{#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>
</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}
</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}
<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">
{#if page === 'dashboard'}
@ -1002,6 +1021,8 @@ Image estimate: ${imageEstimate}` },
.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 .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 .collapse-button) { height: 2.5rem; width: 2.5rem; border-radius: 0.75rem; }
</style>

View file

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

View file

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

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) {
await mockModels(page);
await mockPlans(page);
await page.goto('/');
await expect(page).toHaveURL(/\/login$/);
await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible();