AI food diary
-Calorie AI
-Upload a meal image, add a portion note, and track calories, macros, fruit, and vegetables per day.
+diff --git a/.forgejo/workflows/web.yml b/.forgejo/workflows/web.yml index d8557cb..e36170e 100644 --- a/.forgejo/workflows/web.yml +++ b/.forgejo/workflows/web.yml @@ -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 diff --git a/.gitignore b/.gitignore index 907ad8d..0c0632c 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ local.properties web/node_modules/ web/test-results/ web/playwright-report/ +web/data/ +web/.env diff --git a/README.md b/README.md index 67049cd..cb9dfa9 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/web/.dockerignore b/web/.dockerignore index b0e3907..def1654 100644 --- a/web/.dockerignore +++ b/web/.dockerignore @@ -1,3 +1,5 @@ node_modules npm-debug.log .DS_Store +data +.env diff --git a/web/.env.example b/web/.env.example new file mode 100644 index 0000000..eac2d21 --- /dev/null +++ b/web/.env.example @@ -0,0 +1,3 @@ +CALORIE_AI_WEB_USER=admin +CALORIE_AI_WEB_PASSWORD=change-this-password +CALORIE_AI_SESSION_SECRET=change-this-session-secret diff --git a/web/docker-compose.yml b/web/docker-compose.yml index d8dc165..a04d880 100644 --- a/web/docker-compose.yml +++ b/web/docker-compose.yml @@ -5,3 +5,5 @@ services: restart: unless-stopped ports: - "127.0.0.1:8095:8080" + volumes: + - ./data:/app/data diff --git a/web/playwright.config.js b/web/playwright.config.js index 8698696..2891810 100644 --- a/web/playwright.config.js +++ b/web/playwright.config.js @@ -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', }, }); diff --git a/web/public/app.js b/web/public/app.js index 67f216e..2c39d7e 100644 --- a/web/public/app.js +++ b/web/public/app.js @@ -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() { ${e.calories} kcal | P ${e.proteinGrams}g | C ${e.carbsGrams}g | F ${e.fatGrams}g Fruit ${Number(e.fruitServings || 0).toFixed(1)} | Vegetables ${Number(e.vegetableServings || 0).toFixed(1)}${e.imageIncluded ? ' | image analyzed' : ''} ${escapeHtml(e.description || '')} + ${e.foodGroups ? `Groups: ${escapeHtml(e.foodGroups)}` : ''} ${e.notes ? `${escapeHtml(e.notes)}` : ''} `).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) : ''; diff --git a/web/public/index.html b/web/public/index.html index ece7d0c..09f9a95 100644 --- a/web/public/index.html +++ b/web/public/index.html @@ -8,75 +8,155 @@
-AI food diary
-Upload a meal image, add a portion note, and track calories, macros, fruit, and vegetables per day.
+