Compare commits
No commits in common. "f57c04c989bd8aed029d9d2022527b00dd77b729" and "3e4e7aa524589f2989f3fc8d6aeb8c108433aa5e" have entirely different histories.
f57c04c989
...
3e4e7aa524
19 changed files with 54 additions and 1613 deletions
|
|
@ -1,34 +0,0 @@
|
|||
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: Run Playwright tests
|
||||
run: |
|
||||
cd web
|
||||
CI=true \
|
||||
CALORIE_AI_WEB_USER=admin \
|
||||
CALORIE_AI_WEB_PASSWORD=test-password \
|
||||
CALORIE_AI_SESSION_SECRET=test-session-secret \
|
||||
npm test
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -5,8 +5,3 @@ dist/
|
|||
local.properties
|
||||
*.iml
|
||||
.idea/
|
||||
web/node_modules/
|
||||
web/test-results/
|
||||
web/playwright-report/
|
||||
web/data/
|
||||
web/.env
|
||||
|
|
|
|||
36
README.md
36
README.md
|
|
@ -1,18 +1,17 @@
|
|||
# Calorie AI
|
||||
# Calorie AI Android
|
||||
|
||||
Native Android and Dockerized web calorie tracker with uploaded meal images and admin-configurable AI models.
|
||||
Native Android calorie tracker with optional meal images and admin-configurable AI models.
|
||||
|
||||
## Features
|
||||
|
||||
- Add meals by description, portion estimate, and uploaded image.
|
||||
- Add meals by description, portion estimate, and optional image.
|
||||
- Analyze meals through OpenAI-compatible chat completions.
|
||||
- Uses a vision model for food image calorie and portion estimates.
|
||||
- Uses a tasking model to normalize calories, protein, carbs, fat, fruit servings, vegetable servings, food groups, and notes.
|
||||
- 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.
|
||||
- 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.
|
||||
- Admin settings control API base URL, API key, vision model, and tasking model. The two model fields can contain the same model name.
|
||||
|
||||
## Android Build
|
||||
## Build
|
||||
|
||||
Open this folder in Android Studio and run the `app` configuration, or build with Gradle if available:
|
||||
|
||||
|
|
@ -24,25 +23,6 @@ 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 an authenticated browser UI and a tiny Node proxy at `/api/chat` so OpenAI-compatible endpoints are called server-side instead of directly from the browser.
|
||||
|
||||
Run it with Docker:
|
||||
|
||||
```bash
|
||||
cd web
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Then open:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:8095
|
||||
```
|
||||
|
||||
The Docker web server creates credentials on first boot in `web/data/auth.json` if `CALORIE_AI_WEB_PASSWORD` and `CALORIE_AI_SESSION_SECRET` are not supplied. To pin credentials, copy `web/.env.example` to `web/.env` and set strong values before starting Docker Compose.
|
||||
|
||||
## AI Endpoint
|
||||
|
||||
The app expects an OpenAI-compatible endpoint at:
|
||||
|
|
@ -57,5 +37,3 @@ 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 after web login. The web login is separate from the Android admin PIN.
|
||||
|
|
|
|||
|
|
@ -1,14 +1,9 @@
|
|||
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;
|
||||
|
|
@ -17,12 +12,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;
|
||||
|
|
@ -76,13 +71,12 @@ public class MainActivity extends Activity {
|
|||
title.setTextColor(Color.rgb(38, 55, 45));
|
||||
root.addView(title);
|
||||
|
||||
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);
|
||||
TextView subtitle = text("Track what you eat each day. Add a description, portion estimate, and optional image for AI calorie analysis.", 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();
|
||||
|
|
@ -91,42 +85,31 @@ public class MainActivity extends Activity {
|
|||
}
|
||||
|
||||
private void addTodaySummary() {
|
||||
DayTotals today = totalsForDate(todayKey());
|
||||
LinearLayout card = card();
|
||||
card.addView(text("Today", 18, true));
|
||||
card.addView(text(today.date + " | " + today.meals + " meals", 13, false));
|
||||
JSONArray todayEntries = entriesForDate(todayKey());
|
||||
int calories = 0;
|
||||
int protein = 0;
|
||||
int carbs = 0;
|
||||
int fat = 0;
|
||||
|
||||
TextView totals = text(today.calories + " kcal", 34, true);
|
||||
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");
|
||||
}
|
||||
|
||||
LinearLayout card = card();
|
||||
TextView heading = text("Today", 18, true);
|
||||
card.addView(heading);
|
||||
card.addView(text(todayKey() + " | " + todayEntries.length() + " meals", 13, false));
|
||||
|
||||
TextView totals = text(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 " + 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);
|
||||
|
||||
card.addView(text("Protein " + protein + "g | Carbs " + carbs + "g | Fat " + fat + "g", 14, false));
|
||||
root.addView(card);
|
||||
}
|
||||
|
||||
|
|
@ -134,7 +117,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 and side salad");
|
||||
descriptionInput = input("What did you eat? Example: chicken rice bowl with avocado");
|
||||
descriptionInput.setMinLines(3);
|
||||
descriptionInput.setGravity(Gravity.TOP);
|
||||
card.addView(descriptionInput);
|
||||
|
|
@ -142,7 +125,7 @@ public class MainActivity extends Activity {
|
|||
measureInput = input("Portion or measure. Example: 450g, 1 large bowl, 2 slices");
|
||||
card.addView(measureInput);
|
||||
|
||||
Button imageButton = button("Upload meal image");
|
||||
Button imageButton = button("Choose optional image");
|
||||
imageButton.setOnClickListener(v -> pickImage());
|
||||
card.addView(imageButton);
|
||||
|
||||
|
|
@ -151,7 +134,7 @@ public class MainActivity extends Activity {
|
|||
selectedImageText.setPadding(0, dp(6), 0, dp(6));
|
||||
card.addView(selectedImageText);
|
||||
|
||||
analyzeButton = button("Analyze image/text and save meal");
|
||||
analyzeButton = button("Analyze and save meal");
|
||||
analyzeButton.setOnClickListener(v -> analyzeMeal());
|
||||
card.addView(analyzeButton);
|
||||
|
||||
|
|
@ -183,20 +166,7 @@ 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);
|
||||
|
|
@ -207,11 +177,6 @@ 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));
|
||||
|
|
@ -247,11 +212,11 @@ public class MainActivity extends Activity {
|
|||
apiKey.setText(prefs.getString("api_key", ""));
|
||||
card.addView(apiKey);
|
||||
|
||||
EditText visionModel = input("Vision model for uploaded food images");
|
||||
EditText visionModel = input("Vision model");
|
||||
visionModel.setText(prefs.getString("vision_model", "gpt-4o-mini"));
|
||||
card.addView(visionModel);
|
||||
|
||||
EditText taskModel = input("Tasking model for nutrition JSON and daily tracking");
|
||||
EditText taskModel = input("Tasking model");
|
||||
taskModel.setText(prefs.getString("task_model", "gpt-4o-mini"));
|
||||
card.addView(taskModel);
|
||||
|
||||
|
|
@ -281,7 +246,7 @@ public class MainActivity extends Activity {
|
|||
});
|
||||
card.addView(lock);
|
||||
|
||||
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);
|
||||
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);
|
||||
help.setTextColor(Color.rgb(91, 99, 92));
|
||||
card.addView(help);
|
||||
root.addView(card);
|
||||
|
|
@ -318,7 +283,7 @@ public class MainActivity extends Activity {
|
|||
String taskModel = prefs.getString("task_model", "").trim();
|
||||
|
||||
if (description.isEmpty() && selectedImageUri == null) {
|
||||
status("Describe the meal or upload an image.", true);
|
||||
status("Describe the meal or attach an image.", true);
|
||||
return;
|
||||
}
|
||||
if (baseUrl.isEmpty() || taskModel.isEmpty()) {
|
||||
|
|
@ -327,21 +292,21 @@ public class MainActivity extends Activity {
|
|||
}
|
||||
|
||||
analyzeButton.setEnabled(false);
|
||||
status(selectedImageUri == null ? "Analyzing meal text..." : "Uploading image to vision model...", false);
|
||||
status("Analyzing meal...", false);
|
||||
|
||||
new Thread(() -> {
|
||||
try {
|
||||
String visionEstimate = "";
|
||||
String vision = "";
|
||||
if (selectedImageUri != null) {
|
||||
if (selectedImageDataUri == null) selectedImageDataUri = readImageAsDataUri(selectedImageUri);
|
||||
String visionModel = prefs.getString("vision_model", "").trim();
|
||||
if (!visionModel.isEmpty()) {
|
||||
visionEstimate = requestVisionEstimate(baseUrl, visionModel, description, measure, selectedImageDataUri);
|
||||
vision = requestVisionDescription(baseUrl, visionModel, description, selectedImageDataUri);
|
||||
}
|
||||
}
|
||||
|
||||
JSONObject estimate = requestTaskEstimate(baseUrl, taskModel, description, measure, visionEstimate);
|
||||
saveEntry(description, measure, visionEstimate, estimate, selectedImageUri != null, selectedImageName);
|
||||
JSONObject estimate = requestTaskEstimate(baseUrl, taskModel, description, measure, vision);
|
||||
saveEntry(description, measure, vision, estimate, selectedImageUri != null);
|
||||
|
||||
runOnUiThread(() -> {
|
||||
selectedImageUri = null;
|
||||
|
|
@ -358,11 +323,11 @@ public class MainActivity extends Activity {
|
|||
}).start();
|
||||
}
|
||||
|
||||
private String requestVisionEstimate(String baseUrl, String model, String description, String measure, String imageDataUri) throws Exception {
|
||||
private String requestVisionDescription(String baseUrl, String model, String description, String imageDataUri) throws Exception {
|
||||
JSONArray content = new JSONArray();
|
||||
content.put(new JSONObject()
|
||||
.put("type", "text")
|
||||
.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));
|
||||
.put("text", "Describe this food photo for calorie estimation. Include likely foods, portion sizes, visible quantities, sauces, and uncertainty. User description: " + description));
|
||||
content.put(new JSONObject()
|
||||
.put("type", "image_url")
|
||||
.put("image_url", new JSONObject().put("url", imageDataUri)));
|
||||
|
|
@ -373,22 +338,21 @@ public class MainActivity extends Activity {
|
|||
JSONObject body = new JSONObject()
|
||||
.put("model", model)
|
||||
.put("messages", messages)
|
||||
.put("temperature", 0.15);
|
||||
.put("temperature", 0.2);
|
||||
|
||||
return chatCompletion(baseUrl, body);
|
||||
}
|
||||
|
||||
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"
|
||||
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"
|
||||
+ "User description: " + description + "\n"
|
||||
+ "User measure: " + measure + "\n"
|
||||
+ "Vision nutrition estimate: " + visionEstimate;
|
||||
+ "Vision description: " + vision;
|
||||
|
||||
JSONArray messages = new JSONArray();
|
||||
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", "system").put("content", "Return strict JSON only."));
|
||||
messages.put(new JSONObject().put("role", "user").put("content", prompt));
|
||||
|
||||
JSONObject body = new JSONObject()
|
||||
|
|
@ -405,7 +369,7 @@ public class MainActivity extends Activity {
|
|||
HttpURLConnection connection = (HttpURLConnection) new URL(urlString).openConnection();
|
||||
connection.setRequestMethod("POST");
|
||||
connection.setConnectTimeout(20000);
|
||||
connection.setReadTimeout(90000);
|
||||
connection.setReadTimeout(60000);
|
||||
connection.setRequestProperty("Content-Type", "application/json");
|
||||
|
||||
String apiKey = prefs.getString("api_key", "").trim();
|
||||
|
|
@ -446,28 +410,11 @@ 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 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 {
|
||||
private void saveEntry(String description, String measure, String vision, JSONObject estimate, boolean hasImage) throws JSONException {
|
||||
JSONArray entries = allEntries();
|
||||
JSONObject entry = new JSONObject()
|
||||
.put("date", todayKey())
|
||||
|
|
@ -475,16 +422,12 @@ public class MainActivity extends Activity {
|
|||
.put("description", description)
|
||||
.put("measure", measure)
|
||||
.put("imageIncluded", hasImage)
|
||||
.put("imageName", imageName == null ? "" : imageName)
|
||||
.put("visionEstimate", visionEstimate)
|
||||
.put("visionDescription", vision)
|
||||
.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);
|
||||
|
|
@ -509,34 +452,6 @@ 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");
|
||||
|
|
@ -575,10 +490,6 @@ 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));
|
||||
|
|
@ -655,177 +566,4 @@ 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
node_modules
|
||||
npm-debug.log
|
||||
.DS_Store
|
||||
data
|
||||
.env
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
CALORIE_AI_WEB_USER=admin
|
||||
CALORIE_AI_WEB_PASSWORD=change-this-password
|
||||
CALORIE_AI_SESSION_SECRET=change-this-session-secret
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json server.js ./
|
||||
COPY public ./public
|
||||
|
||||
ENV PORT=8080
|
||||
EXPOSE 8080
|
||||
CMD ["node", "server.js"]
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
services:
|
||||
calorie-ai-web:
|
||||
build: .
|
||||
container_name: calorie-ai-web
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:8095:8080"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
78
web/package-lock.json
generated
78
web/package-lock.json
generated
|
|
@ -1,78 +0,0 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
{
|
||||
"name": "calorie-ai-web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"test": "playwright test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.56.1"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
const { defineConfig } = require('@playwright/test');
|
||||
|
||||
module.exports = defineConfig({
|
||||
testDir: './tests',
|
||||
timeout: 30000,
|
||||
webServer: {
|
||||
command: 'node server.js',
|
||||
url: 'http://127.0.0.1:8096',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
env: {
|
||||
PORT: '8096',
|
||||
CALORIE_AI_WEB_USER: 'admin',
|
||||
CALORIE_AI_WEB_PASSWORD: 'test-password',
|
||||
CALORIE_AI_SESSION_SECRET: 'test-session-secret',
|
||||
},
|
||||
},
|
||||
use: {
|
||||
baseURL: 'http://127.0.0.1:8096',
|
||||
},
|
||||
});
|
||||
|
|
@ -1,338 +0,0 @@
|
|||
const $ = (id) => document.getElementById(id);
|
||||
const settingsKey = 'calorie-ai-web-settings';
|
||||
const entriesKey = 'calorie-ai-web-entries';
|
||||
|
||||
let selectedImageDataUrl = '';
|
||||
let selectedImageName = '';
|
||||
|
||||
function setText(id, value) {
|
||||
const element = $(id);
|
||||
if (element) element.textContent = value;
|
||||
}
|
||||
|
||||
function todayKey() {
|
||||
return dateKey(new Date());
|
||||
}
|
||||
|
||||
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 = '#17251d';
|
||||
ctx.font = '700 20px sans-serif';
|
||||
ctx.fillText('Today', 240, 88);
|
||||
ctx.font = '15px sans-serif';
|
||||
labels.forEach((label, index) => {
|
||||
ctx.fillStyle = colors[index];
|
||||
ctx.fillRect(240, 112 + index * 34, 14, 14);
|
||||
ctx.fillStyle = '#17251d';
|
||||
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.foodGroups ? `<small>Groups: ${escapeHtml(e.foodGroups)}</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());
|
||||
const week = last7Days();
|
||||
const weekAverage = Math.round(week.reduce((sum, day) => sum + day.calories, 0) / week.length);
|
||||
const produce = today.fruit + today.vegetables;
|
||||
|
||||
setText('todayCalories', `${today.calories} kcal`);
|
||||
setText('todayMacroText', `P ${today.protein}g | C ${today.carbs}g | F ${today.fat}g`);
|
||||
setText('todayProduceText', `Fruit ${today.fruit.toFixed(1)} | Veg ${today.vegetables.toFixed(1)}`);
|
||||
setText('mealCountText', String(today.meals));
|
||||
setText('weekAverageText', `${weekAverage} kcal`);
|
||||
setText('produceGoalText', `${produce.toFixed(1)} / 5`);
|
||||
|
||||
drawMacroChart(today);
|
||||
drawBars('calorieChart', week, 'calories');
|
||||
drawBars('produceChart', week, 'produce');
|
||||
renderEntries();
|
||||
}
|
||||
|
||||
function showSection(sectionName) {
|
||||
document.querySelectorAll('.section-page').forEach(section => {
|
||||
section.hidden = section.dataset.section !== sectionName;
|
||||
});
|
||||
document.querySelectorAll('[data-nav]').forEach(button => {
|
||||
button.classList.toggle('active', button.dataset.nav === sectionName);
|
||||
});
|
||||
document.body.classList.remove('menu-open');
|
||||
}
|
||||
|
||||
function loadSettings() {
|
||||
const cfg = settings();
|
||||
$('baseUrl').value = cfg.baseUrl;
|
||||
$('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(),
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-nav]').forEach(button => {
|
||||
button.addEventListener('click', () => showSection(button.dataset.nav));
|
||||
});
|
||||
|
||||
$('menuToggle').addEventListener('click', () => {
|
||||
document.body.classList.toggle('menu-open');
|
||||
});
|
||||
|
||||
$('logout').addEventListener('click', async () => {
|
||||
await fetch('/api/logout', { method: 'POST' });
|
||||
window.location.href = '/login.html';
|
||||
});
|
||||
|
||||
$('image').addEventListener('change', async (event) => {
|
||||
const file = event.target.files[0];
|
||||
selectedImageDataUrl = file ? await fileToDataUrl(file) : '';
|
||||
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();
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
<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>
|
||||
|
Before Width: | Height: | Size: 298 B |
|
|
@ -1,162 +0,0 @@
|
|||
<!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>
|
||||
<div class="app-frame">
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<div class="brand-block">
|
||||
<span class="brand-mark">CA</span>
|
||||
<div>
|
||||
<strong>Calorie AI</strong>
|
||||
<small>Private food diary</small>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="main-nav" aria-label="Main menu">
|
||||
<button class="nav-item active" type="button" data-nav="dashboard">Dashboard</button>
|
||||
<button class="nav-item" type="button" data-nav="log">Log meal</button>
|
||||
<button class="nav-item" type="button" data-nav="analytics">Analytics</button>
|
||||
<button class="nav-item" type="button" data-nav="diary">Diary</button>
|
||||
<button class="nav-item" type="button" data-nav="settings">AI settings</button>
|
||||
</nav>
|
||||
<div class="sidebar-footer">
|
||||
<small>Protected by local web auth</small>
|
||||
<button id="logout" class="ghost" type="button">Sign out</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="content-shell">
|
||||
<header class="topbar">
|
||||
<button id="menuToggle" class="menu-toggle" type="button" aria-label="Open menu">Menu</button>
|
||||
<div>
|
||||
<p class="eyebrow">AI nutrition cockpit</p>
|
||||
<h1>Private food tracking with image-backed nutrition estimates</h1>
|
||||
</div>
|
||||
<button class="primary-action" type="button" data-nav="log">Add meal</button>
|
||||
</header>
|
||||
|
||||
<main class="content">
|
||||
<section class="section-page" data-section="dashboard">
|
||||
<div class="hero-grid">
|
||||
<article class="hero-card">
|
||||
<p class="eyebrow">Today</p>
|
||||
<strong id="todayCalories">0 kcal</strong>
|
||||
<span id="todayMacroText">P 0g | C 0g | F 0g</span>
|
||||
<span id="todayProduceText">Fruit 0.0 | Veg 0.0</span>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<small>Meals logged</small>
|
||||
<strong id="mealCountText">0</strong>
|
||||
<span>Entries saved locally</span>
|
||||
</article>
|
||||
<article class="metric-card warm">
|
||||
<small>7-day average</small>
|
||||
<strong id="weekAverageText">0 kcal</strong>
|
||||
<span>Calories per day</span>
|
||||
</article>
|
||||
<article class="metric-card cool">
|
||||
<small>Produce today</small>
|
||||
<strong id="produceGoalText">0.0 / 5</strong>
|
||||
<span>Fruit + vegetable servings</span>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<section class="panel split-panel">
|
||||
<div>
|
||||
<p class="eyebrow">Quick log</p>
|
||||
<h2>Capture the meal while it is still in front of you.</h2>
|
||||
<p>Upload a plate photo, add a portion note, and let the vision model hand context to the tasking model for clean nutrition JSON.</p>
|
||||
<button type="button" data-nav="log">Open meal logger</button>
|
||||
</div>
|
||||
<canvas id="macroChart" width="420" height="260"></canvas>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class="section-page" data-section="log" hidden>
|
||||
<form id="mealForm" class="panel meal-panel">
|
||||
<div class="panel-heading">
|
||||
<p class="eyebrow">Meal intake</p>
|
||||
<h2>Add meal</h2>
|
||||
<p>Text-only works, but images give the vision model more evidence for portion and produce estimates.</p>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<label class="span-2">
|
||||
Description
|
||||
<textarea id="description" rows="5" placeholder="Example: grilled salmon, rice, broccoli, berries"></textarea>
|
||||
</label>
|
||||
<label>
|
||||
Portion or measure
|
||||
<input id="measure" placeholder="Example: one plate, 450g, 2 cups">
|
||||
</label>
|
||||
<label>
|
||||
Meal image
|
||||
<input id="image" type="file" accept="image/*">
|
||||
</label>
|
||||
</div>
|
||||
<img id="preview" class="preview" alt="Selected meal preview" hidden>
|
||||
<div class="form-actions">
|
||||
<button type="submit">Analyze and save</button>
|
||||
<p id="status" class="status"></p>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="section-page" data-section="analytics" hidden>
|
||||
<div class="grid two analytics-grid">
|
||||
<section class="panel chart-panel wide">
|
||||
<div class="panel-heading compact">
|
||||
<p class="eyebrow">Trend</p>
|
||||
<h2>Calories, last 7 days</h2>
|
||||
</div>
|
||||
<canvas id="calorieChart" width="720" height="260"></canvas>
|
||||
</section>
|
||||
<section class="panel chart-panel wide">
|
||||
<div class="panel-heading compact">
|
||||
<p class="eyebrow">Produce</p>
|
||||
<h2>Fruit and vegetables</h2>
|
||||
</div>
|
||||
<canvas id="produceChart" width="720" height="240"></canvas>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section-page" data-section="diary" hidden>
|
||||
<section class="panel">
|
||||
<div class="row-title">
|
||||
<div>
|
||||
<p class="eyebrow">Local history</p>
|
||||
<h2>Food diary</h2>
|
||||
</div>
|
||||
<button id="clearData" class="secondary" type="button">Clear local diary</button>
|
||||
</div>
|
||||
<div id="entries" class="entries"></div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class="section-page" data-section="settings" hidden>
|
||||
<section class="panel settings-panel">
|
||||
<div class="panel-heading">
|
||||
<p class="eyebrow">Admin</p>
|
||||
<h2>AI settings</h2>
|
||||
<p>These settings stay in browser local storage. The API key is sent only to this server-side proxy, not directly to the model endpoint from the browser.</p>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<label class="span-2">API base URL<input id="baseUrl" placeholder="https://api.openai.com/v1"></label>
|
||||
<label class="span-2">API key<input id="apiKey" type="password" placeholder="Optional for local endpoints"></label>
|
||||
<label>Vision model<input id="visionModel" placeholder="gpt-4o-mini"></label>
|
||||
<label>Tasking model<input id="taskModel" placeholder="gpt-4o-mini"></label>
|
||||
</div>
|
||||
<button id="saveSettings" type="button">Save settings</button>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Sign in - Calorie AI</title>
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
</head>
|
||||
<body class="login-body">
|
||||
<main class="login-shell">
|
||||
<section class="login-brand">
|
||||
<p class="eyebrow">Private nutrition console</p>
|
||||
<h1>Calorie AI</h1>
|
||||
<p>Protected meal logging for calories, macros, fruit, vegetables, and AI image estimates.</p>
|
||||
</section>
|
||||
<form id="loginForm" class="login-card">
|
||||
<span class="brand-mark">CA</span>
|
||||
<h2>Sign in to Calorie AI</h2>
|
||||
<p class="muted">Use the local web credentials configured on this host.</p>
|
||||
<label>Username<input id="username" autocomplete="username" value="admin"></label>
|
||||
<label>Password<input id="password" type="password" autocomplete="current-password" autofocus></label>
|
||||
<button type="submit">Sign in</button>
|
||||
<p id="loginStatus" class="status"></p>
|
||||
</form>
|
||||
</main>
|
||||
<script src="/login.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
const loginForm = document.getElementById('loginForm');
|
||||
const loginStatus = document.getElementById('loginStatus');
|
||||
|
||||
loginForm.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
const button = loginForm.querySelector('button');
|
||||
button.disabled = true;
|
||||
loginStatus.textContent = 'Checking credentials...';
|
||||
loginStatus.className = 'status ok';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/login', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
username: document.getElementById('username').value.trim(),
|
||||
password: document.getElementById('password').value,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Sign in failed' }));
|
||||
throw new Error(error.error || 'Sign in failed');
|
||||
}
|
||||
window.location.href = '/';
|
||||
} catch (error) {
|
||||
loginStatus.textContent = error.message;
|
||||
loginStatus.className = 'status error';
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
|
|
@ -1,246 +0,0 @@
|
|||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f3efe3;
|
||||
--panel: rgba(255, 253, 247, .94);
|
||||
--panel-strong: #fffaf0;
|
||||
--ink: #17251d;
|
||||
--muted: #667268;
|
||||
--line: #e3d8c4;
|
||||
--green: #236b4b;
|
||||
--green-2: #39a36d;
|
||||
--orange: #d5873a;
|
||||
--blue: #5168bc;
|
||||
--nav: #112018;
|
||||
--nav-soft: #1c3427;
|
||||
--danger: #a43434;
|
||||
--shadow: 0 28px 80px rgba(41, 31, 17, .12);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background:
|
||||
radial-gradient(circle at 15% 10%, rgba(255, 226, 154, .65), transparent 28rem),
|
||||
radial-gradient(circle at 80% 0%, rgba(79, 138, 104, .22), transparent 30rem),
|
||||
var(--bg);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
button, input, textarea { font: inherit; }
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(135deg, var(--green), var(--green-2));
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
font-weight: 850;
|
||||
padding: 13px 17px;
|
||||
transition: transform .18s ease, box-shadow .18s ease, opacity .18s ease;
|
||||
}
|
||||
|
||||
button:hover { transform: translateY(-1px); box-shadow: 0 12px 28px rgba(35, 107, 75, .22); }
|
||||
button:disabled { cursor: wait; opacity: .55; transform: none; box-shadow: none; }
|
||||
button.secondary { background: #ebe1d0; color: var(--ink); }
|
||||
button.ghost { width: 100%; background: rgba(255,255,255,.09); color: #f4ead6; box-shadow: none; }
|
||||
|
||||
input, textarea {
|
||||
width: 100%;
|
||||
border: 1px solid #d9ceb8;
|
||||
border-radius: 16px;
|
||||
background: #fffaf2;
|
||||
color: var(--ink);
|
||||
outline: none;
|
||||
padding: 13px 14px;
|
||||
}
|
||||
|
||||
input:focus, textarea:focus { border-color: var(--green-2); box-shadow: 0 0 0 4px rgba(57, 163, 109, .14); }
|
||||
textarea { resize: vertical; }
|
||||
label { display: grid; gap: 8px; color: var(--muted); font-weight: 800; }
|
||||
h1, h2, p { margin-top: 0; }
|
||||
h1 { max-width: 820px; margin-bottom: 0; font-size: clamp(2rem, 4vw, 4.2rem); line-height: .94; letter-spacing: -.06em; }
|
||||
h2 { margin-bottom: 12px; font-size: clamp(1.35rem, 2vw, 2rem); letter-spacing: -.035em; }
|
||||
p { color: var(--muted); line-height: 1.6; }
|
||||
canvas { max-width: 100%; }
|
||||
|
||||
.app-frame { display: grid; grid-template-columns: 280px 1fr; min-height: 100vh; }
|
||||
.sidebar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
padding: 22px;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
gap: 28px;
|
||||
background: linear-gradient(180deg, var(--nav), #0b140f);
|
||||
color: #f8ecd7;
|
||||
}
|
||||
|
||||
.brand-block { display: flex; align-items: center; gap: 12px; }
|
||||
.brand-block strong { display: block; font-size: 1.05rem; }
|
||||
.brand-block small, .sidebar-footer small { color: rgba(248, 236, 215, .62); }
|
||||
.brand-mark {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
border-radius: 17px;
|
||||
background: linear-gradient(135deg, #e7a24e, #3fb473);
|
||||
color: #102017;
|
||||
font-weight: 950;
|
||||
letter-spacing: -.04em;
|
||||
}
|
||||
|
||||
.main-nav { display: grid; align-content: start; gap: 9px; }
|
||||
.nav-item {
|
||||
width: 100%;
|
||||
padding: 13px 14px;
|
||||
border-radius: 15px;
|
||||
background: transparent;
|
||||
color: rgba(248, 236, 215, .72);
|
||||
text-align: left;
|
||||
box-shadow: none;
|
||||
}
|
||||
.nav-item:hover, .nav-item.active { background: var(--nav-soft); color: #fff4df; box-shadow: none; }
|
||||
.sidebar-footer { display: grid; gap: 12px; }
|
||||
|
||||
.content-shell { min-width: 0; }
|
||||
.topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
padding: 26px clamp(18px, 4vw, 48px);
|
||||
border-bottom: 1px solid rgba(227, 216, 196, .7);
|
||||
background: rgba(243, 239, 227, .82);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
.menu-toggle { display: none; background: #e9dfcc; color: var(--ink); box-shadow: none; }
|
||||
.primary-action { white-space: nowrap; }
|
||||
.content { width: min(1180px, calc(100% - 36px)); margin: 0 auto; padding: 28px 0 44px; }
|
||||
.section-page { display: grid; gap: 22px; }
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 8px;
|
||||
color: var(--green);
|
||||
font-size: .76rem;
|
||||
font-weight: 950;
|
||||
letter-spacing: .14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hero-grid { display: grid; grid-template-columns: minmax(300px, 1.4fr) repeat(3, minmax(150px, .62fr)); gap: 18px; }
|
||||
.hero-card, .metric-card, .panel, .login-card, .login-brand {
|
||||
border: 1px solid rgba(227, 216, 196, .9);
|
||||
border-radius: 28px;
|
||||
background: var(--panel);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.hero-card {
|
||||
min-height: 220px;
|
||||
padding: 28px;
|
||||
display: grid;
|
||||
align-content: end;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(22, 42, 30, .9), rgba(35, 107, 75, .84)),
|
||||
radial-gradient(circle at top right, rgba(235, 173, 79, .6), transparent 20rem);
|
||||
color: white;
|
||||
}
|
||||
.hero-card .eyebrow, .hero-card span { color: rgba(255,255,255,.78); }
|
||||
.hero-card strong { display: block; margin-bottom: 8px; font-size: clamp(3rem, 8vw, 5.6rem); line-height: .9; letter-spacing: -.07em; }
|
||||
.hero-card span { display: block; margin-top: 6px; }
|
||||
.metric-card { padding: 22px; display: grid; align-content: end; gap: 8px; background: linear-gradient(180deg, #fffdf8, #f7efde); }
|
||||
.metric-card small { color: var(--muted); font-weight: 850; text-transform: uppercase; letter-spacing: .08em; }
|
||||
.metric-card strong { font-size: 2.2rem; line-height: 1; letter-spacing: -.05em; }
|
||||
.metric-card span { color: var(--muted); font-size: .92rem; }
|
||||
.metric-card.warm strong { color: var(--orange); }
|
||||
.metric-card.cool strong { color: var(--blue); }
|
||||
|
||||
.panel { padding: clamp(20px, 3vw, 32px); }
|
||||
.split-panel { display: grid; grid-template-columns: minmax(280px, .85fr) minmax(320px, 1fr); gap: 24px; align-items: center; }
|
||||
.panel-heading { max-width: 760px; margin-bottom: 20px; }
|
||||
.panel-heading.compact { margin-bottom: 8px; }
|
||||
.grid { display: grid; gap: 20px; }
|
||||
.grid.two { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
|
||||
.span-2 { grid-column: span 2; }
|
||||
.form-actions { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; margin-top: 18px; }
|
||||
.meal-panel { background: linear-gradient(135deg, rgba(255,253,247,.96), rgba(244, 233, 210, .94)); }
|
||||
.preview { width: 100%; max-height: 360px; object-fit: cover; border: 1px solid var(--line); border-radius: 24px; margin-top: 18px; }
|
||||
.status { min-height: 1.5em; margin: 0; font-weight: 800; }
|
||||
.status.error { color: var(--danger); }
|
||||
.status.ok { color: var(--green); }
|
||||
.muted { color: var(--muted); font-size: .93rem; }
|
||||
.chart-panel canvas, .split-panel canvas { width: 100%; height: auto; }
|
||||
.row-title { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 18px; }
|
||||
.row-title h2 { margin-bottom: 0; }
|
||||
.entries { display: grid; gap: 12px; }
|
||||
.entry {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 20px;
|
||||
padding: 16px;
|
||||
background: var(--panel-strong);
|
||||
}
|
||||
.entry strong { font-size: 1.02rem; }
|
||||
.entry small { color: var(--muted); }
|
||||
|
||||
.login-body { display: grid; place-items: center; padding: 24px; }
|
||||
.login-shell { width: min(980px, 100%); display: grid; grid-template-columns: 1.1fr .9fr; gap: 22px; align-items: stretch; }
|
||||
.login-brand {
|
||||
min-height: 560px;
|
||||
padding: clamp(28px, 5vw, 54px);
|
||||
display: grid;
|
||||
align-content: end;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(15, 29, 21, .94), rgba(29, 88, 61, .86)),
|
||||
radial-gradient(circle at top right, rgba(218, 150, 72, .82), transparent 22rem);
|
||||
color: #fff4df;
|
||||
}
|
||||
.login-brand h1 { color: white; }
|
||||
.login-brand p, .login-brand .eyebrow { color: rgba(255,244,223,.78); }
|
||||
.login-card { padding: clamp(24px, 4vw, 40px); align-self: center; display: grid; gap: 16px; }
|
||||
.login-card h2 { margin-bottom: 0; }
|
||||
|
||||
@media (max-width: 1050px) {
|
||||
.app-frame { grid-template-columns: 1fr; }
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
left: 14px;
|
||||
top: 14px;
|
||||
bottom: 14px;
|
||||
z-index: 30;
|
||||
width: min(320px, calc(100vw - 28px));
|
||||
height: auto;
|
||||
border-radius: 26px;
|
||||
transform: translateX(calc(-100% - 18px));
|
||||
transition: transform .2s ease;
|
||||
box-shadow: 0 30px 90px rgba(0,0,0,.34);
|
||||
}
|
||||
body.menu-open .sidebar { transform: translateX(0); }
|
||||
.menu-toggle { display: inline-flex; }
|
||||
.hero-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.hero-card { grid-column: span 2; }
|
||||
.split-panel, .grid.two, .login-shell { grid-template-columns: 1fr; }
|
||||
.login-brand { min-height: 360px; }
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.topbar { align-items: flex-start; padding: 18px; }
|
||||
.primary-action { display: none; }
|
||||
.content { width: min(100% - 22px, 1180px); padding-top: 18px; }
|
||||
.hero-grid, .form-grid { grid-template-columns: 1fr; }
|
||||
.hero-card, .span-2 { grid-column: auto; }
|
||||
.row-title, .form-actions { align-items: stretch; flex-direction: column; }
|
||||
.row-title button, .form-actions button { width: 100%; }
|
||||
.login-body { padding: 12px; }
|
||||
.login-brand { min-height: 260px; }
|
||||
}
|
||||
203
web/server.js
203
web/server.js
|
|
@ -1,203 +0,0 @@
|
|||
const http = require('http');
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const port = Number(process.env.PORT || 8080);
|
||||
const publicDir = path.join(__dirname, 'public');
|
||||
const publicRoot = publicDir + path.sep;
|
||||
const dataDir = process.env.CALORIE_AI_DATA_DIR || path.join(__dirname, 'data');
|
||||
const authFile = path.join(dataDir, 'auth.json');
|
||||
const sessionCookieName = 'calorie_ai_session';
|
||||
const sessionSeconds = 60 * 60 * 24 * 7;
|
||||
const auth = loadAuthConfig();
|
||||
|
||||
const types = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.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 loadAuthConfig() {
|
||||
let stored = {};
|
||||
try {
|
||||
stored = JSON.parse(fs.readFileSync(authFile, 'utf8'));
|
||||
} catch {
|
||||
stored = {};
|
||||
}
|
||||
|
||||
const user = process.env.CALORIE_AI_WEB_USER || stored.user || 'admin';
|
||||
const password = process.env.CALORIE_AI_WEB_PASSWORD || stored.password || crypto.randomBytes(18).toString('base64url');
|
||||
const sessionSecret = process.env.CALORIE_AI_SESSION_SECRET || stored.sessionSecret || crypto.randomBytes(32).toString('hex');
|
||||
|
||||
if (!process.env.CALORIE_AI_WEB_PASSWORD || !process.env.CALORIE_AI_SESSION_SECRET) {
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
fs.writeFileSync(authFile, JSON.stringify({ user, password, sessionSecret, createdAt: new Date().toISOString() }, null, 2));
|
||||
fs.chmodSync(authFile, 0o600);
|
||||
console.log(`Calorie AI auth is stored at ${authFile}`);
|
||||
}
|
||||
|
||||
return { user, password, sessionSecret };
|
||||
}
|
||||
|
||||
function send(res, status, body, contentType = 'application/json; charset=utf-8', headOnly = false, headers = {}) {
|
||||
res.writeHead(status, {
|
||||
'content-type': contentType,
|
||||
'cache-control': 'no-store',
|
||||
'x-content-type-options': 'nosniff',
|
||||
'referrer-policy': 'same-origin',
|
||||
'x-frame-options': 'DENY',
|
||||
'content-security-policy': "default-src 'self'; img-src 'self' data: blob:; script-src 'self'; style-src 'self'; connect-src 'self'; base-uri 'self'; frame-ancestors 'none'",
|
||||
...headers,
|
||||
});
|
||||
res.end(headOnly ? '' : body);
|
||||
}
|
||||
|
||||
function redirect(res, location) {
|
||||
res.writeHead(302, {
|
||||
location,
|
||||
'cache-control': 'no-store',
|
||||
'referrer-policy': 'same-origin',
|
||||
'x-frame-options': 'DENY',
|
||||
});
|
||||
res.end();
|
||||
}
|
||||
|
||||
function parseCookies(req) {
|
||||
return Object.fromEntries(String(req.headers.cookie || '').split(';').map(part => {
|
||||
const index = part.indexOf('=');
|
||||
if (index < 0) return ['', ''];
|
||||
return [part.slice(0, index).trim(), decodeURIComponent(part.slice(index + 1).trim())];
|
||||
}).filter(([key]) => key));
|
||||
}
|
||||
|
||||
function safeEqual(left, right) {
|
||||
const leftBuffer = Buffer.from(String(left));
|
||||
const rightBuffer = Buffer.from(String(right));
|
||||
return leftBuffer.length === rightBuffer.length && crypto.timingSafeEqual(leftBuffer, rightBuffer);
|
||||
}
|
||||
|
||||
function sign(value) {
|
||||
return crypto.createHmac('sha256', auth.sessionSecret).update(value).digest('base64url');
|
||||
}
|
||||
|
||||
function createSession() {
|
||||
const payload = Buffer.from(JSON.stringify({ user: auth.user, exp: Date.now() + sessionSeconds * 1000 })).toString('base64url');
|
||||
return `${payload}.${sign(payload)}`;
|
||||
}
|
||||
|
||||
function isAuthenticated(req) {
|
||||
const token = parseCookies(req)[sessionCookieName];
|
||||
if (!token) return false;
|
||||
const [payload, signature] = token.split('.');
|
||||
if (!payload || !signature || !safeEqual(sign(payload), signature)) return false;
|
||||
try {
|
||||
const decoded = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
|
||||
return decoded.user === auth.user && decoded.exp > Date.now();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function cookieOptions(req, maxAge = sessionSeconds) {
|
||||
const secure = req.headers['x-forwarded-proto'] === 'https' || process.env.CALORIE_AI_COOKIE_SECURE === 'true';
|
||||
return `HttpOnly; SameSite=Lax; Path=/; Max-Age=${maxAge}${secure ? '; Secure' : ''}`;
|
||||
}
|
||||
|
||||
function publicRequest(req) {
|
||||
const url = req.url.split('?')[0];
|
||||
return (req.method === 'POST' && url === '/api/login')
|
||||
|| ((req.method === 'GET' || req.method === 'HEAD') && ['/login.html', '/login.js', '/styles.css', '/favicon.svg'].includes(url));
|
||||
}
|
||||
|
||||
function serveStatic(req, res) {
|
||||
const cleanUrl = decodeURIComponent(req.url.split('?')[0]);
|
||||
const file = cleanUrl === '/' ? '/index.html' : cleanUrl;
|
||||
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 }));
|
||||
}
|
||||
}
|
||||
|
||||
async function login(req, res) {
|
||||
try {
|
||||
const payload = JSON.parse(await readBody(req));
|
||||
if (!safeEqual(payload.username || '', auth.user) || !safeEqual(payload.password || '', auth.password)) {
|
||||
return send(res, 401, JSON.stringify({ error: 'Invalid username or password' }));
|
||||
}
|
||||
|
||||
send(res, 200, JSON.stringify({ ok: true, user: auth.user }), undefined, false, {
|
||||
'set-cookie': `${sessionCookieName}=${encodeURIComponent(createSession())}; ${cookieOptions(req)}`,
|
||||
});
|
||||
} catch (error) {
|
||||
send(res, 400, JSON.stringify({ error: error.message }));
|
||||
}
|
||||
}
|
||||
|
||||
function logout(req, res) {
|
||||
send(res, 200, JSON.stringify({ ok: true }), undefined, false, {
|
||||
'set-cookie': `${sessionCookieName}=; ${cookieOptions(req, 0)}`,
|
||||
});
|
||||
}
|
||||
|
||||
http.createServer((req, res) => {
|
||||
const url = req.url.split('?')[0];
|
||||
const authenticated = isAuthenticated(req);
|
||||
|
||||
if (!authenticated && !publicRequest(req)) {
|
||||
if (url.startsWith('/api/')) return send(res, 401, JSON.stringify({ error: 'Authentication required' }));
|
||||
return redirect(res, '/login.html');
|
||||
}
|
||||
|
||||
if (authenticated && (req.method === 'GET' || req.method === 'HEAD') && url === '/login.html') return redirect(res, '/');
|
||||
if (req.method === 'POST' && url === '/api/login') return login(req, res);
|
||||
if (req.method === 'POST' && url === '/api/logout') return logout(req, res);
|
||||
if (req.method === 'GET' && url === '/api/session') return send(res, 200, JSON.stringify({ user: auth.user }));
|
||||
if (req.method === 'POST' && req.url === '/api/chat') return proxyChat(req, res);
|
||||
if (req.method === 'GET' || req.method === 'HEAD') return serveStatic(req, res);
|
||||
send(res, 405, 'Method not allowed', 'text/plain; charset=utf-8');
|
||||
}).listen(port, () => {
|
||||
console.log(`Calorie AI web listening on ${port}`);
|
||||
});
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
const png = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=',
|
||||
'base64',
|
||||
);
|
||||
|
||||
async function login(page) {
|
||||
await page.goto('/');
|
||||
await expect(page).toHaveURL(/login\.html/);
|
||||
await expect(page.getByRole('heading', { name: 'Sign in to Calorie AI' })).toBeVisible();
|
||||
await page.locator('#password').fill('test-password');
|
||||
await page.getByRole('button', { name: 'Sign in' }).click();
|
||||
await expect(page.getByRole('heading', { name: /Private food tracking/ })).toBeVisible();
|
||||
}
|
||||
|
||||
test('requires authentication before loading the app', async ({ page, request }) => {
|
||||
await page.goto('/');
|
||||
await expect(page).toHaveURL(/login\.html/);
|
||||
await expect(page.getByRole('heading', { name: 'Sign in to Calorie AI' })).toBeVisible();
|
||||
|
||||
const response = await request.post('/api/chat', { data: {} });
|
||||
expect(response.status()).toBe(401);
|
||||
});
|
||||
|
||||
test.describe('authenticated app', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await login(page);
|
||||
await page.evaluate(() => localStorage.clear());
|
||||
await page.reload();
|
||||
});
|
||||
|
||||
test('loads the dashboard, menu, and validates missing meal input', async ({ page }) => {
|
||||
await expect(page.getByRole('navigation', { name: 'Main menu' })).toBeVisible();
|
||||
await expect(page.locator('#todayCalories')).toHaveText('0 kcal');
|
||||
await page.getByRole('button', { name: 'Log meal' }).click();
|
||||
await page.getByRole('button', { name: 'Analyze and save' }).click();
|
||||
await expect(page.locator('#status')).toContainText('Describe the meal or upload an image.');
|
||||
});
|
||||
|
||||
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.getByRole('button', { name: 'AI settings' }).click();
|
||||
await page.locator('#baseUrl').fill('https://api.example.test/v1');
|
||||
await page.locator('#visionModel').fill('vision-test-model');
|
||||
await page.locator('#taskModel').fill('task-test-model');
|
||||
await page.getByRole('button', { name: 'Save settings' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Log meal' }).click();
|
||||
await page.locator('#description').fill('salmon rice bowl with broccoli and berries');
|
||||
await page.locator('#measure').fill('one dinner bowl');
|
||||
await page.locator('#image').setInputFiles({ name: 'meal.png', mimeType: 'image/png', buffer: png });
|
||||
await expect(page.locator('#preview')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Analyze and save' }).click();
|
||||
await expect(page.locator('#status')).toContainText('Saved.');
|
||||
await expect(page.locator('#todayCalories')).toHaveText('640 kcal');
|
||||
await expect(page.locator('#todayMacroText')).toHaveText('P 44g | C 60g | F 23g');
|
||||
await expect(page.locator('#todayProduceText')).toHaveText('Fruit 0.5 | Veg 1.5');
|
||||
|
||||
await page.getByRole('button', { name: 'Analytics' }).click();
|
||||
await expect(page.locator('#calorieChart')).toBeVisible();
|
||||
await expect(page.locator('#produceChart')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Diary', exact: true }).click();
|
||||
await expect(page.locator('.entry')).toContainText('Salmon rice bowl');
|
||||
|
||||
page.once('dialog', dialog => dialog.accept());
|
||||
await page.getByRole('button', { name: 'Clear local diary' }).click();
|
||||
await expect(page.locator('#todayCalories')).toHaveText('0 kcal');
|
||||
await expect(page.locator('#entries')).toContainText('No meals logged yet.');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue