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 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.
Production runs on k3s, deployed by Argo CD from the Helm chart in charts/dietetyk — see Deployment.
-
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.jsand is covered bytests/test-meal-prompts.js. -
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.
-
Withings Integration (OAuth2, per-user credentials): weight, body fat percentage, muscle mass and blood pressure.
-
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. -
Google Fit Synchronization: steps and calories fetched hourly via OAuth2, without an intermediate app. Needs the Google client configured in the Admin Panel; without it the option is hidden.
-
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). -
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.
-
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. -
Manual Tracking: water intake, supplements, energy level and mood, and body circumference measurements.
-
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.
-
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.
-
Accounts and Security: password login with optional TOTP two-factor authentication (an administrator can require it), "Sign in with Google" and linking Google to an existing account, invitation-based or optional public registration, session revocation ("log out everywhere"), and account deletion. Integration secrets are encrypted at rest.
-
Admin Panel: user management (invitations, deletion, forcing a password change, requiring or resetting 2FA) and global settings — Mailgun, the public app URL, the 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.
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.
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.
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.
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 int()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 inCLAUDE.md, not byt().
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.
- Backend: Node.js + Express (
backend/) - AI: Google Gemini via
@google/generative-ai, default modelgemini-2.5-flash(backend/config.js) - Database: SQLite, a single file in
DATABASE_DIR(thebackend/directory by default;/app/dataon a persistent volume in the container) - Frontend: React 18 (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
- Node.js 24 (the version CI and the images use; sqlite3@6 needs at least 20.17) and npm installed
- Grant execution permissions to the startup script and run it:
It installs dependencies, builds the frontend into
chmod +x scripts/start.sh ./scripts/start.sh
backend/publicand createsbackend/.envfrombackend/.env.exampleif it does not exist yet. - Fill in
backend/.env. The backend refuses to start without the two secrets:SetAPP_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>
DATABASE_DIRto keep the SQLite file outsidebackend/. 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. - Start the backend server:
cd backend npm start - The application is available at
http://localhost:3000— Express serves the built frontend frombackend/public. For frontend development with hot reload, runnpm run devinfrontend/instead and openhttp://localhost:5173; Vite proxies/apito the backend on port 3000.
- Backend:
cd backend && npm test(pointDATABASE_DIRat 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:e2ein the repository root (Playwright). It starts the backend itself on port 3000 and needs the frontend already built; thetest-e2ejob in.github/workflows/docker-publish.ymlshows the environment it expects.
Production runs on a single-node k3s cluster on the RenaCode VPS, deployed by Argo CD from the Helm chart in charts/dietetyk. Nothing is built or edited on the server: the code reaches production only through main.
- CI (
.github/workflows/docker-publish.yml) runs on every push tomainand on pull requests tomain(pull requests only run the tests; they never build, publish or deploy):test-backend—npm auditof production dependencies (high/critical blocks the run, no exceptions are whitelisted) andnpm test;test-frontend—npm auditof the frontend, unit tests,check-i18nand lint;test-e2e— Playwright against a throwaway database.
- Images:
build-backend/build-frontendstart only when their own tests and E2E passed, and only for the service whose files changed (dorny/paths-filter;workflow_dispatchbuilds both). They pushghcr.io/renacode/dietetyk-ai-{backend,frontend}taggedlatestandsha-<commit>. Base images are pinned by digest (Node 24 LTS on Debian trixie for the backend — see the comment indocker/backend.Dockerfileon why not bookworm). - Tag bump:
update-gitwritessha-<commit>intocharts/dietetyk/values.yamland pusheschore: update image tags to sha-… [skip ci]tomainwith theDEPLOY_PATsecret (mainis protected; the PAT is the bypass). - Argo CD (Application
dietetykin therenacode-infrarepo:path: charts/dietetyk, namespacedefault, automated sync with prune + self-heal) sees the new tag and rolls the pods.
A red E2E therefore stops the release before anything reaches the registry or values.yaml.
| Object | Notes |
|---|---|
backend Deployment |
Node API on :3000, runs as uid 1000, liveness/readiness on GET /api/healthz. Data on a PVC (persistence, local-path) mounted at /app/data. |
frontend Deployment |
nginx serving the built SPA and proxying /api to the backend; its config is the ConfigMap in templates/nginx-configmap.yaml, not docker/nginx.conf. |
Ingress |
Traefik, host dietetyk.renacode.com, TLS from cert-manager (letsencrypt-prod). |
NetworkPolicy |
On by default (networkPolicy.enabled): the backend accepts only the frontend pod on :3000, the frontend only Traefik on :80. Egress (networkPolicy.egress) blocks the private networks listed in networkPolicy.egress.siecDomowa, which is empty in this public repository and set by the cluster operator in the Argo CD Application's valuesObject; everything else outbound is open. Rollback: enabled: false (or egress.enabled: false for egress alone) and let Argo CD sync. Covered by backend/tests/test-chart.js. |
| sqlite-web sidecar | Opt-in (dbImage.enabled, off): it has no authentication. Enable only for a debugging session and reach it with kubectl port-forward. |
The backend .env is the dotenv key of the Kubernetes Secret dietetyk-backend-secret, mounted at /app/.env. It is created by hand on the cluster, never committed:
GEMINI_API_KEY=your_gemini_api_key
GEMINI_MODEL=gemini-2.5-flash
APP_PASSWORD=<openssl rand -hex 32>
OAUTH_STATE_SECRET=<a second, different openssl rand -hex 32>Note
GEMINI_MODEL is optional — omit it and the backend uses gemini-2.5-flash. Earlier revisions of this README recommended gemini-1.5-flash, which returns 404 in the current SDK; config.js substitutes the working model and logs a warning at startup.
APP_PASSWORD is the key material for encrypting integration secrets at rest (utils/encryption.js) — not a login password. Changing it makes stored Oura/Withings/Gemini credentials undecryptable, so it is rotated together with a re-encryption pass: see backend/docs/secret-rotation.md.
OAUTH_STATE_SECRET signs the state parameter of the OAuth flows and is required — the backend refuses to start without it. Keep it different from APP_PASSWORD.
The cluster has no imagePullSecrets (values.yaml: imagePullSecrets: []): it relies on the ghcr.io/renacode/dietetyk-ai-* packages being public. If they turn private, new pods sit in ImagePullBackOff while the old ones keep serving — a failed deploy that does not look like an outage. To check:
curl -s "https://ghcr.io/token?scope=repository:renacode/dietetyk-ai-backend:pull&service=ghcr.io"A token in the answer means the package is public and the problem is elsewhere (e.g. the tag); UNAUTHORIZED means it is private. Either make it public again, or create a pull secret (scripts/create-ghcr-secret.sh) and list it in imagePullSecrets.
kubectl -n default get pods -l app.kubernetes.io/instance=dietetyk -w
kubectl -n default describe pod <pod> | grep -A5 Events # the real pull/probe error
kubectl -n default logs deploy/dietetyk-backend --tail=50
curl -s https://dietetyk.renacode.com/api/healthzArgo CD shows the synced revision; it should match the last chore: update image tags commit on main.
The backend backs up its SQLite database every day at 04:30 Europe/Warsaw (BACKUP_HOUR_LOCAL, HH:MM) — before the host's off-site copy job picks up the newest file — and at startup when the newest copy is older than 24 hours. Copies go to /app/data/backups on the PVC with mode 0600, keeping the newest copy of each of the last 14 days — see backupDatabase in backend/db.js and scheduleDailyBackup in backend/server.js.
Every backup is verified before it counts. Right after VACUUM INTO writes the copy, the backend reopens it read-only and runs PRAGMA quick_check plus a row-count sanity check. A copy that fails is deleted immediately and rotation is skipped, so a run of bad backups can never evict the last good ones.
Those copies sit on the same disk as the database. The off-site copy is the daily renacode-kopia.timer on the VPS (backup/kopia.sh in renacode-infra): it takes the newest verified backup out of the pod, encrypts it with age and pushes it to the private RenaCode/renacode-backup repository. Restoring is described in that repository's README; scripts/verify_backup.sh <file> checks that a copy opens, passes an integrity check and has non-empty core tables.
docker-compose.yml and docker/nginx.conf are the old single-VPS setup (certbot certificates, sqlite-web on :8081). Production no longer uses them, and neither does CI; they are kept only as a way to run the published images on one machine. The same goes for scripts/setup-deploy-user.sh, deploy_pull.sh, deploy_sync.sh and vps_backup_db.sh.
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.
Warning
Do not keep credentials in a plaintext file inside the project directory. Older revisions of this README pointed to a passwords.txt in the project root — it held the VPS root password and the Oura/Withings client secrets alongside app logins. That file has been deleted; the credentials belong in a password manager.
Being git-ignored was never enough protection: a plaintext file still lands in every directory backup, every rsync, every editor/IDE workspace index, and any tar of the project. Integration secrets belong in the Settings tab, where they are encrypted at rest (see utils/encryption.js); server credentials belong in a password manager and nowhere else.
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.
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.
- Log in to your Oura account on the Oura Developer Portal.
- Click "Create New Application".
- Fill in the application details (e.g., Name:
Dietetyk AI, Description:AI Dietician Application). - In the "Redirect URIs" field, add the following callback URL (replace
dietetyk.renacode.comwith your own domain if deployed elsewhere):https://dietetyk.renacode.com/api/auth/oura/callback - Save the application. A Client ID and Client Secret will be generated.
- 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.
- Log in to your Withings account on the Withings Developer Portal.
- Navigate to the Partner Dashboard.
- Create a new developer application.
- For the "Callback URL" (Redirect URI), enter:
https://dietetyk.renacode.com/api/auth/withings/callback - Select the data scopes for weight and body composition.
- Once created, you will receive a Client ID and Client Secret.
- 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.
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).
- Install the Health Auto Export app from the App Store on your iPhone.
- 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. - In the Health Auto Export app, navigate to Automations and create a new REST API automation.
- Paste the copied URL as the destination address and set the format to JSON.
- 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.
- Enable background delivery (e.g., hourly)—activity data will flow into
health_metricswithactivity_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.
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 (the same keys used for Google Login). This means standard users do not need to register their own developer applications.
- The administrator must configure
google_client_idandgoogle_client_secretin the Admin Panel from the Google Cloud Console, with the Authorized redirect URI set tohttps://dietetyk.renacode.com/api/auth/google-fit/callbackand the Fitness API enabled with scopehttps://www.googleapis.com/auth/fitness.activity.read. - Each user navigates to the Settings tab, Google Fit section, and clicks "Connect Google Fit".
- After choosing a Google account and accepting the permissions, data is synchronized automatically (hourly, between 5:00 and 22:00, and immediately upon connection).
- 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.
If you already have an account created with a username/password and want to link it to a Google account for single-click login without losing history:
- Log in normally (username/password) and go to the Settings tab, Google Account section.
- Click "Connect Google" and choose the Google account you wish to link.
- From now on, you can log in using either method—both lead to the same account.
- You can unlink Google at any time by clicking "Disconnect Google" (login will then require your password).
- Go to Google AI Studio.
- Log in with your Google account.
- Click "Get API Key".
- Click "Create API Key" (choose a new or existing Google Cloud project).
- Copy the generated key.
- 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: 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 namedprotect-main, which means all changes must be submitted via Pull Requests and pass verification.