Add web calorie tracker and analytics
This commit is contained in:
parent
3e4e7aa524
commit
71c8837b48
16 changed files with 1105 additions and 53 deletions
41
.forgejo/workflows/web.yml
Normal file
41
.forgejo/workflows/web.yml
Normal 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
3
.gitignore
vendored
|
|
@ -5,3 +5,6 @@ dist/
|
|||
local.properties
|
||||
*.iml
|
||||
.idea/
|
||||
web/node_modules/
|
||||
web/test-results/
|
||||
web/playwright-report/
|
||||
|
|
|
|||
34
README.md
34
README.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
3
web/.dockerignore
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
node_modules
|
||||
npm-debug.log
|
||||
.DS_Store
|
||||
9
web/Dockerfile
Normal file
9
web/Dockerfile
Normal 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
7
web/docker-compose.yml
Normal 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
78
web/package-lock.json
generated
Normal 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
12
web/package.json
Normal 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
9
web/playwright.config.js
Normal 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
302
web/public/app.js
Normal 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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
5
web/public/favicon.svg
Normal 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
82
web/public/index.html
Normal 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
80
web/public/styles.css
Normal 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
80
web/server.js
Normal 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
59
web/tests/app.spec.js
Normal 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.');
|
||||
});
|
||||
Loading…
Reference in a new issue