Add web calorie tracker and analytics

This commit is contained in:
Daniel 2026-05-19 17:13:41 +02:00
parent 3e4e7aa524
commit 71c8837b48
16 changed files with 1105 additions and 53 deletions

View file

@ -0,0 +1,41 @@
name: Web CI
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
jobs:
test:
runs-on: forgejo-local
steps:
- name: Check out repository
run: |
git clone "http://forgejo:3000/${GITHUB_REPOSITORY}.git" .
git checkout "$GITHUB_SHA"
- name: Install Node and browsers
run: |
apt-get update
apt-get install -y --no-install-recommends ca-certificates curl nodejs npm
cd web
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
npm test

3
.gitignore vendored
View file

@ -5,3 +5,6 @@ dist/
local.properties
*.iml
.idea/
web/node_modules/
web/test-results/
web/playwright-report/

View file

@ -1,17 +1,18 @@
# Calorie AI Android
# Calorie AI
Native Android calorie tracker with optional meal images and admin-configurable AI models.
Native Android and Dockerized web calorie tracker with uploaded meal images and admin-configurable AI models.
## Features
- Add meals by description, portion estimate, and optional image.
- Add meals by description, portion estimate, and uploaded image.
- Analyze meals through OpenAI-compatible chat completions.
- Uses a vision model for image interpretation when an image is attached.
- Uses a tasking model to estimate calories, macros, and notes.
- Stores daily meal entries locally on device.
- 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.
- Stores daily meal entries locally on Android or in browser local storage.
- 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.
## Build
## Android Build
Open this folder in Android Studio and run the `app` configuration, or build with Gradle if available:
@ -23,6 +24,23 @@ Forgejo Actions builds the debug APK on every push to `main` and uploads it as t
Tagged versions also build an installable APK named `calorie-ai-vX.Y.Z.apk` through the Android Release workflow.
## 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.
Run it with Docker:
```bash
cd web
docker compose up -d --build
```
Then open:
```text
http://127.0.0.1:8095
```
## AI Endpoint
The app expects an OpenAI-compatible endpoint at:
@ -37,3 +55,5 @@ Example base URLs:
- An emulator host-loopback URL ending in `:11434/v1` for a local OpenAI-compatible service
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.

View file

@ -1,9 +1,14 @@
package com.danvics.calorieai;
import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.database.Cursor;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.RectF;
import android.graphics.Typeface;
import android.graphics.drawable.GradientDrawable;
import android.net.Uri;
@ -12,12 +17,12 @@ import android.provider.OpenableColumns;
import android.text.InputType;
import android.util.Base64;
import android.view.Gravity;
import android.view.View;
import android.widget.Button;
import android.widget.EditText;
import android.widget.LinearLayout;
import android.widget.ScrollView;
import android.widget.TextView;
import android.database.Cursor;
import org.json.JSONArray;
import org.json.JSONException;
@ -71,12 +76,13 @@ public class MainActivity extends Activity {
title.setTextColor(Color.rgb(38, 55, 45));
root.addView(title);
TextView subtitle = text("Track what you eat each day. Add a description, portion estimate, and optional image for AI calorie analysis.", 15, false);
TextView subtitle = text("Log meals with text, portions, and uploaded images. The vision model estimates the food in the image, then the task model normalizes calories, macros, fruit, and vegetable servings.", 15, false);
subtitle.setTextColor(Color.rgb(91, 99, 92));
subtitle.setPadding(0, dp(6), 0, dp(18));
root.addView(subtitle);
addTodaySummary();
addAnalytics();
addMealForm();
addEntries();
addAdminSettings();
@ -85,31 +91,42 @@ public class MainActivity extends Activity {
}
private void addTodaySummary() {
JSONArray todayEntries = entriesForDate(todayKey());
int calories = 0;
int protein = 0;
int carbs = 0;
int fat = 0;
for (int i = 0; i < todayEntries.length(); i++) {
JSONObject entry = todayEntries.optJSONObject(i);
if (entry == null) continue;
calories += entry.optInt("calories");
protein += entry.optInt("proteinGrams");
carbs += entry.optInt("carbsGrams");
fat += entry.optInt("fatGrams");
}
DayTotals today = totalsForDate(todayKey());
LinearLayout card = card();
TextView heading = text("Today", 18, true);
card.addView(heading);
card.addView(text(todayKey() + " | " + todayEntries.length() + " meals", 13, false));
card.addView(text("Today", 18, true));
card.addView(text(today.date + " | " + today.meals + " meals", 13, false));
TextView totals = text(calories + " kcal", 34, true);
TextView totals = text(today.calories + " kcal", 34, true);
totals.setTextColor(Color.rgb(47, 125, 89));
totals.setPadding(0, dp(8), 0, dp(2));
card.addView(totals);
card.addView(text("Protein " + protein + "g | Carbs " + carbs + "g | Fat " + fat + "g", 14, false));
card.addView(text("Protein " + today.protein + "g | Carbs " + today.carbs + "g | Fat " + today.fat + "g", 14, false));
card.addView(text("Fruit " + servings(today.fruit) + " servings | Vegetables " + servings(today.vegetables) + " servings", 14, false));
root.addView(card);
}
private void addAnalytics() {
DayTotals today = totalsForDate(todayKey());
DayTotals[] week = weeklyTotals();
LinearLayout card = card();
card.addView(text("Charts", 20, true));
TextView note = text("Calories, macros, and produce intake for today and the last 7 days.", 13, false);
note.setTextColor(Color.rgb(91, 99, 92));
card.addView(note);
MacroRingView macroRing = new MacroRingView(this, today.protein, today.carbs, today.fat);
macroRing.setLayoutParams(new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, dp(180)));
card.addView(macroRing);
WeeklyCaloriesView caloriesView = new WeeklyCaloriesView(this, week);
caloriesView.setLayoutParams(new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, dp(190)));
card.addView(caloriesView);
ProduceView produceView = new ProduceView(this, week);
produceView.setLayoutParams(new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, dp(160)));
card.addView(produceView);
root.addView(card);
}
@ -117,7 +134,7 @@ public class MainActivity extends Activity {
LinearLayout card = card();
card.addView(text("Add Meal", 20, true));
descriptionInput = input("What did you eat? Example: chicken rice bowl with avocado");
descriptionInput = input("What did you eat? Example: chicken rice bowl with avocado and side salad");
descriptionInput.setMinLines(3);
descriptionInput.setGravity(Gravity.TOP);
card.addView(descriptionInput);
@ -125,7 +142,7 @@ public class MainActivity extends Activity {
measureInput = input("Portion or measure. Example: 450g, 1 large bowl, 2 slices");
card.addView(measureInput);
Button imageButton = button("Choose optional image");
Button imageButton = button("Upload meal image");
imageButton.setOnClickListener(v -> pickImage());
card.addView(imageButton);
@ -134,7 +151,7 @@ public class MainActivity extends Activity {
selectedImageText.setPadding(0, dp(6), 0, dp(6));
card.addView(selectedImageText);
analyzeButton = button("Analyze and save meal");
analyzeButton = button("Analyze image/text and save meal");
analyzeButton.setOnClickListener(v -> analyzeMeal());
card.addView(analyzeButton);
@ -166,7 +183,20 @@ public class MainActivity extends Activity {
card.addView(meal);
card.addView(text(entry.optInt("calories") + " kcal | P " + entry.optInt("proteinGrams")
+ "g | C " + entry.optInt("carbsGrams") + "g | F " + entry.optInt("fatGrams") + "g", 14, false));
card.addView(text("Fruit " + servings(entry.optDouble("fruitServings"))
+ " | Vegetables " + servings(entry.optDouble("vegetableServings")), 13, false));
if (entry.optBoolean("imageIncluded")) {
TextView image = text("Image analyzed by vision model" + imageSuffix(entry), 12, false);
image.setTextColor(Color.rgb(91, 99, 92));
card.addView(image);
}
card.addView(text(entry.optString("description"), 13, false));
String foodGroups = entry.optString("foodGroups");
if (!foodGroups.isEmpty()) {
TextView groups = text("Groups: " + foodGroups, 12, false);
groups.setTextColor(Color.rgb(91, 99, 92));
card.addView(groups);
}
String notes = entry.optString("notes");
if (!notes.isEmpty()) {
TextView note = text(notes, 13, false);
@ -177,6 +207,11 @@ public class MainActivity extends Activity {
root.addView(card);
}
private String imageSuffix(JSONObject entry) {
String imageName = entry.optString("imageName");
return imageName.isEmpty() ? "" : " (" + imageName + ")";
}
private void addAdminSettings() {
LinearLayout card = card();
card.addView(text("Admin AI Settings", 20, true));
@ -212,11 +247,11 @@ public class MainActivity extends Activity {
apiKey.setText(prefs.getString("api_key", ""));
card.addView(apiKey);
EditText visionModel = input("Vision model");
EditText visionModel = input("Vision model for uploaded food images");
visionModel.setText(prefs.getString("vision_model", "gpt-4o-mini"));
card.addView(visionModel);
EditText taskModel = input("Tasking model");
EditText taskModel = input("Tasking model for nutrition JSON and daily tracking");
taskModel.setText(prefs.getString("task_model", "gpt-4o-mini"));
card.addView(taskModel);
@ -246,7 +281,7 @@ public class MainActivity extends Activity {
});
card.addView(lock);
TextView help = text("The vision model reads attached meal photos. The tasking model estimates calories, macros, and notes from the full meal context. Both fields may use the same model.", 12, false);
TextView help = text("The vision model estimates visible food, quantities, calories, macros, fruit servings, and vegetable servings from the image. The tasking model normalizes that estimate with your text. Both fields may use the same model.", 12, false);
help.setTextColor(Color.rgb(91, 99, 92));
card.addView(help);
root.addView(card);
@ -283,7 +318,7 @@ public class MainActivity extends Activity {
String taskModel = prefs.getString("task_model", "").trim();
if (description.isEmpty() && selectedImageUri == null) {
status("Describe the meal or attach an image.", true);
status("Describe the meal or upload an image.", true);
return;
}
if (baseUrl.isEmpty() || taskModel.isEmpty()) {
@ -292,21 +327,21 @@ public class MainActivity extends Activity {
}
analyzeButton.setEnabled(false);
status("Analyzing meal...", false);
status(selectedImageUri == null ? "Analyzing meal text..." : "Uploading image to vision model...", false);
new Thread(() -> {
try {
String vision = "";
String visionEstimate = "";
if (selectedImageUri != null) {
if (selectedImageDataUri == null) selectedImageDataUri = readImageAsDataUri(selectedImageUri);
String visionModel = prefs.getString("vision_model", "").trim();
if (!visionModel.isEmpty()) {
vision = requestVisionDescription(baseUrl, visionModel, description, selectedImageDataUri);
visionEstimate = requestVisionEstimate(baseUrl, visionModel, description, measure, selectedImageDataUri);
}
}
JSONObject estimate = requestTaskEstimate(baseUrl, taskModel, description, measure, vision);
saveEntry(description, measure, vision, estimate, selectedImageUri != null);
JSONObject estimate = requestTaskEstimate(baseUrl, taskModel, description, measure, visionEstimate);
saveEntry(description, measure, visionEstimate, estimate, selectedImageUri != null, selectedImageName);
runOnUiThread(() -> {
selectedImageUri = null;
@ -323,11 +358,11 @@ public class MainActivity extends Activity {
}).start();
}
private String requestVisionDescription(String baseUrl, String model, String description, String imageDataUri) throws Exception {
private String requestVisionEstimate(String baseUrl, String model, String description, String measure, String imageDataUri) throws Exception {
JSONArray content = new JSONArray();
content.put(new JSONObject()
.put("type", "text")
.put("text", "Describe this food photo for calorie estimation. Include likely foods, portion sizes, visible quantities, sauces, and uncertainty. User description: " + description));
.put("text", "Analyze this meal photo for nutrition tracking. Estimate visible foods, portions, calories, protein grams, carbs grams, fat grams, fruit servings, and vegetable servings. One produce serving is about 80g or one cup raw leafy vegetables. Return concise JSON if possible. User description: " + description + "; user portion note: " + measure));
content.put(new JSONObject()
.put("type", "image_url")
.put("image_url", new JSONObject().put("url", imageDataUri)));
@ -338,21 +373,22 @@ public class MainActivity extends Activity {
JSONObject body = new JSONObject()
.put("model", model)
.put("messages", messages)
.put("temperature", 0.2);
.put("temperature", 0.15);
return chatCompletion(baseUrl, body);
}
private JSONObject requestTaskEstimate(String baseUrl, String model, String description, String measure, String vision) throws Exception {
String prompt = "You are a nutrition logging assistant. Estimate calories and macros for one meal. "
+ "Return only JSON with keys mealName, calories, proteinGrams, carbsGrams, fatGrams, notes. "
+ "Use integer grams and calories. Mention uncertainty in notes.\n\n"
private JSONObject requestTaskEstimate(String baseUrl, String model, String description, String measure, String visionEstimate) throws Exception {
String prompt = "You are a nutrition logging assistant. Produce a final nutrition estimate for one meal. "
+ "If a vision estimate is provided, use it as the primary evidence for visible ingredients and portion sizes. "
+ "Return only JSON with keys mealName, calories, proteinGrams, carbsGrams, fatGrams, fruitServings, vegetableServings, foodGroups, notes. "
+ "Use integer grams and calories. fruitServings and vegetableServings can be decimal numbers. foodGroups should be a short comma-separated string. Mention uncertainty in notes.\n\n"
+ "User description: " + description + "\n"
+ "User measure: " + measure + "\n"
+ "Vision description: " + vision;
+ "Vision nutrition estimate: " + visionEstimate;
JSONArray messages = new JSONArray();
messages.put(new JSONObject().put("role", "system").put("content", "Return strict JSON only."));
messages.put(new JSONObject().put("role", "system").put("content", "Return strict JSON only. Do not wrap the response in markdown."));
messages.put(new JSONObject().put("role", "user").put("content", prompt));
JSONObject body = new JSONObject()
@ -369,7 +405,7 @@ public class MainActivity extends Activity {
HttpURLConnection connection = (HttpURLConnection) new URL(urlString).openConnection();
connection.setRequestMethod("POST");
connection.setConnectTimeout(20000);
connection.setReadTimeout(60000);
connection.setReadTimeout(90000);
connection.setRequestProperty("Content-Type", "application/json");
String apiKey = prefs.getString("api_key", "").trim();
@ -410,11 +446,28 @@ public class MainActivity extends Activity {
.put("proteinGrams", Math.max(0, estimate.optInt("proteinGrams")))
.put("carbsGrams", Math.max(0, estimate.optInt("carbsGrams")))
.put("fatGrams", Math.max(0, estimate.optInt("fatGrams")))
.put("fruitServings", Math.max(0, estimate.optDouble("fruitServings", estimate.optDouble("fruitsServings", 0))))
.put("vegetableServings", Math.max(0, estimate.optDouble("vegetableServings", estimate.optDouble("vegetablesServings", 0))))
.put("foodGroups", stringifyFoodGroups(estimate.opt("foodGroups")))
.put("notes", estimate.optString("notes", ""))
.put("raw", content);
}
private void saveEntry(String description, String measure, String vision, JSONObject estimate, boolean hasImage) throws JSONException {
private String stringifyFoodGroups(Object value) {
if (value == null || value == JSONObject.NULL) return "";
if (value instanceof JSONArray) {
JSONArray array = (JSONArray) value;
StringBuilder builder = new StringBuilder();
for (int i = 0; i < array.length(); i++) {
if (i > 0) builder.append(", ");
builder.append(array.optString(i));
}
return builder.toString();
}
return String.valueOf(value);
}
private void saveEntry(String description, String measure, String visionEstimate, JSONObject estimate, boolean hasImage, String imageName) throws JSONException {
JSONArray entries = allEntries();
JSONObject entry = new JSONObject()
.put("date", todayKey())
@ -422,12 +475,16 @@ public class MainActivity extends Activity {
.put("description", description)
.put("measure", measure)
.put("imageIncluded", hasImage)
.put("visionDescription", vision)
.put("imageName", imageName == null ? "" : imageName)
.put("visionEstimate", visionEstimate)
.put("mealName", estimate.optString("mealName", "Meal"))
.put("calories", estimate.optInt("calories"))
.put("proteinGrams", estimate.optInt("proteinGrams"))
.put("carbsGrams", estimate.optInt("carbsGrams"))
.put("fatGrams", estimate.optInt("fatGrams"))
.put("fruitServings", estimate.optDouble("fruitServings"))
.put("vegetableServings", estimate.optDouble("vegetableServings"))
.put("foodGroups", estimate.optString("foodGroups"))
.put("notes", estimate.optString("notes"))
.put("rawAi", estimate.optString("raw"));
entries.put(entry);
@ -452,6 +509,34 @@ public class MainActivity extends Activity {
return filtered;
}
private DayTotals totalsForDate(String date) {
DayTotals totals = new DayTotals(date, date.substring(5));
JSONArray entries = entriesForDate(date);
totals.meals = entries.length();
for (int i = 0; i < entries.length(); i++) {
JSONObject entry = entries.optJSONObject(i);
if (entry == null) continue;
totals.calories += entry.optInt("calories");
totals.protein += entry.optInt("proteinGrams");
totals.carbs += entry.optInt("carbsGrams");
totals.fat += entry.optInt("fatGrams");
totals.fruit += entry.optDouble("fruitServings", entry.optDouble("fruitsServings", 0));
totals.vegetables += entry.optDouble("vegetableServings", entry.optDouble("vegetablesServings", 0));
}
return totals;
}
private DayTotals[] weeklyTotals() {
DayTotals[] days = new DayTotals[7];
LocalDate today = LocalDate.now();
for (int i = 0; i < 7; i++) {
LocalDate day = today.minusDays(6 - i);
days[i] = totalsForDate(day.toString());
days[i].label = day.getDayOfWeek().toString().substring(0, 3);
}
return days;
}
private String readImageAsDataUri(Uri uri) throws Exception {
try (InputStream input = getContentResolver().openInputStream(uri)) {
if (input == null) throw new IllegalStateException("Could not read selected image");
@ -490,6 +575,10 @@ public class MainActivity extends Activity {
return LocalDate.now().toString();
}
private String servings(double value) {
return String.format(java.util.Locale.US, "%.1f", value);
}
private void status(String message, boolean error) {
statusText.setText(message);
statusText.setTextColor(error ? Color.rgb(164, 52, 52) : Color.rgb(47, 125, 89));
@ -566,4 +655,177 @@ public class MainActivity extends Activity {
private int dp(int value) {
return Math.round(value * getResources().getDisplayMetrics().density);
}
private static class DayTotals {
String date;
String label;
int calories;
int protein;
int carbs;
int fat;
double fruit;
double vegetables;
int meals;
DayTotals(String date, String label) {
this.date = date;
this.label = label;
}
}
private static class MacroRingView extends View {
private final int protein;
private final int carbs;
private final int fat;
private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
MacroRingView(Context context, int protein, int carbs, int fat) {
super(context);
this.protein = protein;
this.carbs = carbs;
this.fat = fat;
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
float density = getResources().getDisplayMetrics().density;
int total = Math.max(0, protein) + Math.max(0, carbs) + Math.max(0, fat);
float left = 22 * density;
float top = 24 * density;
float size = Math.min(getHeight() - 42 * density, getWidth() * 0.42f);
RectF oval = new RectF(left, top, left + size, top + size);
paint.setStyle(Paint.Style.STROKE);
paint.setStrokeWidth(18 * density);
paint.setStrokeCap(Paint.Cap.ROUND);
if (total == 0) {
paint.setColor(Color.rgb(229, 221, 207));
canvas.drawArc(oval, -90, 360, false, paint);
} else {
float start = -90;
start = arc(canvas, oval, start, protein * 360f / total, Color.rgb(47, 125, 89));
start = arc(canvas, oval, start, carbs * 360f / total, Color.rgb(218, 150, 72));
arc(canvas, oval, start, fat * 360f / total, Color.rgb(97, 119, 196));
}
paint.setStyle(Paint.Style.FILL);
paint.setTextSize(15 * density);
paint.setTypeface(Typeface.DEFAULT_BOLD);
paint.setColor(Color.rgb(38, 55, 45));
float x = left + size + 28 * density;
canvas.drawText("Macros today", x, top + 18 * density, paint);
paint.setTypeface(Typeface.DEFAULT);
legend(canvas, x, top + 50 * density, Color.rgb(47, 125, 89), "Protein " + protein + "g");
legend(canvas, x, top + 78 * density, Color.rgb(218, 150, 72), "Carbs " + carbs + "g");
legend(canvas, x, top + 106 * density, Color.rgb(97, 119, 196), "Fat " + fat + "g");
}
private float arc(Canvas canvas, RectF oval, float start, float sweep, int color) {
paint.setColor(color);
canvas.drawArc(oval, start, sweep, false, paint);
return start + sweep;
}
private void legend(Canvas canvas, float x, float y, int color, String label) {
float density = getResources().getDisplayMetrics().density;
paint.setStyle(Paint.Style.FILL);
paint.setColor(color);
canvas.drawCircle(x, y - 5 * density, 5 * density, paint);
paint.setColor(Color.rgb(38, 55, 45));
paint.setTextSize(13 * density);
canvas.drawText(label, x + 14 * density, y, paint);
}
}
private static class WeeklyCaloriesView extends View {
private final DayTotals[] days;
private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
WeeklyCaloriesView(Context context, DayTotals[] days) {
super(context);
this.days = days;
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
float density = getResources().getDisplayMetrics().density;
paint.setColor(Color.rgb(38, 55, 45));
paint.setTypeface(Typeface.DEFAULT_BOLD);
paint.setTextSize(15 * density);
canvas.drawText("Calories by day", 8 * density, 22 * density, paint);
int max = 1;
for (DayTotals day : days) max = Math.max(max, day.calories);
float chartTop = 40 * density;
float chartHeight = getHeight() - 80 * density;
float barGap = 8 * density;
float barWidth = (getWidth() - 16 * density - barGap * (days.length - 1)) / days.length;
paint.setTypeface(Typeface.DEFAULT);
for (int i = 0; i < days.length; i++) {
DayTotals day = days[i];
float left = 8 * density + i * (barWidth + barGap);
float height = chartHeight * day.calories / max;
float top = chartTop + chartHeight - height;
paint.setColor(Color.rgb(232, 238, 232));
canvas.drawRoundRect(left, chartTop, left + barWidth, chartTop + chartHeight, 8 * density, 8 * density, paint);
paint.setColor(Color.rgb(47, 125, 89));
canvas.drawRoundRect(left, top, left + barWidth, chartTop + chartHeight, 8 * density, 8 * density, paint);
paint.setColor(Color.rgb(91, 99, 92));
paint.setTextSize(10 * density);
canvas.drawText(day.label, left, chartTop + chartHeight + 18 * density, paint);
}
}
}
private static class ProduceView extends View {
private final DayTotals[] days;
private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
ProduceView(Context context, DayTotals[] days) {
super(context);
this.days = days;
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
float density = getResources().getDisplayMetrics().density;
paint.setColor(Color.rgb(38, 55, 45));
paint.setTypeface(Typeface.DEFAULT_BOLD);
paint.setTextSize(15 * density);
canvas.drawText("Fruit and vegetables", 8 * density, 22 * density, paint);
double max = 1;
for (DayTotals day : days) max = Math.max(max, day.fruit + day.vegetables);
float chartTop = 42 * density;
float chartHeight = getHeight() - 72 * density;
float groupGap = 8 * density;
float groupWidth = (getWidth() - 16 * density - groupGap * (days.length - 1)) / days.length;
float barWidth = (groupWidth - 4 * density) / 2f;
paint.setTypeface(Typeface.DEFAULT);
for (int i = 0; i < days.length; i++) {
DayTotals day = days[i];
float left = 8 * density + i * (groupWidth + groupGap);
drawProduceBar(canvas, left, chartTop, chartHeight, barWidth, day.fruit, max, Color.rgb(218, 150, 72));
drawProduceBar(canvas, left + barWidth + 4 * density, chartTop, chartHeight, barWidth, day.vegetables, max, Color.rgb(47, 125, 89));
paint.setColor(Color.rgb(91, 99, 92));
paint.setTextSize(10 * density);
canvas.drawText(day.label, left, chartTop + chartHeight + 18 * density, paint);
}
}
private void drawProduceBar(Canvas canvas, float left, float chartTop, float chartHeight, float barWidth, double value, double max, int color) {
float density = getResources().getDisplayMetrics().density;
float height = (float) (chartHeight * value / max);
float top = chartTop + chartHeight - height;
paint.setColor(Color.rgb(232, 238, 232));
canvas.drawRoundRect(left, chartTop, left + barWidth, chartTop + chartHeight, 6 * density, 6 * density, paint);
paint.setColor(color);
canvas.drawRoundRect(left, top, left + barWidth, chartTop + chartHeight, 6 * density, 6 * density, paint);
}
}
}

3
web/.dockerignore Normal file
View file

@ -0,0 +1,3 @@
node_modules
npm-debug.log
.DS_Store

9
web/Dockerfile Normal file
View file

@ -0,0 +1,9 @@
FROM node:22-alpine
WORKDIR /app
COPY package.json server.js ./
COPY public ./public
ENV PORT=8080
EXPOSE 8080
CMD ["node", "server.js"]

7
web/docker-compose.yml Normal file
View file

@ -0,0 +1,7 @@
services:
calorie-ai-web:
build: .
container_name: calorie-ai-web
restart: unless-stopped
ports:
- "127.0.0.1:8095:8080"

78
web/package-lock.json generated Normal file
View file

@ -0,0 +1,78 @@
{
"name": "calorie-ai-web",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "calorie-ai-web",
"version": "0.1.0",
"devDependencies": {
"@playwright/test": "^1.56.1"
}
},
"node_modules/@playwright/test": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
"integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.60.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.60.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
}
}
}

12
web/package.json Normal file
View file

@ -0,0 +1,12 @@
{
"name": "calorie-ai-web",
"version": "0.1.0",
"private": true,
"scripts": {
"start": "node server.js",
"test": "playwright test"
},
"devDependencies": {
"@playwright/test": "^1.56.1"
}
}

9
web/playwright.config.js Normal file
View file

@ -0,0 +1,9 @@
const { defineConfig } = require('@playwright/test');
module.exports = defineConfig({
testDir: './tests',
timeout: 30000,
use: {
baseURL: 'http://127.0.0.1:8095',
},
});

302
web/public/app.js Normal file
View file

@ -0,0 +1,302 @@
const $ = (id) => document.getElementById(id);
const settingsKey = 'calorie-ai-web-settings';
const entriesKey = 'calorie-ai-web-entries';
let selectedImageDataUrl = '';
let selectedImageName = '';
function todayKey() {
return dateKey(new Date());
}
function dateKey(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function readJson(key, fallback) {
try { return JSON.parse(localStorage.getItem(key)) || fallback; } catch { return fallback; }
}
function saveJson(key, value) {
localStorage.setItem(key, JSON.stringify(value));
}
function settings() {
return readJson(settingsKey, { baseUrl: '', apiKey: '', visionModel: 'gpt-4o-mini', taskModel: 'gpt-4o-mini' });
}
function entries() {
return readJson(entriesKey, []);
}
function setStatus(message, error = false) {
$('status').textContent = message;
$('status').className = `status ${error ? 'error' : 'ok'}`;
}
function fileToDataUrl(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
}
async function chatCompletion(body) {
const cfg = settings();
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ baseUrl: cfg.baseUrl, apiKey: cfg.apiKey, body }),
});
const text = await response.text();
if (!response.ok) throw new Error(text);
const json = JSON.parse(text);
return json.choices[0].message.content.trim();
}
async function requestVisionEstimate(description, measure, imageDataUrl) {
const cfg = settings();
if (!cfg.visionModel) return '';
return chatCompletion({
model: cfg.visionModel,
temperature: 0.15,
messages: [{
role: 'user',
content: [
{ type: 'text', text: `Analyze this meal photo for nutrition tracking. Estimate visible foods, portions, calories, protein grams, carbs grams, fat grams, fruit servings, and vegetable servings. One produce serving is about 80g or one cup raw leafy vegetables. Return concise JSON if possible. User description: ${description}; user portion note: ${measure}` },
{ type: 'image_url', image_url: { url: imageDataUrl } },
],
}],
});
}
async function requestTaskEstimate(description, measure, visionEstimate) {
const cfg = settings();
return chatCompletion({
model: cfg.taskModel,
temperature: 0.1,
messages: [
{ role: 'system', content: 'Return strict JSON only. Do not wrap the response in markdown.' },
{ role: 'user', content: `You are a nutrition logging assistant. Produce a final nutrition estimate for one meal. If a vision estimate is provided, use it as primary evidence for visible ingredients and portion sizes. Return only JSON with keys mealName, calories, proteinGrams, carbsGrams, fatGrams, fruitServings, vegetableServings, foodGroups, notes. Use integer grams and calories. fruitServings and vegetableServings can be decimal numbers. foodGroups should be a short comma-separated string. Mention uncertainty in notes.\n\nUser description: ${description}\nUser measure: ${measure}\nVision nutrition estimate: ${visionEstimate}` },
],
});
}
function parseEstimate(content) {
let cleaned = content.trim().replace(/^```json/i, '').replace(/^```/, '').replace(/```$/, '').trim();
const start = cleaned.indexOf('{');
const end = cleaned.lastIndexOf('}');
if (start >= 0 && end > start) cleaned = cleaned.slice(start, end + 1);
const parsed = JSON.parse(cleaned);
return {
mealName: parsed.mealName || 'Meal',
calories: Math.max(0, Number.parseInt(parsed.calories || 0, 10)),
proteinGrams: Math.max(0, Number.parseInt(parsed.proteinGrams || 0, 10)),
carbsGrams: Math.max(0, Number.parseInt(parsed.carbsGrams || 0, 10)),
fatGrams: Math.max(0, Number.parseInt(parsed.fatGrams || 0, 10)),
fruitServings: Math.max(0, Number(parsed.fruitServings ?? parsed.fruitsServings ?? 0)),
vegetableServings: Math.max(0, Number(parsed.vegetableServings ?? parsed.vegetablesServings ?? 0)),
foodGroups: Array.isArray(parsed.foodGroups) ? parsed.foodGroups.join(', ') : String(parsed.foodGroups || ''),
notes: parsed.notes || '',
raw: content,
};
}
function totalsFor(date) {
return entries().filter(e => e.date === date).reduce((sum, e) => ({
meals: sum.meals + 1,
calories: sum.calories + (e.calories || 0),
protein: sum.protein + (e.proteinGrams || 0),
carbs: sum.carbs + (e.carbsGrams || 0),
fat: sum.fat + (e.fatGrams || 0),
fruit: sum.fruit + (e.fruitServings || 0),
vegetables: sum.vegetables + (e.vegetableServings || 0),
}), { meals: 0, calories: 0, protein: 0, carbs: 0, fat: 0, fruit: 0, vegetables: 0 });
}
function last7Days() {
const now = new Date();
return Array.from({ length: 7 }, (_, index) => {
const d = new Date(now);
d.setDate(now.getDate() - (6 - index));
const date = dateKey(d);
return { date, label: d.toLocaleDateString(undefined, { weekday: 'short' }), ...totalsFor(date) };
});
}
function drawMacroChart(total) {
const canvas = $('macroChart');
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
const values = [total.protein, total.carbs, total.fat];
const colors = ['#2f7d59', '#da9648', '#6177c4'];
const labels = [`Protein ${total.protein}g`, `Carbs ${total.carbs}g`, `Fat ${total.fat}g`];
const sum = values.reduce((a, b) => a + b, 0) || 1;
let start = -Math.PI / 2;
values.forEach((value, index) => {
const arc = (value / sum) * Math.PI * 2;
ctx.beginPath();
ctx.lineWidth = 34;
ctx.strokeStyle = colors[index];
ctx.arc(120, 130, 70, start, start + arc);
ctx.stroke();
start += arc;
});
ctx.fillStyle = '#26372d';
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.fillText(label, 264, 125 + index * 34);
});
}
function drawBars(canvasId, days, mode) {
const canvas = $(canvasId);
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
const values = days.map(d => mode === 'calories' ? d.calories : d.fruit + d.vegetables);
const max = Math.max(1, ...values);
const left = 26;
const bottom = canvas.height - 44;
const height = canvas.height - 76;
const gap = 14;
const bar = (canvas.width - left * 2 - gap * 6) / 7;
days.forEach((day, index) => {
const x = left + index * (bar + gap);
const h = values[index] / max * height;
ctx.fillStyle = '#eef3ee';
roundRect(ctx, x, bottom - height, bar, height, 12);
ctx.fillStyle = mode === 'calories' ? '#2f7d59' : '#da9648';
roundRect(ctx, x, bottom - h, bar, h, 12);
if (mode === 'produce') {
const vegH = day.vegetables / max * height;
ctx.fillStyle = '#2f7d59';
roundRect(ctx, x + bar * .52, bottom - vegH, bar * .48, vegH, 10);
}
ctx.fillStyle = '#5b635c';
ctx.font = '13px sans-serif';
ctx.fillText(day.label, x, bottom + 24);
});
}
function roundRect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.roundRect(x, y, w, Math.max(1, h), r);
ctx.fill();
}
function renderEntries() {
const container = $('entries');
const list = [...entries()].sort((a, b) => `${b.date} ${b.time}`.localeCompare(`${a.date} ${a.time}`));
if (!list.length) {
container.innerHTML = '<p class="muted">No meals logged yet.</p>';
return;
}
container.innerHTML = list.map(e => `
<article class="entry">
<strong>${escapeHtml(e.date)} ${escapeHtml(e.time)} - ${escapeHtml(e.mealName)}</strong>
<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.notes ? `<small>${escapeHtml(e.notes)}</small>` : ''}
</article>`).join('');
}
function escapeHtml(value) {
return String(value).replace(/[&<>"]/g, ch => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[ch]));
}
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();
drawMacroChart(today);
drawBars('calorieChart', week, 'calories');
drawBars('produceChart', week, 'produce');
renderEntries();
}
function loadSettings() {
const cfg = settings();
$('baseUrl').value = cfg.baseUrl;
$('apiKey').value = cfg.apiKey;
$('visionModel').value = cfg.visionModel;
$('taskModel').value = cfg.taskModel;
}
$('saveSettings').addEventListener('click', () => {
saveJson(settingsKey, {
baseUrl: $('baseUrl').value.trim(),
apiKey: $('apiKey').value.trim(),
visionModel: $('visionModel').value.trim(),
taskModel: $('taskModel').value.trim(),
});
});
$('image').addEventListener('change', async (event) => {
const file = event.target.files[0];
selectedImageDataUrl = file ? await fileToDataUrl(file) : '';
selectedImageName = file ? file.name : '';
$('preview').hidden = !selectedImageDataUrl;
$('preview').src = selectedImageDataUrl || '';
});
$('mealForm').addEventListener('submit', async (event) => {
event.preventDefault();
const cfg = settings();
const description = $('description').value.trim();
const measure = $('measure').value.trim();
if (!description && !selectedImageDataUrl) return setStatus('Describe the meal or upload an image.', true);
if (!cfg.baseUrl || !cfg.taskModel) return setStatus('Save API base URL and tasking model first.', true);
const button = event.submitter;
button.disabled = true;
try {
setStatus(selectedImageDataUrl ? 'Uploading image to vision model...' : 'Analyzing meal text...');
const vision = selectedImageDataUrl ? await requestVisionEstimate(description, measure, selectedImageDataUrl) : '';
setStatus('Normalizing calories and macros...');
const estimate = parseEstimate(await requestTaskEstimate(description, measure, vision));
const now = new Date();
const next = [...entries(), {
...estimate,
date: todayKey(),
time: now.toTimeString().slice(0, 5),
description,
measure,
imageIncluded: Boolean(selectedImageDataUrl),
imageName: selectedImageName,
visionEstimate: vision,
}];
saveJson(entriesKey, next);
$('mealForm').reset();
selectedImageDataUrl = '';
selectedImageName = '';
$('preview').hidden = true;
setStatus('Saved.');
render();
} catch (error) {
setStatus(error.message, true);
} finally {
button.disabled = false;
}
});
$('clearData').addEventListener('click', () => {
if (!confirm('Clear the local diary on this browser?')) return;
saveJson(entriesKey, []);
render();
});
loadSettings();
render();

5
web/public/favicon.svg Normal file
View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="16" fill="#2f7d59"/>
<path d="M20 40c0-10 8-22 24-22 1 16-8 26-21 26-1 0-2 0-3-.2 3-6 8-11 15-15-8 2-14 6-18 13-2-4-2-9 3-14z" fill="#f8f4ec"/>
<circle cx="42" cy="42" r="6" fill="#da9648"/>
</svg>

After

Width:  |  Height:  |  Size: 298 B

82
web/public/index.html Normal file
View file

@ -0,0 +1,82 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Calorie AI</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<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>
<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>
</div>
</section>
<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>
<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>
<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">
<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>
<script src="/app.js"></script>
</body>
</html>

80
web/public/styles.css Normal file
View file

@ -0,0 +1,80 @@
:root {
color-scheme: light;
--bg: #f8f4ec;
--ink: #26372d;
--muted: #5b635c;
--card: #ffffff;
--line: #e5ddcf;
--green: #2f7d59;
--orange: #da9648;
--blue: #6177c4;
--soft: #eef3ee;
}
* { box-sizing: border-box; }
body {
margin: 0;
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);
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 {
border: 0;
border-radius: 14px;
background: var(--green);
color: white;
cursor: pointer;
font: inherit;
font-weight: 800;
padding: 12px 15px;
}
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; }
}

80
web/server.js Normal file
View file

@ -0,0 +1,80 @@
const http = require('http');
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 types = {
'.html': 'text/html; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.js': 'application/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.svg': 'image/svg+xml',
};
function readBody(req) {
return new Promise((resolve, reject) => {
let body = '';
req.on('data', chunk => {
body += chunk;
if (body.length > 12 * 1024 * 1024) {
req.destroy();
reject(new Error('Request body is too large'));
}
});
req.on('end', () => resolve(body));
req.on('error', reject);
});
}
function send(res, status, body, contentType = 'application/json; charset=utf-8', headOnly = false) {
res.writeHead(status, {
'content-type': contentType,
'cache-control': 'no-store',
});
res.end(headOnly ? '' : body);
}
function serveStatic(req, res) {
const cleanUrl = decodeURIComponent(req.url.split('?')[0]);
const file = cleanUrl === '/' ? '/index.html' : cleanUrl;
const target = path.normalize(path.join(publicDir, file));
if (!target.startsWith(publicRoot)) return send(res, 403, 'Forbidden', 'text/plain; charset=utf-8');
fs.readFile(target, (err, data) => {
if (err) return send(res, 404, 'Not found', 'text/plain; charset=utf-8');
send(res, 200, data, types[path.extname(target)] || 'application/octet-stream', req.method === 'HEAD');
});
}
async function proxyChat(req, res) {
try {
const payload = JSON.parse(await readBody(req));
const baseUrl = String(payload.baseUrl || '').replace(/\/+$/, '');
if (!/^https?:\/\//.test(baseUrl)) throw new Error('A valid API base URL is required');
if (!payload.body || typeof payload.body !== 'object') throw new Error('Missing chat completion body');
const headers = { 'content-type': 'application/json' };
if (payload.apiKey) headers.authorization = `Bearer ${payload.apiKey}`;
const upstream = await fetch(`${baseUrl}/chat/completions`, {
method: 'POST',
headers,
body: JSON.stringify(payload.body),
});
const text = await upstream.text();
send(res, upstream.status, text);
} catch (error) {
send(res, 400, JSON.stringify({ error: error.message }));
}
}
http.createServer((req, res) => {
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');
}).listen(port, () => {
console.log(`Calorie AI web listening on ${port}`);
});

59
web/tests/app.spec.js Normal file
View file

@ -0,0 +1,59 @@
const { test, expect } = require('@playwright/test');
const png = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=',
'base64',
);
test.beforeEach(async ({ page }) => {
await page.goto('/');
await page.evaluate(() => localStorage.clear());
await page.reload();
});
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 } }] }),
});
});
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.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 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();
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.');
});