Add web auth and app navigation
This commit is contained in:
parent
71c8837b48
commit
f57c04c989
14 changed files with 695 additions and 188 deletions
|
|
@ -24,18 +24,11 @@ jobs:
|
|||
npm install
|
||||
npx playwright install --with-deps chromium
|
||||
|
||||
- name: Start web app
|
||||
run: |
|
||||
cd web
|
||||
PORT=8095 node server.js > /tmp/calorie-ai-web.log 2>&1 &
|
||||
for i in $(seq 1 30); do
|
||||
curl -fsS http://127.0.0.1:8095 >/dev/null && exit 0
|
||||
sleep 1
|
||||
done
|
||||
cat /tmp/calorie-ai-web.log
|
||||
exit 1
|
||||
|
||||
- name: Run Playwright tests
|
||||
run: |
|
||||
cd web
|
||||
CI=true \
|
||||
CALORIE_AI_WEB_USER=admin \
|
||||
CALORIE_AI_WEB_PASSWORD=test-password \
|
||||
CALORIE_AI_SESSION_SECRET=test-session-secret \
|
||||
npm test
|
||||
|
|
|
|||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -8,3 +8,5 @@ local.properties
|
|||
web/node_modules/
|
||||
web/test-results/
|
||||
web/playwright-report/
|
||||
web/data/
|
||||
web/.env
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ Tagged versions also build an installable APK named `calorie-ai-vX.Y.Z.apk` thro
|
|||
|
||||
## Web Frontend
|
||||
|
||||
The web frontend lives in `web/`. It serves a browser UI and a tiny Node proxy at `/api/chat` so OpenAI-compatible endpoints are called server-side instead of directly from the browser.
|
||||
The web frontend lives in `web/`. It serves an authenticated browser UI and a tiny Node proxy at `/api/chat` so OpenAI-compatible endpoints are called server-side instead of directly from the browser.
|
||||
|
||||
Run it with Docker:
|
||||
|
||||
|
|
@ -41,6 +41,8 @@ Then open:
|
|||
http://127.0.0.1:8095
|
||||
```
|
||||
|
||||
The Docker web server creates credentials on first boot in `web/data/auth.json` if `CALORIE_AI_WEB_PASSWORD` and `CALORIE_AI_SESSION_SECRET` are not supplied. To pin credentials, copy `web/.env.example` to `web/.env` and set strong values before starting Docker Compose.
|
||||
|
||||
## AI Endpoint
|
||||
|
||||
The app expects an OpenAI-compatible endpoint at:
|
||||
|
|
@ -56,4 +58,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 and does not require a PIN by default.
|
||||
The web frontend stores AI settings in browser local storage after web login. The web login is separate from the Android admin PIN.
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
node_modules
|
||||
npm-debug.log
|
||||
.DS_Store
|
||||
data
|
||||
.env
|
||||
|
|
|
|||
3
web/.env.example
Normal file
3
web/.env.example
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
CALORIE_AI_WEB_USER=admin
|
||||
CALORIE_AI_WEB_PASSWORD=change-this-password
|
||||
CALORIE_AI_SESSION_SECRET=change-this-session-secret
|
||||
|
|
@ -5,3 +5,5 @@ services:
|
|||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:8095:8080"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
|
|
|
|||
|
|
@ -3,7 +3,18 @@ const { defineConfig } = require('@playwright/test');
|
|||
module.exports = defineConfig({
|
||||
testDir: './tests',
|
||||
timeout: 30000,
|
||||
webServer: {
|
||||
command: 'node server.js',
|
||||
url: 'http://127.0.0.1:8096',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
env: {
|
||||
PORT: '8096',
|
||||
CALORIE_AI_WEB_USER: 'admin',
|
||||
CALORIE_AI_WEB_PASSWORD: 'test-password',
|
||||
CALORIE_AI_SESSION_SECRET: 'test-session-secret',
|
||||
},
|
||||
},
|
||||
use: {
|
||||
baseURL: 'http://127.0.0.1:8095',
|
||||
baseURL: 'http://127.0.0.1:8096',
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,6 +5,11 @@ const entriesKey = 'calorie-ai-web-entries';
|
|||
let selectedImageDataUrl = '';
|
||||
let selectedImageName = '';
|
||||
|
||||
function setText(id, value) {
|
||||
const element = $(id);
|
||||
if (element) element.textContent = value;
|
||||
}
|
||||
|
||||
function todayKey() {
|
||||
return dateKey(new Date());
|
||||
}
|
||||
|
|
@ -147,14 +152,14 @@ function drawMacroChart(total) {
|
|||
ctx.stroke();
|
||||
start += arc;
|
||||
});
|
||||
ctx.fillStyle = '#26372d';
|
||||
ctx.fillStyle = '#17251d';
|
||||
ctx.font = '700 20px sans-serif';
|
||||
ctx.fillText('Today', 240, 88);
|
||||
ctx.font = '15px sans-serif';
|
||||
labels.forEach((label, index) => {
|
||||
ctx.fillStyle = colors[index];
|
||||
ctx.fillRect(240, 112 + index * 34, 14, 14);
|
||||
ctx.fillStyle = '#26372d';
|
||||
ctx.fillStyle = '#17251d';
|
||||
ctx.fillText(label, 264, 125 + index * 34);
|
||||
});
|
||||
}
|
||||
|
|
@ -207,6 +212,7 @@ function renderEntries() {
|
|||
<span>${e.calories} kcal | P ${e.proteinGrams}g | C ${e.carbsGrams}g | F ${e.fatGrams}g</span>
|
||||
<small>Fruit ${Number(e.fruitServings || 0).toFixed(1)} | Vegetables ${Number(e.vegetableServings || 0).toFixed(1)}${e.imageIncluded ? ' | image analyzed' : ''}</small>
|
||||
<small>${escapeHtml(e.description || '')}</small>
|
||||
${e.foodGroups ? `<small>Groups: ${escapeHtml(e.foodGroups)}</small>` : ''}
|
||||
${e.notes ? `<small>${escapeHtml(e.notes)}</small>` : ''}
|
||||
</article>`).join('');
|
||||
}
|
||||
|
|
@ -217,16 +223,33 @@ function escapeHtml(value) {
|
|||
|
||||
function render() {
|
||||
const today = totalsFor(todayKey());
|
||||
$('todayCalories').textContent = `${today.calories} kcal`;
|
||||
$('todayMacroText').textContent = `P ${today.protein}g | C ${today.carbs}g | F ${today.fat}g`;
|
||||
$('todayProduceText').textContent = `Fruit ${today.fruit.toFixed(1)} | Veg ${today.vegetables.toFixed(1)}`;
|
||||
const week = last7Days();
|
||||
const weekAverage = Math.round(week.reduce((sum, day) => sum + day.calories, 0) / week.length);
|
||||
const produce = today.fruit + today.vegetables;
|
||||
|
||||
setText('todayCalories', `${today.calories} kcal`);
|
||||
setText('todayMacroText', `P ${today.protein}g | C ${today.carbs}g | F ${today.fat}g`);
|
||||
setText('todayProduceText', `Fruit ${today.fruit.toFixed(1)} | Veg ${today.vegetables.toFixed(1)}`);
|
||||
setText('mealCountText', String(today.meals));
|
||||
setText('weekAverageText', `${weekAverage} kcal`);
|
||||
setText('produceGoalText', `${produce.toFixed(1)} / 5`);
|
||||
|
||||
drawMacroChart(today);
|
||||
drawBars('calorieChart', week, 'calories');
|
||||
drawBars('produceChart', week, 'produce');
|
||||
renderEntries();
|
||||
}
|
||||
|
||||
function showSection(sectionName) {
|
||||
document.querySelectorAll('.section-page').forEach(section => {
|
||||
section.hidden = section.dataset.section !== sectionName;
|
||||
});
|
||||
document.querySelectorAll('[data-nav]').forEach(button => {
|
||||
button.classList.toggle('active', button.dataset.nav === sectionName);
|
||||
});
|
||||
document.body.classList.remove('menu-open');
|
||||
}
|
||||
|
||||
function loadSettings() {
|
||||
const cfg = settings();
|
||||
$('baseUrl').value = cfg.baseUrl;
|
||||
|
|
@ -244,6 +267,19 @@ $('saveSettings').addEventListener('click', () => {
|
|||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-nav]').forEach(button => {
|
||||
button.addEventListener('click', () => showSection(button.dataset.nav));
|
||||
});
|
||||
|
||||
$('menuToggle').addEventListener('click', () => {
|
||||
document.body.classList.toggle('menu-open');
|
||||
});
|
||||
|
||||
$('logout').addEventListener('click', async () => {
|
||||
await fetch('/api/logout', { method: 'POST' });
|
||||
window.location.href = '/login.html';
|
||||
});
|
||||
|
||||
$('image').addEventListener('change', async (event) => {
|
||||
const file = event.target.files[0];
|
||||
selectedImageDataUrl = file ? await fileToDataUrl(file) : '';
|
||||
|
|
|
|||
|
|
@ -8,75 +8,155 @@
|
|||
<link rel="stylesheet" href="/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<section class="hero">
|
||||
<div>
|
||||
<p class="eyebrow">AI food diary</p>
|
||||
<h1>Calorie AI</h1>
|
||||
<p>Upload a meal image, add a portion note, and track calories, macros, fruit, and vegetables per day.</p>
|
||||
<div class="app-frame">
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<div class="brand-block">
|
||||
<span class="brand-mark">CA</span>
|
||||
<div>
|
||||
<strong>Calorie AI</strong>
|
||||
<small>Private food diary</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="today-card">
|
||||
<span>Today</span>
|
||||
<strong id="todayCalories">0 kcal</strong>
|
||||
<small id="todayMacroText">P 0g | C 0g | F 0g</small>
|
||||
<small id="todayProduceText">Fruit 0.0 | Veg 0.0</small>
|
||||
<nav class="main-nav" aria-label="Main menu">
|
||||
<button class="nav-item active" type="button" data-nav="dashboard">Dashboard</button>
|
||||
<button class="nav-item" type="button" data-nav="log">Log meal</button>
|
||||
<button class="nav-item" type="button" data-nav="analytics">Analytics</button>
|
||||
<button class="nav-item" type="button" data-nav="diary">Diary</button>
|
||||
<button class="nav-item" type="button" data-nav="settings">AI settings</button>
|
||||
</nav>
|
||||
<div class="sidebar-footer">
|
||||
<small>Protected by local web auth</small>
|
||||
<button id="logout" class="ghost" type="button">Sign out</button>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<section class="grid two">
|
||||
<form id="mealForm" class="panel">
|
||||
<h2>Add meal</h2>
|
||||
<label>
|
||||
Description
|
||||
<textarea id="description" rows="4" placeholder="Example: grilled salmon, rice, broccoli, berries"></textarea>
|
||||
</label>
|
||||
<label>
|
||||
Portion or measure
|
||||
<input id="measure" placeholder="Example: one plate, 450g, 2 cups">
|
||||
</label>
|
||||
<label>
|
||||
Meal image
|
||||
<input id="image" type="file" accept="image/*">
|
||||
</label>
|
||||
<img id="preview" class="preview" alt="Selected meal preview" hidden>
|
||||
<button type="submit">Analyze and save</button>
|
||||
<p id="status" class="status"></p>
|
||||
</form>
|
||||
<div class="content-shell">
|
||||
<header class="topbar">
|
||||
<button id="menuToggle" class="menu-toggle" type="button" aria-label="Open menu">Menu</button>
|
||||
<div>
|
||||
<p class="eyebrow">AI nutrition cockpit</p>
|
||||
<h1>Private food tracking with image-backed nutrition estimates</h1>
|
||||
</div>
|
||||
<button class="primary-action" type="button" data-nav="log">Add meal</button>
|
||||
</header>
|
||||
|
||||
<section class="panel settings">
|
||||
<h2>Admin AI settings</h2>
|
||||
<label>API base URL<input id="baseUrl" placeholder="https://api.openai.com/v1"></label>
|
||||
<label>API key<input id="apiKey" type="password" placeholder="Optional for local endpoints"></label>
|
||||
<label>Vision model<input id="visionModel" placeholder="gpt-4o-mini"></label>
|
||||
<label>Tasking model<input id="taskModel" placeholder="gpt-4o-mini"></label>
|
||||
<button id="saveSettings" type="button">Save settings</button>
|
||||
<p class="muted">The vision model estimates food and calories from images. The tasking model normalizes the final nutrition JSON. They can be the same model.</p>
|
||||
</section>
|
||||
</section>
|
||||
<main class="content">
|
||||
<section class="section-page" data-section="dashboard">
|
||||
<div class="hero-grid">
|
||||
<article class="hero-card">
|
||||
<p class="eyebrow">Today</p>
|
||||
<strong id="todayCalories">0 kcal</strong>
|
||||
<span id="todayMacroText">P 0g | C 0g | F 0g</span>
|
||||
<span id="todayProduceText">Fruit 0.0 | Veg 0.0</span>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<small>Meals logged</small>
|
||||
<strong id="mealCountText">0</strong>
|
||||
<span>Entries saved locally</span>
|
||||
</article>
|
||||
<article class="metric-card warm">
|
||||
<small>7-day average</small>
|
||||
<strong id="weekAverageText">0 kcal</strong>
|
||||
<span>Calories per day</span>
|
||||
</article>
|
||||
<article class="metric-card cool">
|
||||
<small>Produce today</small>
|
||||
<strong id="produceGoalText">0.0 / 5</strong>
|
||||
<span>Fruit + vegetable servings</span>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<section class="grid three">
|
||||
<section class="panel chart-panel">
|
||||
<h2>Macros today</h2>
|
||||
<canvas id="macroChart" width="420" height="260"></canvas>
|
||||
</section>
|
||||
<section class="panel chart-panel wide">
|
||||
<h2>Calories, last 7 days</h2>
|
||||
<canvas id="calorieChart" width="720" height="260"></canvas>
|
||||
</section>
|
||||
<section class="panel chart-panel wide">
|
||||
<h2>Fruit and vegetables</h2>
|
||||
<canvas id="produceChart" width="720" height="240"></canvas>
|
||||
</section>
|
||||
</section>
|
||||
<section class="panel split-panel">
|
||||
<div>
|
||||
<p class="eyebrow">Quick log</p>
|
||||
<h2>Capture the meal while it is still in front of you.</h2>
|
||||
<p>Upload a plate photo, add a portion note, and let the vision model hand context to the tasking model for clean nutrition JSON.</p>
|
||||
<button type="button" data-nav="log">Open meal logger</button>
|
||||
</div>
|
||||
<canvas id="macroChart" width="420" height="260"></canvas>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="row-title">
|
||||
<h2>Food diary</h2>
|
||||
<button id="clearData" class="secondary" type="button">Clear local diary</button>
|
||||
</div>
|
||||
<div id="entries" class="entries"></div>
|
||||
</section>
|
||||
</main>
|
||||
<section class="section-page" data-section="log" hidden>
|
||||
<form id="mealForm" class="panel meal-panel">
|
||||
<div class="panel-heading">
|
||||
<p class="eyebrow">Meal intake</p>
|
||||
<h2>Add meal</h2>
|
||||
<p>Text-only works, but images give the vision model more evidence for portion and produce estimates.</p>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<label class="span-2">
|
||||
Description
|
||||
<textarea id="description" rows="5" placeholder="Example: grilled salmon, rice, broccoli, berries"></textarea>
|
||||
</label>
|
||||
<label>
|
||||
Portion or measure
|
||||
<input id="measure" placeholder="Example: one plate, 450g, 2 cups">
|
||||
</label>
|
||||
<label>
|
||||
Meal image
|
||||
<input id="image" type="file" accept="image/*">
|
||||
</label>
|
||||
</div>
|
||||
<img id="preview" class="preview" alt="Selected meal preview" hidden>
|
||||
<div class="form-actions">
|
||||
<button type="submit">Analyze and save</button>
|
||||
<p id="status" class="status"></p>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="section-page" data-section="analytics" hidden>
|
||||
<div class="grid two analytics-grid">
|
||||
<section class="panel chart-panel wide">
|
||||
<div class="panel-heading compact">
|
||||
<p class="eyebrow">Trend</p>
|
||||
<h2>Calories, last 7 days</h2>
|
||||
</div>
|
||||
<canvas id="calorieChart" width="720" height="260"></canvas>
|
||||
</section>
|
||||
<section class="panel chart-panel wide">
|
||||
<div class="panel-heading compact">
|
||||
<p class="eyebrow">Produce</p>
|
||||
<h2>Fruit and vegetables</h2>
|
||||
</div>
|
||||
<canvas id="produceChart" width="720" height="240"></canvas>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section-page" data-section="diary" hidden>
|
||||
<section class="panel">
|
||||
<div class="row-title">
|
||||
<div>
|
||||
<p class="eyebrow">Local history</p>
|
||||
<h2>Food diary</h2>
|
||||
</div>
|
||||
<button id="clearData" class="secondary" type="button">Clear local diary</button>
|
||||
</div>
|
||||
<div id="entries" class="entries"></div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class="section-page" data-section="settings" hidden>
|
||||
<section class="panel settings-panel">
|
||||
<div class="panel-heading">
|
||||
<p class="eyebrow">Admin</p>
|
||||
<h2>AI settings</h2>
|
||||
<p>These settings stay in browser local storage. The API key is sent only to this server-side proxy, not directly to the model endpoint from the browser.</p>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<label class="span-2">API base URL<input id="baseUrl" placeholder="https://api.openai.com/v1"></label>
|
||||
<label class="span-2">API key<input id="apiKey" type="password" placeholder="Optional for local endpoints"></label>
|
||||
<label>Vision model<input id="visionModel" placeholder="gpt-4o-mini"></label>
|
||||
<label>Tasking model<input id="taskModel" placeholder="gpt-4o-mini"></label>
|
||||
</div>
|
||||
<button id="saveSettings" type="button">Save settings</button>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
29
web/public/login.html
Normal file
29
web/public/login.html
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Sign in - Calorie AI</title>
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
</head>
|
||||
<body class="login-body">
|
||||
<main class="login-shell">
|
||||
<section class="login-brand">
|
||||
<p class="eyebrow">Private nutrition console</p>
|
||||
<h1>Calorie AI</h1>
|
||||
<p>Protected meal logging for calories, macros, fruit, vegetables, and AI image estimates.</p>
|
||||
</section>
|
||||
<form id="loginForm" class="login-card">
|
||||
<span class="brand-mark">CA</span>
|
||||
<h2>Sign in to Calorie AI</h2>
|
||||
<p class="muted">Use the local web credentials configured on this host.</p>
|
||||
<label>Username<input id="username" autocomplete="username" value="admin"></label>
|
||||
<label>Password<input id="password" type="password" autocomplete="current-password" autofocus></label>
|
||||
<button type="submit">Sign in</button>
|
||||
<p id="loginStatus" class="status"></p>
|
||||
</form>
|
||||
</main>
|
||||
<script src="/login.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
31
web/public/login.js
Normal file
31
web/public/login.js
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
const loginForm = document.getElementById('loginForm');
|
||||
const loginStatus = document.getElementById('loginStatus');
|
||||
|
||||
loginForm.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
const button = loginForm.querySelector('button');
|
||||
button.disabled = true;
|
||||
loginStatus.textContent = 'Checking credentials...';
|
||||
loginStatus.className = 'status ok';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/login', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
username: document.getElementById('username').value.trim(),
|
||||
password: document.getElementById('password').value,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Sign in failed' }));
|
||||
throw new Error(error.error || 'Sign in failed');
|
||||
}
|
||||
window.location.href = '/';
|
||||
} catch (error) {
|
||||
loginStatus.textContent = error.message;
|
||||
loginStatus.className = 'status error';
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
|
|
@ -1,80 +1,246 @@
|
|||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f8f4ec;
|
||||
--ink: #26372d;
|
||||
--muted: #5b635c;
|
||||
--card: #ffffff;
|
||||
--line: #e5ddcf;
|
||||
--green: #2f7d59;
|
||||
--orange: #da9648;
|
||||
--blue: #6177c4;
|
||||
--soft: #eef3ee;
|
||||
--bg: #f3efe3;
|
||||
--panel: rgba(255, 253, 247, .94);
|
||||
--panel-strong: #fffaf0;
|
||||
--ink: #17251d;
|
||||
--muted: #667268;
|
||||
--line: #e3d8c4;
|
||||
--green: #236b4b;
|
||||
--green-2: #39a36d;
|
||||
--orange: #d5873a;
|
||||
--blue: #5168bc;
|
||||
--nav: #112018;
|
||||
--nav-soft: #1c3427;
|
||||
--danger: #a43434;
|
||||
--shadow: 0 28px 80px rgba(41, 31, 17, .12);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: radial-gradient(circle at top left, #fff8df, transparent 30rem), var(--bg);
|
||||
background:
|
||||
radial-gradient(circle at 15% 10%, rgba(255, 226, 154, .65), transparent 28rem),
|
||||
radial-gradient(circle at 80% 0%, rgba(79, 138, 104, .22), transparent 30rem),
|
||||
var(--bg);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.shell { width: min(1180px, calc(100% - 32px)); margin: 0 auto; padding: 32px 0; }
|
||||
.hero { display: grid; grid-template-columns: 1fr minmax(220px, 320px); gap: 20px; align-items: stretch; margin-bottom: 20px; }
|
||||
.eyebrow { color: var(--green); font-weight: 800; letter-spacing: .12em; margin: 0 0 8px; text-transform: uppercase; }
|
||||
h1 { font-size: clamp(2.7rem, 8vw, 6rem); line-height: .9; margin: 0; }
|
||||
h2 { margin: 0 0 16px; font-size: 1.15rem; }
|
||||
p { color: var(--muted); line-height: 1.5; }
|
||||
.today-card, .panel {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 24px;
|
||||
background: rgba(255,255,255,.88);
|
||||
box-shadow: 0 24px 60px rgba(48, 38, 20, .08);
|
||||
}
|
||||
.today-card { padding: 24px; display: grid; align-content: center; gap: 8px; }
|
||||
.today-card strong { font-size: 2.3rem; color: var(--green); }
|
||||
.today-card small { color: var(--muted); }
|
||||
.grid { display: grid; gap: 20px; margin-bottom: 20px; }
|
||||
.two { grid-template-columns: 1.2fr .8fr; }
|
||||
.three { grid-template-columns: minmax(260px, .8fr) minmax(320px, 1.1fr); }
|
||||
.wide { grid-column: auto; }
|
||||
.panel { padding: 22px; }
|
||||
label { display: grid; gap: 7px; margin-bottom: 14px; color: var(--muted); font-weight: 700; }
|
||||
input, textarea {
|
||||
width: 100%;
|
||||
border: 1px solid #dbd3c5;
|
||||
border-radius: 14px;
|
||||
background: #fcfaf6;
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
padding: 12px 13px;
|
||||
}
|
||||
textarea { resize: vertical; }
|
||||
button, input, textarea { font: inherit; }
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
border-radius: 14px;
|
||||
background: var(--green);
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(135deg, var(--green), var(--green-2));
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-weight: 800;
|
||||
padding: 12px 15px;
|
||||
font-weight: 850;
|
||||
padding: 13px 17px;
|
||||
transition: transform .18s ease, box-shadow .18s ease, opacity .18s ease;
|
||||
}
|
||||
button.secondary { background: #e9e3d7; color: var(--ink); }
|
||||
button:disabled { opacity: .55; cursor: wait; }
|
||||
.preview { width: 100%; max-height: 280px; object-fit: cover; border-radius: 18px; margin: 4px 0 14px; border: 1px solid var(--line); }
|
||||
.status { min-height: 1.5em; margin-bottom: 0; }
|
||||
.status.error { color: #a43434; }
|
||||
.status.ok { color: var(--green); }
|
||||
.muted { color: var(--muted); font-size: .9rem; }
|
||||
.chart-panel canvas { width: 100%; height: auto; }
|
||||
.entries { display: grid; gap: 12px; }
|
||||
.entry { border: 1px solid var(--line); border-radius: 18px; padding: 14px; background: #fffdf8; }
|
||||
.entry strong { display: block; margin-bottom: 5px; }
|
||||
.entry small { color: var(--muted); display: block; margin-top: 4px; }
|
||||
.row-title { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.hero, .two, .three { grid-template-columns: 1fr; }
|
||||
.shell { width: min(100% - 20px, 1180px); padding: 20px 0; }
|
||||
button:hover { transform: translateY(-1px); box-shadow: 0 12px 28px rgba(35, 107, 75, .22); }
|
||||
button:disabled { cursor: wait; opacity: .55; transform: none; box-shadow: none; }
|
||||
button.secondary { background: #ebe1d0; color: var(--ink); }
|
||||
button.ghost { width: 100%; background: rgba(255,255,255,.09); color: #f4ead6; box-shadow: none; }
|
||||
|
||||
input, textarea {
|
||||
width: 100%;
|
||||
border: 1px solid #d9ceb8;
|
||||
border-radius: 16px;
|
||||
background: #fffaf2;
|
||||
color: var(--ink);
|
||||
outline: none;
|
||||
padding: 13px 14px;
|
||||
}
|
||||
|
||||
input:focus, textarea:focus { border-color: var(--green-2); box-shadow: 0 0 0 4px rgba(57, 163, 109, .14); }
|
||||
textarea { resize: vertical; }
|
||||
label { display: grid; gap: 8px; color: var(--muted); font-weight: 800; }
|
||||
h1, h2, p { margin-top: 0; }
|
||||
h1 { max-width: 820px; margin-bottom: 0; font-size: clamp(2rem, 4vw, 4.2rem); line-height: .94; letter-spacing: -.06em; }
|
||||
h2 { margin-bottom: 12px; font-size: clamp(1.35rem, 2vw, 2rem); letter-spacing: -.035em; }
|
||||
p { color: var(--muted); line-height: 1.6; }
|
||||
canvas { max-width: 100%; }
|
||||
|
||||
.app-frame { display: grid; grid-template-columns: 280px 1fr; min-height: 100vh; }
|
||||
.sidebar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
padding: 22px;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
gap: 28px;
|
||||
background: linear-gradient(180deg, var(--nav), #0b140f);
|
||||
color: #f8ecd7;
|
||||
}
|
||||
|
||||
.brand-block { display: flex; align-items: center; gap: 12px; }
|
||||
.brand-block strong { display: block; font-size: 1.05rem; }
|
||||
.brand-block small, .sidebar-footer small { color: rgba(248, 236, 215, .62); }
|
||||
.brand-mark {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
border-radius: 17px;
|
||||
background: linear-gradient(135deg, #e7a24e, #3fb473);
|
||||
color: #102017;
|
||||
font-weight: 950;
|
||||
letter-spacing: -.04em;
|
||||
}
|
||||
|
||||
.main-nav { display: grid; align-content: start; gap: 9px; }
|
||||
.nav-item {
|
||||
width: 100%;
|
||||
padding: 13px 14px;
|
||||
border-radius: 15px;
|
||||
background: transparent;
|
||||
color: rgba(248, 236, 215, .72);
|
||||
text-align: left;
|
||||
box-shadow: none;
|
||||
}
|
||||
.nav-item:hover, .nav-item.active { background: var(--nav-soft); color: #fff4df; box-shadow: none; }
|
||||
.sidebar-footer { display: grid; gap: 12px; }
|
||||
|
||||
.content-shell { min-width: 0; }
|
||||
.topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
padding: 26px clamp(18px, 4vw, 48px);
|
||||
border-bottom: 1px solid rgba(227, 216, 196, .7);
|
||||
background: rgba(243, 239, 227, .82);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
.menu-toggle { display: none; background: #e9dfcc; color: var(--ink); box-shadow: none; }
|
||||
.primary-action { white-space: nowrap; }
|
||||
.content { width: min(1180px, calc(100% - 36px)); margin: 0 auto; padding: 28px 0 44px; }
|
||||
.section-page { display: grid; gap: 22px; }
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 8px;
|
||||
color: var(--green);
|
||||
font-size: .76rem;
|
||||
font-weight: 950;
|
||||
letter-spacing: .14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hero-grid { display: grid; grid-template-columns: minmax(300px, 1.4fr) repeat(3, minmax(150px, .62fr)); gap: 18px; }
|
||||
.hero-card, .metric-card, .panel, .login-card, .login-brand {
|
||||
border: 1px solid rgba(227, 216, 196, .9);
|
||||
border-radius: 28px;
|
||||
background: var(--panel);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.hero-card {
|
||||
min-height: 220px;
|
||||
padding: 28px;
|
||||
display: grid;
|
||||
align-content: end;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(22, 42, 30, .9), rgba(35, 107, 75, .84)),
|
||||
radial-gradient(circle at top right, rgba(235, 173, 79, .6), transparent 20rem);
|
||||
color: white;
|
||||
}
|
||||
.hero-card .eyebrow, .hero-card span { color: rgba(255,255,255,.78); }
|
||||
.hero-card strong { display: block; margin-bottom: 8px; font-size: clamp(3rem, 8vw, 5.6rem); line-height: .9; letter-spacing: -.07em; }
|
||||
.hero-card span { display: block; margin-top: 6px; }
|
||||
.metric-card { padding: 22px; display: grid; align-content: end; gap: 8px; background: linear-gradient(180deg, #fffdf8, #f7efde); }
|
||||
.metric-card small { color: var(--muted); font-weight: 850; text-transform: uppercase; letter-spacing: .08em; }
|
||||
.metric-card strong { font-size: 2.2rem; line-height: 1; letter-spacing: -.05em; }
|
||||
.metric-card span { color: var(--muted); font-size: .92rem; }
|
||||
.metric-card.warm strong { color: var(--orange); }
|
||||
.metric-card.cool strong { color: var(--blue); }
|
||||
|
||||
.panel { padding: clamp(20px, 3vw, 32px); }
|
||||
.split-panel { display: grid; grid-template-columns: minmax(280px, .85fr) minmax(320px, 1fr); gap: 24px; align-items: center; }
|
||||
.panel-heading { max-width: 760px; margin-bottom: 20px; }
|
||||
.panel-heading.compact { margin-bottom: 8px; }
|
||||
.grid { display: grid; gap: 20px; }
|
||||
.grid.two { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
|
||||
.span-2 { grid-column: span 2; }
|
||||
.form-actions { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; margin-top: 18px; }
|
||||
.meal-panel { background: linear-gradient(135deg, rgba(255,253,247,.96), rgba(244, 233, 210, .94)); }
|
||||
.preview { width: 100%; max-height: 360px; object-fit: cover; border: 1px solid var(--line); border-radius: 24px; margin-top: 18px; }
|
||||
.status { min-height: 1.5em; margin: 0; font-weight: 800; }
|
||||
.status.error { color: var(--danger); }
|
||||
.status.ok { color: var(--green); }
|
||||
.muted { color: var(--muted); font-size: .93rem; }
|
||||
.chart-panel canvas, .split-panel canvas { width: 100%; height: auto; }
|
||||
.row-title { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 18px; }
|
||||
.row-title h2 { margin-bottom: 0; }
|
||||
.entries { display: grid; gap: 12px; }
|
||||
.entry {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 20px;
|
||||
padding: 16px;
|
||||
background: var(--panel-strong);
|
||||
}
|
||||
.entry strong { font-size: 1.02rem; }
|
||||
.entry small { color: var(--muted); }
|
||||
|
||||
.login-body { display: grid; place-items: center; padding: 24px; }
|
||||
.login-shell { width: min(980px, 100%); display: grid; grid-template-columns: 1.1fr .9fr; gap: 22px; align-items: stretch; }
|
||||
.login-brand {
|
||||
min-height: 560px;
|
||||
padding: clamp(28px, 5vw, 54px);
|
||||
display: grid;
|
||||
align-content: end;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(15, 29, 21, .94), rgba(29, 88, 61, .86)),
|
||||
radial-gradient(circle at top right, rgba(218, 150, 72, .82), transparent 22rem);
|
||||
color: #fff4df;
|
||||
}
|
||||
.login-brand h1 { color: white; }
|
||||
.login-brand p, .login-brand .eyebrow { color: rgba(255,244,223,.78); }
|
||||
.login-card { padding: clamp(24px, 4vw, 40px); align-self: center; display: grid; gap: 16px; }
|
||||
.login-card h2 { margin-bottom: 0; }
|
||||
|
||||
@media (max-width: 1050px) {
|
||||
.app-frame { grid-template-columns: 1fr; }
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
left: 14px;
|
||||
top: 14px;
|
||||
bottom: 14px;
|
||||
z-index: 30;
|
||||
width: min(320px, calc(100vw - 28px));
|
||||
height: auto;
|
||||
border-radius: 26px;
|
||||
transform: translateX(calc(-100% - 18px));
|
||||
transition: transform .2s ease;
|
||||
box-shadow: 0 30px 90px rgba(0,0,0,.34);
|
||||
}
|
||||
body.menu-open .sidebar { transform: translateX(0); }
|
||||
.menu-toggle { display: inline-flex; }
|
||||
.hero-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.hero-card { grid-column: span 2; }
|
||||
.split-panel, .grid.two, .login-shell { grid-template-columns: 1fr; }
|
||||
.login-brand { min-height: 360px; }
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.topbar { align-items: flex-start; padding: 18px; }
|
||||
.primary-action { display: none; }
|
||||
.content { width: min(100% - 22px, 1180px); padding-top: 18px; }
|
||||
.hero-grid, .form-grid { grid-template-columns: 1fr; }
|
||||
.hero-card, .span-2 { grid-column: auto; }
|
||||
.row-title, .form-actions { align-items: stretch; flex-direction: column; }
|
||||
.row-title button, .form-actions button { width: 100%; }
|
||||
.login-body { padding: 12px; }
|
||||
.login-brand { min-height: 260px; }
|
||||
}
|
||||
|
|
|
|||
125
web/server.js
125
web/server.js
|
|
@ -1,10 +1,16 @@
|
|||
const http = require('http');
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const port = Number(process.env.PORT || 8080);
|
||||
const publicDir = path.join(__dirname, 'public');
|
||||
const publicRoot = publicDir + path.sep;
|
||||
const dataDir = process.env.CALORIE_AI_DATA_DIR || path.join(__dirname, 'data');
|
||||
const authFile = path.join(dataDir, 'auth.json');
|
||||
const sessionCookieName = 'calorie_ai_session';
|
||||
const sessionSeconds = 60 * 60 * 24 * 7;
|
||||
const auth = loadAuthConfig();
|
||||
|
||||
const types = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
|
|
@ -29,14 +35,98 @@ function readBody(req) {
|
|||
});
|
||||
}
|
||||
|
||||
function send(res, status, body, contentType = 'application/json; charset=utf-8', headOnly = false) {
|
||||
function loadAuthConfig() {
|
||||
let stored = {};
|
||||
try {
|
||||
stored = JSON.parse(fs.readFileSync(authFile, 'utf8'));
|
||||
} catch {
|
||||
stored = {};
|
||||
}
|
||||
|
||||
const user = process.env.CALORIE_AI_WEB_USER || stored.user || 'admin';
|
||||
const password = process.env.CALORIE_AI_WEB_PASSWORD || stored.password || crypto.randomBytes(18).toString('base64url');
|
||||
const sessionSecret = process.env.CALORIE_AI_SESSION_SECRET || stored.sessionSecret || crypto.randomBytes(32).toString('hex');
|
||||
|
||||
if (!process.env.CALORIE_AI_WEB_PASSWORD || !process.env.CALORIE_AI_SESSION_SECRET) {
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
fs.writeFileSync(authFile, JSON.stringify({ user, password, sessionSecret, createdAt: new Date().toISOString() }, null, 2));
|
||||
fs.chmodSync(authFile, 0o600);
|
||||
console.log(`Calorie AI auth is stored at ${authFile}`);
|
||||
}
|
||||
|
||||
return { user, password, sessionSecret };
|
||||
}
|
||||
|
||||
function send(res, status, body, contentType = 'application/json; charset=utf-8', headOnly = false, headers = {}) {
|
||||
res.writeHead(status, {
|
||||
'content-type': contentType,
|
||||
'cache-control': 'no-store',
|
||||
'x-content-type-options': 'nosniff',
|
||||
'referrer-policy': 'same-origin',
|
||||
'x-frame-options': 'DENY',
|
||||
'content-security-policy': "default-src 'self'; img-src 'self' data: blob:; script-src 'self'; style-src 'self'; connect-src 'self'; base-uri 'self'; frame-ancestors 'none'",
|
||||
...headers,
|
||||
});
|
||||
res.end(headOnly ? '' : body);
|
||||
}
|
||||
|
||||
function redirect(res, location) {
|
||||
res.writeHead(302, {
|
||||
location,
|
||||
'cache-control': 'no-store',
|
||||
'referrer-policy': 'same-origin',
|
||||
'x-frame-options': 'DENY',
|
||||
});
|
||||
res.end();
|
||||
}
|
||||
|
||||
function parseCookies(req) {
|
||||
return Object.fromEntries(String(req.headers.cookie || '').split(';').map(part => {
|
||||
const index = part.indexOf('=');
|
||||
if (index < 0) return ['', ''];
|
||||
return [part.slice(0, index).trim(), decodeURIComponent(part.slice(index + 1).trim())];
|
||||
}).filter(([key]) => key));
|
||||
}
|
||||
|
||||
function safeEqual(left, right) {
|
||||
const leftBuffer = Buffer.from(String(left));
|
||||
const rightBuffer = Buffer.from(String(right));
|
||||
return leftBuffer.length === rightBuffer.length && crypto.timingSafeEqual(leftBuffer, rightBuffer);
|
||||
}
|
||||
|
||||
function sign(value) {
|
||||
return crypto.createHmac('sha256', auth.sessionSecret).update(value).digest('base64url');
|
||||
}
|
||||
|
||||
function createSession() {
|
||||
const payload = Buffer.from(JSON.stringify({ user: auth.user, exp: Date.now() + sessionSeconds * 1000 })).toString('base64url');
|
||||
return `${payload}.${sign(payload)}`;
|
||||
}
|
||||
|
||||
function isAuthenticated(req) {
|
||||
const token = parseCookies(req)[sessionCookieName];
|
||||
if (!token) return false;
|
||||
const [payload, signature] = token.split('.');
|
||||
if (!payload || !signature || !safeEqual(sign(payload), signature)) return false;
|
||||
try {
|
||||
const decoded = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
|
||||
return decoded.user === auth.user && decoded.exp > Date.now();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function cookieOptions(req, maxAge = sessionSeconds) {
|
||||
const secure = req.headers['x-forwarded-proto'] === 'https' || process.env.CALORIE_AI_COOKIE_SECURE === 'true';
|
||||
return `HttpOnly; SameSite=Lax; Path=/; Max-Age=${maxAge}${secure ? '; Secure' : ''}`;
|
||||
}
|
||||
|
||||
function publicRequest(req) {
|
||||
const url = req.url.split('?')[0];
|
||||
return (req.method === 'POST' && url === '/api/login')
|
||||
|| ((req.method === 'GET' || req.method === 'HEAD') && ['/login.html', '/login.js', '/styles.css', '/favicon.svg'].includes(url));
|
||||
}
|
||||
|
||||
function serveStatic(req, res) {
|
||||
const cleanUrl = decodeURIComponent(req.url.split('?')[0]);
|
||||
const file = cleanUrl === '/' ? '/index.html' : cleanUrl;
|
||||
|
|
@ -71,7 +161,40 @@ async function proxyChat(req, res) {
|
|||
}
|
||||
}
|
||||
|
||||
async function login(req, res) {
|
||||
try {
|
||||
const payload = JSON.parse(await readBody(req));
|
||||
if (!safeEqual(payload.username || '', auth.user) || !safeEqual(payload.password || '', auth.password)) {
|
||||
return send(res, 401, JSON.stringify({ error: 'Invalid username or password' }));
|
||||
}
|
||||
|
||||
send(res, 200, JSON.stringify({ ok: true, user: auth.user }), undefined, false, {
|
||||
'set-cookie': `${sessionCookieName}=${encodeURIComponent(createSession())}; ${cookieOptions(req)}`,
|
||||
});
|
||||
} catch (error) {
|
||||
send(res, 400, JSON.stringify({ error: error.message }));
|
||||
}
|
||||
}
|
||||
|
||||
function logout(req, res) {
|
||||
send(res, 200, JSON.stringify({ ok: true }), undefined, false, {
|
||||
'set-cookie': `${sessionCookieName}=; ${cookieOptions(req, 0)}`,
|
||||
});
|
||||
}
|
||||
|
||||
http.createServer((req, res) => {
|
||||
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.html');
|
||||
}
|
||||
|
||||
if (authenticated && (req.method === 'GET' || req.method === 'HEAD') && url === '/login.html') 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 === 'GET' && url === '/api/session') return send(res, 200, JSON.stringify({ user: auth.user }));
|
||||
if (req.method === 'POST' && req.url === '/api/chat') return proxyChat(req, res);
|
||||
if (req.method === 'GET' || req.method === 'HEAD') return serveStatic(req, res);
|
||||
send(res, 405, 'Method not allowed', 'text/plain; charset=utf-8');
|
||||
|
|
|
|||
|
|
@ -5,55 +5,82 @@ const png = Buffer.from(
|
|||
'base64',
|
||||
);
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
async function login(page) {
|
||||
await page.goto('/');
|
||||
await page.evaluate(() => localStorage.clear());
|
||||
await page.reload();
|
||||
await expect(page).toHaveURL(/login\.html/);
|
||||
await expect(page.getByRole('heading', { name: 'Sign in to Calorie AI' })).toBeVisible();
|
||||
await page.locator('#password').fill('test-password');
|
||||
await page.getByRole('button', { name: 'Sign in' }).click();
|
||||
await expect(page.getByRole('heading', { name: /Private food tracking/ })).toBeVisible();
|
||||
}
|
||||
|
||||
test('requires authentication before loading the app', async ({ page, request }) => {
|
||||
await page.goto('/');
|
||||
await expect(page).toHaveURL(/login\.html/);
|
||||
await expect(page.getByRole('heading', { name: 'Sign in to Calorie AI' })).toBeVisible();
|
||||
|
||||
const response = await request.post('/api/chat', { data: {} });
|
||||
expect(response.status()).toBe(401);
|
||||
});
|
||||
|
||||
test('loads the dashboard and validates missing meal input', async ({ page }) => {
|
||||
await expect(page.getByRole('heading', { name: 'Calorie AI' })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Analyze and save' }).click();
|
||||
await expect(page.locator('#status')).toContainText('Describe the meal or upload an image.');
|
||||
});
|
||||
|
||||
test('saves settings, uploads an image, logs nutrition, updates charts, and clears diary', async ({ page }) => {
|
||||
let calls = 0;
|
||||
await page.route('**/api/chat', async (route) => {
|
||||
calls += 1;
|
||||
const content = calls === 1
|
||||
? '{"foods":["salmon","rice","broccoli","berries"],"calories":620,"proteinGrams":42,"carbsGrams":58,"fatGrams":22,"fruitServings":0.5,"vegetableServings":1.5}'
|
||||
: '{"mealName":"Salmon rice bowl","calories":640,"proteinGrams":44,"carbsGrams":60,"fatGrams":23,"fruitServings":0.5,"vegetableServings":1.5,"foodGroups":"fish, grains, fruit, vegetables","notes":"Estimated from uploaded image and portion note."}';
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ choices: [{ message: { content } }] }),
|
||||
});
|
||||
test.describe('authenticated app', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await login(page);
|
||||
await page.evaluate(() => localStorage.clear());
|
||||
await page.reload();
|
||||
});
|
||||
|
||||
await page.locator('#baseUrl').fill('https://api.example.test/v1');
|
||||
await page.locator('#visionModel').fill('vision-test-model');
|
||||
await page.locator('#taskModel').fill('task-test-model');
|
||||
await page.getByRole('button', { name: 'Save settings' }).click();
|
||||
test('loads the dashboard, menu, and validates missing meal input', async ({ page }) => {
|
||||
await expect(page.getByRole('navigation', { name: 'Main menu' })).toBeVisible();
|
||||
await expect(page.locator('#todayCalories')).toHaveText('0 kcal');
|
||||
await page.getByRole('button', { name: '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.');
|
||||
});
|
||||
|
||||
await page.locator('#description').fill('salmon rice bowl with broccoli and berries');
|
||||
await page.locator('#measure').fill('one dinner bowl');
|
||||
await page.locator('#image').setInputFiles({ name: 'meal.png', mimeType: 'image/png', buffer: png });
|
||||
await expect(page.locator('#preview')).toBeVisible();
|
||||
test('saves settings, uploads an image, logs nutrition, updates charts, and clears diary', async ({ page }) => {
|
||||
let calls = 0;
|
||||
await page.route('**/api/chat', async (route) => {
|
||||
calls += 1;
|
||||
const content = calls === 1
|
||||
? '{"foods":["salmon","rice","broccoli","berries"],"calories":620,"proteinGrams":42,"carbsGrams":58,"fatGrams":22,"fruitServings":0.5,"vegetableServings":1.5}'
|
||||
: '{"mealName":"Salmon rice bowl","calories":640,"proteinGrams":44,"carbsGrams":60,"fatGrams":23,"fruitServings":0.5,"vegetableServings":1.5,"foodGroups":"fish, grains, fruit, vegetables","notes":"Estimated from uploaded image and portion note."}';
|
||||
|
||||
await page.getByRole('button', { name: 'Analyze and save' }).click();
|
||||
await expect(page.locator('#status')).toContainText('Saved.');
|
||||
await expect(page.locator('#todayCalories')).toHaveText('640 kcal');
|
||||
await expect(page.locator('#todayMacroText')).toHaveText('P 44g | C 60g | F 23g');
|
||||
await expect(page.locator('#todayProduceText')).toHaveText('Fruit 0.5 | Veg 1.5');
|
||||
await expect(page.locator('.entry')).toContainText('Salmon rice bowl');
|
||||
await expect(page.locator('#macroChart')).toBeVisible();
|
||||
await expect(page.locator('#calorieChart')).toBeVisible();
|
||||
await expect(page.locator('#produceChart')).toBeVisible();
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ choices: [{ message: { content } }] }),
|
||||
});
|
||||
});
|
||||
|
||||
page.once('dialog', dialog => dialog.accept());
|
||||
await page.getByRole('button', { name: 'Clear local diary' }).click();
|
||||
await expect(page.locator('#todayCalories')).toHaveText('0 kcal');
|
||||
await expect(page.locator('#entries')).toContainText('No meals logged yet.');
|
||||
await page.getByRole('button', { name: 'AI settings' }).click();
|
||||
await page.locator('#baseUrl').fill('https://api.example.test/v1');
|
||||
await page.locator('#visionModel').fill('vision-test-model');
|
||||
await page.locator('#taskModel').fill('task-test-model');
|
||||
await page.getByRole('button', { name: 'Save settings' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Log meal' }).click();
|
||||
await page.locator('#description').fill('salmon rice bowl with broccoli and berries');
|
||||
await page.locator('#measure').fill('one dinner bowl');
|
||||
await page.locator('#image').setInputFiles({ name: 'meal.png', mimeType: 'image/png', buffer: png });
|
||||
await expect(page.locator('#preview')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Analyze and save' }).click();
|
||||
await expect(page.locator('#status')).toContainText('Saved.');
|
||||
await expect(page.locator('#todayCalories')).toHaveText('640 kcal');
|
||||
await expect(page.locator('#todayMacroText')).toHaveText('P 44g | C 60g | F 23g');
|
||||
await expect(page.locator('#todayProduceText')).toHaveText('Fruit 0.5 | Veg 1.5');
|
||||
|
||||
await page.getByRole('button', { name: 'Analytics' }).click();
|
||||
await expect(page.locator('#calorieChart')).toBeVisible();
|
||||
await expect(page.locator('#produceChart')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Diary', exact: true }).click();
|
||||
await expect(page.locator('.entry')).toContainText('Salmon rice bowl');
|
||||
|
||||
page.once('dialog', dialog => dialog.accept());
|
||||
await page.getByRole('button', { name: 'Clear local diary' }).click();
|
||||
await expect(page.locator('#todayCalories')).toHaveText('0 kcal');
|
||||
await expect(page.locator('#entries')).toContainText('No meals logged yet.');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue