Skip to content

About

Self-hosted diet journal: Gemini AI estimates calories and nutrients from meal text or photos and combines them with Oura, Withings, Apple Health and Google Fit data for dashboard insights, AI chat and trend charts (Node.js, React, SQLite).

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

🥗 Dietetyk AI (AI Dietician)

A self-hosted web application for keeping a diet journal with Gemini AI: you describe or photograph a meal, Gemini estimates its calories and nutrients, and the app puts that next to sleep, recovery, activity and body-composition data from Oura Ring, Withings, Apple Health and Google Fit, with dashboard insights, an AI chat and trend charts. The interface is available in Polish (default) and English.

Important

Calories, macronutrients, fiber, sugar and sodium for a meal are estimates produced by Gemini from your description or photo, not measurements. Insights that correlate them with measured data (e.g. sodium vs. blood pressure, fiber vs. sleep) inherit that uncertainty.

A native iOS client (SwiftUI, in development and not deployed yet) lives in a separate private repository (RenaCode/Dietetyk-IOS); it talks to the same API and can send HealthKit data to the same Apple Health endpoint as the webhook described below.

Deployment details and operational documentation (runbooks such as secret rotation) are kept in the owner's private infrastructure repository.


🚀 Main Features

  1. AI Meal Journal: Input your meals in natural language (e.g., "This morning I ate 2 slices of whole grain bread with avocado and a fried egg") and/or a photo. Gemini breaks the meal down into ingredients, estimates calories, protein, carbohydrates, fat, fiber, sugar and sodium, rates the meal and adds a short tip. Frequently repeated meals can be added again in one click without another AI call (/api/meals/frequent, /api/meals/repeat), and meals whose calories do not match their macros or stand out from your own history are flagged (utils/mealAnomaly.js).

    Photo + description work together. When you attach a photo and type something, the text is treated as a correction and completion of the photo — never as a second meal. Your description is the authoritative source and the photo is supporting evidence, so you can fix a portion the model misjudged ("that was 200 g of chicken, not 150"), correct an ingredient or the cooking method ("turkey, not chicken", "fried in butter"), add what is out of frame ("plus a glass of juice"), or exclude something visible you did not eat ("I skipped the bread"). Where the two disagree the description wins, and the dietician comment says so — e.g. "photo suggests about 150 g, using 200 g per your description" — so the number is always traceable. Prompt construction lives in utils/mealPrompts.js and is covered by tests/test-meal-prompts.js.

  2. Oura Ring Integration (OAuth2, per-user credentials): sleep (score, duration, stages, bedtime and wake time), readiness, resting heart rate, HRV, daily activity, SpO2 and daily stress.

  3. Withings Integration (OAuth2, per-user credentials): weight, body fat percentage, muscle mass and blood pressure.

  4. Apple Health Synchronization: a per-user webhook (POST /api/integrations/apple-health/<sync token>) fed by the Health Auto Export iPhone app. Steps, active/basal energy, exercise minutes, distance, water, wrist temperature, sleep and workouts (with heart-rate zones when workout metrics are included) go into the daily metrics; every other numeric metric is kept as hourly samples, and symptoms, heart-rate notifications, cycle tracking and medications are stored as events.

  5. Google Fit Synchronization (off by default): steps and calories fetched hourly via OAuth2, without an intermediate app. An administrator switches it on and sets the Google client in the Admin Panel; until then the option is hidden and its routes answer 404.

  6. Daily AI Advice and AI Chat: Gemini combines your meals with sleep, recovery, activity, body composition and current weather/time of day (Open-Meteo, location configurable in Settings) to give daily recommendations, and answers questions about your data in a chat (POST /api/chat).

  7. Dashboard Insights: 49 insight cards (sleep, recovery, training readiness, calorie balance and target suggestions, weight-goal forecast, blood-pressure and SpO2 trends, heart-rate zones, weekend effect and more), plus a Wellness Score and the Energy Battery below.

  8. Energy Battery: A single 0–100 number at the top of the dashboard answering "how much fuel do I have today". It charges overnight from sleep quality, duration and readiness, drains through the day from actual training load (relative to your own 30-day median, not a population norm) and from time awake, takes a hit from accumulated sleep debt over the last 14 nights, and adjusts for stress vs. recovery minutes. Every card shows its own breakdown, so the number is checkable rather than magic. See /api/dashboard/energy-battery.

  9. Manual Tracking: water intake, supplements, energy level and mood, and body circumference measurements.

  10. Progress Charts (Custom SVG): hand-rolled, responsive SVG charts (no charting library) — weight against body fat percentage (dual axis), muscle mass, and 7/30/90-day trends for sleep, recovery, steps, calories and blood pressure.

  11. Reports and Data Export: daily, weekly and monthly e-mail summaries (via Mailgun, configured by the administrator), a PDF report for a doctor or dietician containing only computed data with no AI-generated text, time-limited read-only share links for that report, and a full JSON export of your own data.

  12. Accounts and Security: password login with optional TOTP two-factor authentication (an administrator can require it), invitation-based or optional public registration, session revocation ("log out everywhere"), and account deletion. Integration secrets are encrypted at rest. Optionally (chart value sso.enabled, off by default) the whole host sits behind single sign-on through Authelia, and the app maps the verified identity headers to an account (backend/services/ssoIdentity.js); see GUI Login and Admin Access below.

  13. Catering Menu Import: a CronJob (backend/catering/sync.js, chart value catering.enabled, the panel login in a Secret) logs in to the catering's Dietly customer panel each morning and imports the day's menu as meals, with the provider's own nutrition values and no Gemini call. It authenticates with the user's sync token like the Apple Health webhook (routes/catering.js); a day is imported once, so meals you delete do not come back.

  14. Admin Panel: user management (invitations, deletion, forcing a password change, requiring or resetting 2FA) and global settings — Mailgun, the public app URL, the Google Fit switch and its Google OAuth client, mandatory 2FA and public registration — changeable without a restart. Oura, Withings and Gemini credentials are not set here; each user enters their own in Settings.

Note

Energy Battery and Wellness Score answer different questions and are deliberately kept separate. Wellness Score rates how good the day was (sleep, readiness, resting heart rate recovery, calorie adherence, hydration) — a judgement about behaviour. The battery says how much resource is left right now. A day with a perfect diet after three short nights scores well and shows a low battery; that is the intended behaviour.


🧭 Notes for Developers

Insights are fetched in one batch

The dashboard renders ~49 independent insight cards. Each used to have its own useEffect and its own fetch, so opening the screen fired ~50 HTTP round-trips and as many separate SQLite query bursts.

GET /api/dashboard/insights?ids=a,b,c&date=YYYY-MM-DD now runs them in one request (6 at a time server-side) and returns a per-item status:

{ "date": "2026-08-20", "results": { "sleep-insight": { "status": "ok", "data": { … } } } }

Each item is isolated — an error, a timeout (15 s cap, relevant for AI-backed insights) or an unknown id yields a status for that card only and never breaks the rest of the response. On the client this is useInsights() (frontend/src/utils/useInsights.js).

The registry is automatic. routes/dashboard.js wraps router.get and indexes every /api/dashboard/<id> route as it is registered, so a new insight joins the batch without touching a second list. tests/test-energy-battery.js asserts that the count of routes in the file matches the count in the registry, so an insight added in a different style fails the test instead of silently disappearing from the dashboard.

Two insights stay overridable after the batch because they refresh independently: ai-explanation-insight (backend generates in the background, client polls) and training-plan-insight (manual "Odśwież" button). Both keep an override keyed by date so switching days never shows the previous day's result.

Activity data source priority

Three sources write activity metrics to health_metrics: the Apple Health webhook/HealthKit, Google Fit, and Oura. The hierarchy lives in one place, utils/activitySources.js:

apple (3)  >  google_fit (2)  >  oura (1)

Phone and watch sources report continuously; Oura only finalises a day the next morning, so on conflict the phone data is closer to the truth. A lower-priority source can still fill columns the higher one left empty, and a day written as all-zeros never locks out a later real value.

Previously each upsert only guarded against overwriting 'apple', leaving Google Fit and Oura to overwrite each other — the same day showed different step counts depending on which sync ran last that hour. tests/test-activity-sources.js pins this down by writing the same data in both orders and asserting the result is identical.

Dates are always Europe/Warsaw

Google Fit's dataset:aggregate aligns its daily buckets to the start of the requested window, not to UTC or any timezone. The window therefore starts at Warsaw midnight (getWarsawDayStartMillis), and because durationMillis is a fixed 24 h, buckets are labelled by their midpoint so the week containing a DST change still maps to seven distinct, consecutive days. tests/test-dates.js covers both DST transitions and the year boundary.

Translation coverage

npm run check-i18n (in frontend/) cross-checks every t('…') literal against the dictionary in utils/i18n.js and reports three things: missing translations, texts hardcoded in JSX despite having a translation, and stale dictionary entries.

More than 450 strings go through t(), so switching the language actually translates the interface. t() also warns in the console (dev builds only) whenever a translation is missing, so future drift is visible instead of silent.

The check also lists Polish literals that are still hardcoded. Some of them are genuine gaps to wrap in t(); others remain unwrapped on purpose:

  • Keyword matchers — 'siłownia', 'pływ', 'różeniec' are compared against workout and supplement names with .includes(). Wrapping them in t() would change what the code matches, not what it shows, and silently break the matching in English.
  • Comparison operands — e.g. recentCategory !== 'Prawidłowe', where the value comes from the backend.
  • Comments — Polish prose inside // and {/* */} is covered by the separate code-language rule in CLAUDE.md, not by t().

Note

The dictionary keys are the Polish source strings themselves, so changing Polish copy silently breaks its translation. npm run check-i18n is what catches that: it matches exact occurrences only. An earlier version used a substring match and reported 61 hardcoded strings where only 43 were real — "Zaloguj się" was matching inside "Sesja wygasła. Zaloguj się ponownie.". Wrapping a hit like that would have torn the sentence in half.

About 140 dictionary entries match no string in the code. They are pre-written translations for wording that has since changed; they are kept rather than deleted, because the English text is still useful when that part of the UI is revisited.


🛠️ Architecture and Technologies

  • Backend: Node.js + Express (backend/)
  • AI: Google Gemini via @google/generative-ai, default model gemini-2.5-flash (backend/config.js)
  • Database: SQLite, a single file in DATABASE_DIR (the backend/ directory by default; /app/data on a persistent volume in the container)
  • Frontend: React 19 (Vite) styled in a dark theme with glassmorphism effects, hand-rolled SVG charts (frontend/)
  • E-mail: Mailgun (summaries, invitations, admin reports), configured in the Admin Panel
  • Containerization: two images on GHCR (Node.js API, nginx serving the SPA), deployed to k3s by a Helm chart and Argo CD

💻 How to Run Locally (Development)

Requirements

  • Node.js 24 (the version CI and the images use; sqlite3@6 needs at least 20.17) and npm installed

Quick Start

  1. Grant execution permissions to the startup script and run it:
    chmod +x scripts/start.sh
    ./scripts/start.sh
    It installs dependencies, builds the frontend into backend/public and creates backend/.env from backend/.env.example if it does not exist yet.
  2. Fill in backend/.env. The backend refuses to start without the two secrets:
    APP_PASSWORD=<openssl rand -hex 32>
    OAUTH_STATE_SECRET=<a second, different openssl rand -hex 32>
    GEMINI_API_KEY=<optional: server-side key from Google AI Studio>
    Set DATABASE_DIR to keep the SQLite file outside backend/. Other optional variables: PORT, APP_URL (public URL used for OAuth redirects and CORS), ADMIN_INITIAL_PASSWORD / ADMIN_EMAIL (first admin account), WEATHER_LAT / WEATHER_LON (default weather location), BACKUP_HOUR_LOCAL.
  3. Start the backend server:
    cd backend
    npm start
  4. The application is available at http://localhost:3000 — Express serves the built frontend from backend/public. For frontend development with hot reload, run npm run dev in frontend/ instead and open http://localhost:5173; Vite proxies /api to the backend on port 3000.

Tests

  • Backend: cd backend && npm test (point DATABASE_DIR at a temporary directory so your dev database is not touched).
  • Frontend: cd frontend && npm test && npm run check-i18n && npm run lint.
  • End-to-end: npm run test:e2e in the repository root (Playwright). It starts the backend itself on port 3000 and needs the frontend already built; the test-e2e job in .github/workflows/docker-publish.yml shows the environment it expects.

🔐 GUI Login and Admin Access

User accounts and default credentials are defined locally (saved in the database). On first run, the backend generates a random admin password and prints it once to the container log (see [DB INIT] in db.js) — you will be asked to change it on first login.

Once logged in as an administrator (admin), you can navigate to the Settings or Admin Panel (available in the navigation menu for accounts with the admin role) to manage the global configuration of the application. Developer credentials for Oura Ring and Withings (needed for integration) are configured by each user individually in their own Settings tab.

Single sign-on (optional). With sso.enabled: true in the Helm chart, Traefik puts Authelia's forwardAuth in front of the host and strips any client-supplied Remote-* headers; the same switch sets SSO_NAGLOWKI=1, the only condition under which the backend trusts those headers. The frontend then asks POST /api/auth/sso for a session instead of showing the login form, entry is limited to the groups in sso.allowedGroups, and the application role still comes from the account in the database, never from the header (backend/services/ssoIdentity.js). Without the switch nothing changes: the app's own login is the only one.


🔌 Configuration of Integrations (Step-by-Step)

To automatically import sleep, activity, and body composition data from external sensors, and to allow the AI to analyze your diet using your own API key, enter the appropriate credentials in the Settings tab.

1. Oura Ring Integration (Sleep, HRV, Activity)

  1. Log in to your Oura account on the Oura Developer Portal.
  2. Click "Create New Application".
  3. Fill in the application details (e.g., Name: Dietetyk AI, Description: AI Dietician Application).
  4. In the "Redirect URIs" field, add the following callback URL (replace dietetyk.renacode.com with your own domain if deployed elsewhere): https://dietetyk.renacode.com/api/auth/oura/callback
  5. Save the application. A Client ID and Client Secret will be generated.
  6. Copy and paste them into the Oura Ring section in the Settings tab of the Dietetyk AI app, click "Save credentials", and then click "Connect Oura" to authorize the integration.

2. Withings Integration (Weight and Body Composition)

  1. Log in to your Withings account on the Withings Developer Portal.
  2. Navigate to the Partner Dashboard.
  3. Create a new developer application.
  4. For the "Callback URL" (Redirect URI), enter: https://dietetyk.renacode.com/api/auth/withings/callback
  5. Select the data scopes for weight and body composition.
  6. Once created, you will receive a Client ID and Client Secret.
  7. Copy these details and enter them in the Withings section in the Settings tab of the Dietetyk AI app, click "Save credentials", and click "Connect Withings" to authorize the integration.

3. Apple Health Integration (Steps, Calories, Active Minutes)

Unlike Oura and Withings, Apple Health does not expose a public cloud API—data is sent from the phone via a webhook using the free Health Auto Export app (acting as a bridge between HealthKit and our backend).

  1. Install the Health Auto Export app from the App Store on your iPhone.
  2. Log in to Dietetyk AI, navigate to the Settings tab, and locate the Apple Health section. Copy the generated webhook URL (which contains your private sync token, e.g., https://dietetyk.renacode.com/api/integrations/apple-health/<token>). You can regenerate a new token if needed.
  3. In the Health Auto Export app, navigate to Automations and create a new REST API automation.
  4. Paste the copied URL as the destination address and set the format to JSON.
  5. Select the metrics. Steps, Active Energy, Basal Energy Burned and Apple Exercise Time feed the calorie balance; distance, Dietary Water, wrist temperature and sleep are used as well, and you can tick everything — metrics without a dedicated column are stored as hourly samples instead of being dropped. To synchronize workouts, create a second automation for Workouts pointing to the same URL and enable Include Workout Metrics for heart-rate data. Symptoms, heart-rate notifications, cycle tracking and medications can be sent from their own automations to the same URL.
  6. Enable background delivery (e.g., hourly)—activity data will flow into health_metrics with activity_source = 'apple' and will show up on your Dashboard automatically.

Note

When both Apple Health and Oura are active, Apple Health data is treated as the primary source for steps/calories/activity minutes (since it syncs immediately, while Oura usually finalizes its summary the next morning). Oura only fills in the activity values Apple Health has not reported.

4. Google Fit Integration (Steps, Calories)

Unlike Apple Health (webhook) and Oura/Withings (per-user credentials), Google Fit uses OAuth2 and global Google credentials (Client ID/Secret) configured once by the administrator in the Admin Panel. This means standard users do not need to register their own developer applications.

Google Fit is switched off by default (app_config.google_fit_enabled, the same kind of switch as mandatory 2FA). While it is off, every /api/auth/google-fit* route answers 404, the hourly sync skips it and Settings hides the card; stored connections and the history they synced are kept untouched, so switching it on again resumes where it stopped.

  1. The administrator ticks "Włącz integrację Google Fit" in the Admin Panel, Google Fit section, and configures google_client_id and google_client_secret there (the Admin Panel form saves only once its required Mailgun fields are filled in) from the Google Cloud Console, with the Authorized redirect URI set to https://dietetyk.renacode.com/api/auth/google-fit/callback and the Fitness API enabled with scope https://www.googleapis.com/auth/fitness.activity.read.
  2. Each user navigates to the Settings tab, Google Fit section, and clicks "Connect Google Fit".
  3. After choosing a Google account and accepting the permissions, data is synchronized automatically (hourly, between 5:00 and 22:00, and immediately upon connection).
  4. The integration can be disconnected at any time by clicking "Disconnect Integration".

Note

Activity sources follow one fixed priority, apple > google_fit > oura (see Activity data source priority): Google Fit never overwrites activity values that came from Apple Health, and Oura never overwrites either of them.

5. Gemini AI Integration (API Key)

  1. Go to Google AI Studio.
  2. Log in with your Google account.
  3. Click "Get API Key".
  4. Click "Create API Key" (choose a new or existing Google Cloud project).
  5. Copy the generated key.
  6. Paste it in the Gemini AI section in the Settings tab of the Dietetyk AI app and click "Save credentials". Once configured, meal analyses, the chat, daily advice and summaries will use your personal quota.

Note

Regular (non-admin) accounts need their own Gemini key — without it the AI features are unavailable for them. The server-wide GEMINI_API_KEY from backend/.env is used only as a fallback for administrator accounts.


🌍 Hosting and Contributions

  • Hosting: The production application is hosted at https://dietetyk.renacode.com.
  • Contributions: Pull Requests (PRs) with improvements, bug fixes, or new features are highly encouraged.
  • Main Branch: The main branch of the repository (main) is protected by a GitHub Ruleset named ochrona-main, which means all changes must be submitted via Pull Requests and pass verification.

About

Self-hosted diet journal: Gemini AI estimates calories and nutrients from meal text or photos and combines them with Oura, Withings, Apple Health and Google Fit data for dashboard insights, AI chat and trend charts (Node.js, React, SQLite).

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages