smooth-app
A collaborative, free and open database of ingredients, nutrition facts and information on food products from around the world
Your glucose numbers, on your Strava activities. Self-hosted, open source.
HealthGo · JavaScriptGitHub @MrCodeEUOnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
Self-hosted glucose stats for Strava activities
Translation pending
Taken from the repository README
Loading the full README. The saved excerpt is shown below.
Free and self-hosted. After a Strava activity ends, Glucava adds your Dexcom glucose stats (time in range, min, max, average, sparkline) to its description:
Strava's API is paywalled, so Glucava edits the description through a headless Chrome session with your own cookies. One Go binary (PocketBase + web UI); Chrome/Chromium must be installed.Read the full README ↗Free and self-hosted. After a Strava activity ends, Glucava adds your Dexcom glucose stats (time in range, min, max, average, sparkline) to its description:
🩸 TIR 92% | min 78 | max 164 | avg 112 mg/dL
▁▂▃▃▄▅▄▃▃▂▂▃
Strava's API is paywalled, so Glucava edits the description through a headless Chrome session with your own cookies. One Go binary (PocketBase + web UI); Chrome/Chromium must be installed.
Not a medical device. Glucava copies numbers from your CGM into a Strava description for your own reference. It does not measure, diagnose, alarm or advise, and it must never be used for treatment decisions or as a replacement for your CGM app's alarms. It can be wrong, late or silent (a source can be down, a timestamp off, Strava's page can change). Use at your own risk; see the warranty disclaimer in the license.
Glucava is an independent project. It is not affiliated with or endorsed by Strava, Dexcom, Abbott, Glooko or Nightscout; those names belong to their owners. Automating Strava's web UI may breach Strava's terms of service (see SECURITY.md); the session is your own and the volume is low, but the risk is yours.
All from demo mode (make mock), so the data is made up. The UI follows your light or dark preference.
The optional chart photo (square, as Strava shows it) and the settings card that styles it, with a live preview:
Notification emails (HTML with a plain-text fallback, inline charts drawn by glucava):
make mock # fake Strava + Dexcom data, http://127.0.0.1:8090 (make help lists all targets)
Real use, with Docker:
docker build -t glucava .
docker run -d --name glucava -p 127.0.0.1:8090:8090 -v glucava:/data \
-e [email protected] \
-e GLUCAVA_ADMIN_PASSWORD='long-random-password' \
-e GLUCAVA_SECRET_KEY="$(openssl rand -base64 32)" glucava
Put a TLS reverse proxy in front. Then:
glucava dexcom set <user> --region us|ous|jp). Enable Share in the Dexcom app. "Test connection" checks the login.glucava strava cookies import file.json).glucava strava check to verify the session and edit-page selectors without saving.post_minutes) only widens the chart photo's curve with cooldown glucose after the activity; it plays no part in the description text. Dexcom Share can also lag a few minutes behind the wall clock, so the very last readings are sometimes still missing at the moment an activity ends or its Post window closes. "Reprocess once more after (minutes)" (post_buffer_minutes, default 5, 0 turns it off) automatically reprocesses the activity once more after that buffer, refreshing the stats and description text with whatever readings arrived since, and is also when the chart photo's first upload happens if the chart is on — never earlier, since a photo cannot be replaced once attached. Turning the buffer off (0) goes back to attaching the chart immediately after the Post window.curl -X POST -H "Authorization: Bearer gst_..." https://host/api/trigger
For Android/Tasker, the Tokens page generates a ready-to-import .prf.xml (download button and QR code, next to a freshly created token) instead of building one by hand: Strava notification → check now, deliberately unfiltered (Strava's own notification wording varies by type and locale, and an extra check is harmless). See docs/triggers.md for Tasker, HTTP Shortcuts/MacroDroid, and Apple Shortcuts setup.| Variable | Meaning |
|---|---|
GLUCAVA_ADMIN_EMAIL, GLUCAVA_ADMIN_PASSWORD | first user, created on start only while no user exists; change it later under Settings → Account or with glucava user |
GLUCAVA_DEXCOM_USERNAME, GLUCAVA_DEXCOM_PASSWORD, GLUCAVA_DEXCOM_REGION | Dexcom Share login, stored on first start if no credential is stored yet (alternative to make dexcom); safe to remove afterwards |
GLUCAVA_RETENTION_DAYS | readings/events retention in days (0 = forever); overrides the UI setting on every start, not just the first |
GLUCAVA_PUBLIC_URL | address of the web UI, e.g. https://glucava.example.com; used for links in emails. Seeds the Public URL setting while none is saved |
GLUCAVA_SMTP_HOST, GLUCAVA_SMTP_SENDER_ADDRESS | seed the SMTP settings on first start (only while none are saved; afterwards edit them in Settings → Email) |
GLUCAVA_SMTP_PORT (default 587), GLUCAVA_SMTP_USERNAME, GLUCAVA_SMTP_PASSWORD, GLUCAVA_SMTP_TLS=1, GLUCAVA_SMTP_SENDER_NAME (default glucava), GLUCAVA_SMTP_TO | rest of the seed; TO is the recipient. The password goes into the encrypted vault, and is stored later too if you add it after the first start |
GLUCAVA_SECRET_KEY | encryption key for stored secrets (default: <data dir>/secret.key) |
GLUCAVA_TRUSTED_PROXIES | comma-separated IPs/CIDRs of your reverse proxy. Only then are X-Forwarded-For/-Proto believed (per-client rate limits, Secure cookie). Unset = direct peer only |
GLUCAVA_SOURCE | live glucose source (default and currently only: dexcom); an unknown name stops the server at start |
GLUCAVA_CANARY_INTERVAL | how often the edit-page canary runs, e.g. 12h; off disables it (default 24h) |
GLUCAVA_POLL_LOOKBACK | how old an activity may be and still be picked up by polling (default ) |
Glucava sends through any SMTP server; Gmail works well for a personal setup.
Turn on 2-Step Verification for the Google account.
Create an app password at https://myaccount.google.com/apppasswords (16 characters; your normal password is rejected). Type it without spaces.
Either fill in Settings → Email, or seed it from .env before the first start:
GLUCAVA_SMTP_HOST=smtp.gmail.com
GLUCAVA_SMTP_PORT=587
GLUCAVA_SMTP_TLS=0 # 587 uses StartTLS; for port 465 set GLUCAVA_SMTP_TLS=1
[email protected]
GLUCAVA_SMTP_PASSWORD=your-16-char-app-password
[email protected] # must be the Gmail address (or one of its aliases)
[email protected]
Press Send test notification on the Settings page.
Under What to email, choose the kinds you want:
Set Public URL (or GLUCAVA_PUBLIC_URL) to add "Open in glucava" buttons that point at the activity, the Strava page, the tokens page or the dashboard. Without it, mails contain no links to the web UI (the Strava activity link is always there).
Summaries are email-only; ntfy and webhooks keep receiving alerts only. Scripted: glucava config set mail_weekly true (keys mail_alerts, mail_activity, mail_weekly, public_url).
The variables only seed the settings while no SMTP host is saved yet; after that the web UI is the source of truth and later edits in .env are ignored (except a password added while none is stored). Glucava does not expose the SMTP auth method or EHLO name, so servers that need LOGIN auth or a custom EHLO name (e.g. Gmail SMTP-relay) are not supported.
glucava is an installable web app (a PWA) and can send Web Push notifications to phones and computers, with no app store and no third-party account. It pushes what email does: failure alerts and, if you switch them on, the activity, weekly and monthly summaries. Nothing else: no glucose alarms, and no glucose data is stored on the device (the service worker caches only CSS, JavaScript and icons; every page and number is loaded live).
Turn it on under Settings → Notifications → Push notifications: press Enable on this device, allow notifications when the browser asks, then Send test push. Each browser or phone is enabled on its own; the card lists the subscribed devices and can remove them. Choose what is pushed with the two switches (failure alerts on by default, summaries off) and press Save settings. Scripted: glucava config set push_alerts true / push_summaries true.
Requirements and caveats:
http://localhost also works, for trying it out). Browsers refuse service workers and push on insecure origins. Behind a reverse proxy this is usually already the case.https:// endpoints on public addresses are accepted or contacted.vapid_private secret) makes existing devices stop receiving pushes until each one presses Enable on this device again.To try it before deploying, run the mock (make mock, open http://127.0.0.1:8306): it has one made-up device and sends through a stand-in that only logs, so the card, list and buttons work, but no real push is delivered. Real delivery needs a real browser on an HTTPS address.
Everything the web UI can change is scriptable, with the same validation:
# settings: key=value lines, comments allowed; idempotent (prints "unchanged" on a re-run)
glucava config apply settings.conf --dev=false --dir /data
glucava config set unit=mmol/L poll_interval_minutes=5 --dev=false --dir /data
glucava config list --dev=false --dir /data # all keys with current values
glucava config get smtp_host --dev=false --dir /data
# secrets: the value is read from stdin, never from argv
printf '%s\n' "$SMTP_APP_PASSWORD" | glucava secrets set smtp_password --dev=false --dir /data
glucava secrets status --dev=false --dir /data # set/unset per secret, never the values
unit, range_low, range_high, pre_minutes, post_minutes, poll_interval_minutes, retention_days, dexcom_region, dexcom_username, ntfy_url, webhook_url, email_to, smtp_host, smtp_port, smtp_username, smtp_tls, smtp_sender_address, smtp_sender_name, public_url, mail_alerts, mail_activity, mail_weekly, mail_health, push_alerts, push_summaries, gap_alert_hours, chart_image, chart_theme, chart_size, chart_band, chart_activity, chart_dots, chart_line, chart_hr, chart_panel_order, chart_pre_minutes, hr_read, post_buffer_minutes, description_template (glucava config list is authoritative).smtp_host with smtp_sender_address) can come in any order. An invalid batch changes nothing and exits non-zero.dexcom_password, ntfy_token, webhook_secret, smtp_password. Strava cookies: glucava strava cookies import. Trigger tokens: glucava token create <name>.GLUCAVA_RETENTION_DAYS, when set, still overrides retention_days at every server start.--dev=false for quiet output; results go to stdout, errors to stderr.glucava user show prints the account email, glucava user set-email <new> changes it, and glucava user set-password reads the new password (12+ characters) from stdin. The email may be left out while there is exactly one user. All of these sign out every other session. Logout also invalidates the session server-side. Logins last 3 days.glucava secrets rotate-key re-encrypts stored secrets with a fresh key. Stop the server and back up the data dir first.glucava data purge --yes does the same from the shell. Activities are kept by retention because they log what was written to Strava.GET /health reports {"status":"ok","build":"<version>"}, unauthenticated. GET /metrics serves Prometheus text-format metrics: job outcomes and queue depth (glucava_jobs_*), background ingest health (glucava_ingest_*, including glucava_ingest_last_success_timestamp_seconds — alert on its age to catch a source that has silently stopped answering), Strava write latency (glucava_strava_write_duration_seconds), a recent-window glucose summary (glucava_glucose_*), HTTP request counts/latency/in-flight (glucava_http_*), and the standard Go runtime/process collectors. Like /api/trigger, it needs Authorization: Bearer <token> with a token from glucava token create <name> — the same tokens, so a scrape config and a phone's trigger call can use different ones if you want to tell them apart, or the same one. A Prometheus scrape config:
scrape_configs:
- job_name: glucava
bearer_token: gst_...
static_configs:
- targets: ["host:8090"]
All state is in the data dir (/data in Docker): the SQLite database (settings, activities, readings, events, encrypted secrets) and, unless you set GLUCAVA_SECRET_KEY, secret.key.
GLUCAVA_SECRET_KEY in your secret store, or secret.key copied separately). Data without the key cannot be decrypted, and the key without the data is useless, which is the point.glucava secrets set ..., glucava strava cookies import). Settings and history are not affected.glucava config list --dev=false --dir <copy> and glucava secrets status --dev=false --dir <copy> with the key.The interface language is set under Settings > Language: "Automatic" follows the browser, or pick one (English and German so far; the conversion of every screen is in progress). Translations are plain JSON files in internal/i18n/locales/, so adding a language needs no code. See docs/TRANSLATING.md.
Run make hooks once to enable the git hooks (pre-commit: gofmt, vet, lint; pre-push: tests, govulncheck). make check runs everything CI runs. See docs/DEVELOPMENT.md. See SECURITY.md for the threat model. Automating Strava's web UI may breach its terms; use at your own risk.
Frontend build (Tailwind). The UI is styled with Tailwind v4. make css downloads the pinned standalone CLI (checksum-verified, into the gitignored .bin/) and rebuilds the committed internal/web/static/app.css from internal/web/static/input.css plus the class strings in internal/web/*.go. go build, go run and the Docker build never need Tailwind; you only run make css after changing input.css or a class string, and commit the result. CI runs make css-check, which fails if app.css is stale. Class strings in Go must be whole literals (see the comment in internal/web/components.go). make shot SHOTFLAGS=-matrix (with make mock running) screenshots every page at 1280 and 390 px in light and dark, and fails on browser console errors or CSP violations.
Adding a glucose source. Dexcom Share (internal/glucose/dexcom.go) is the only built-in source, but the pipeline (background ingestion, activity processing, live dashboard reading) only depends on the small glucose.Source interface:
type Source interface {
Samples(ctx context.Context, from, to time.Time) ([]stats.Sample, error)
}
A Libre, Tandem or Medtronic live API source is a new type implementing that one method, plus one entry in sourceBuilders (cmd/glucava/wire.go); users then select it with GLUCAVA_SOURCE. No changes needed elsewhere.
Adding a file importer (a one-shot export, not a live API) is a separate, smaller interface in internal/glucose/importers:
type Importer interface {
Parse(r io.Reader) (samples []stats.Sample, skipped int, err error)
}
Register it by name in an init() func (see libre.go) and it is immediately available to glucava glucose import --format <name>; no other wiring needed.
Either kind of contributed source, for hardware or an account type the maintainer doesn't have, is welcome but untested by CI; say so plainly in the code and docs, the same way the Libre importer is marked.
web.SourceURL, change it in a fork). Third-party components are listed in NOTICE.md.GLUCAVA_SMTP_* (below) can seed it on first start. Emails are HTML cards with an icon, a plain-text fallback, an inline glucose chart in the activity summary and a time-in-range bar chart in the weekly one (drawn by glucava itself, embedded in the mail, nothing loaded from the internet), and link back to the right page of the web UI when a public URL is set. Two optional summaries, both off by default and email-only: a summary after each processed activity and a weekly summary (Monday, 08:00 in the server's time zone (set TZ in a container), for the week before).chart_image on, glucava also attaches the glucose chart to the activity as a photo, once per activity (a reprocess never adds a second one, and glucava cannot delete photos). It drives Strava's edit page like the description write, so it depends on the photo file input there: glucava strava inspect <id> reports whether the input is found, and it is a "no photo control" failure alert if not. Note that Strava may use a first photo as the activity's cover instead of the map. The look (theme, size, shaded ranges, dots, line thickness) is set under Settings, with a live preview that updates as you change the options; with the option on, each activity page also shows the exact image. The activity-span and target-range shading, the out-of-range dots, the heart rate curve and the elevation profile (chart_elevation, a faint terrain silhouette behind the glucose curve, fetched from Strava the same way heart rate is) are each a toggleable "panel" that can also be reordered (chart_panel_order): where activity and target-range shading overlap, whichever is listed later wins.hr_read on (default) glucava reads the activity's heart rate from Strava's web session when it processes an activity, thins it to about 300 points and keeps it per activity, like the glucose readings. It shows on the activity page (average, max, min), in the activity summary email, in the activities CSV (hr_avg, hr_max, hr_min), and as a second curve on the chart photo (chart_hr). An activity without heart rate data is processed as before; a failed read is logged and never fails the run. To fill it for an activity processed earlier, reprocess it.chart_pre_minutes (default 30) before the activity so you see where glucose came from. Only the chart uses this; the description numbers keep using pre_minutes.text/template using {{.TIR}}, {{.TIRWindow}}, {{.Min}}, {{.Max}}, {{.Avg}}, {{.StdDev}}, {{.CV}}, {{.GMI}}, {{.VeryLow}}, {{.VeryHigh}}, {{.Unit}}, {{.Distance}}, {{.Elevation}}, {{.Pace}} and {{.Sparkline}} ({{if .Sparkline}}...{{end}} to only draw it when there is one). {{.TIR}} covers the activity window (pre_minutes lead-in, nothing after); {{.TIRWindow}} covers the wider window the chart photo draws from (chart_pre_minutes/post_minutes) — the chart and the description can legitimately show different percentages for the same activity, since they cover different spans, and {{.TIRWindow}} is how to show or explain that in the text too. If post_minutes is set, {{.TIRWindow}} starts out computed from whatever glucose data has arrived so far and is corrected on the delayed reprocess pass once the rest lands (same as the chart photo). {{.Distance}} (e.g. "12.02 km") and {{.Elevation}} (e.g. "143 m") are the activity's totals from Strava's own listing; {{.Pace}} is a single sport-aware field already in the right unit and label (pace for Run/TrailRun/Walk/Hike/Swim, speed for Ride/VirtualRide/EBikeRide/Handcycle), empty for a sport with no meaningful distance metric (Workout, WeightTraining, …) or a manual/trainer entry with no distance recorded. The preview updates as you type, using your latest activity or made-up sample data, before anything is saved; an invalid template is rejected on save, and — as a backstop — falls back to the default rather than skip an activity if one somehow got through anyway. Scriptable as description_template, like every other setting.gap_alert_hours, 0 turns it off) glucava raises one alert per gap, through the usual channels. It catches a stopped sensor share or a broken Dexcom login, and does not try to replace the sensor app's own low/high alarms.internal/report/fonts/OFL.txt), so the output is identical on every host and nothing is fetched from the internet; a month is two pages and takes well under a second. The official Docker image includes the typst binary. When running the binary yourself install Typst (0.15 or newer) on the PATH or point GLUCAVA_TYPST at it; without it the download answers with a 503 that says so and everything else works. The same URL works from scripts with a session cookie: /export/report.pdf?range=30d (also range=7d|14d|90d|all, from=YYYY-MM-DD&to=YYYY-MM-DD, compare=prev). It is not a medical device and says so in every page footer.make glucose-import FILE=export.csv FORMAT=libre backfills readings from another app's export directly into the same database, so an older activity can be reprocessed against them. glucava glucose import --format currently understands glooko (a Glooko export — either the zip download directly, or cgm_data_*.csv from its extracted folder; verified against a real export), libre (a LibreView CSV export; unverified against a real file — see the code comment) and nightscout (an entries.json export). A zip is decoded entirely in memory and never extracted to disk: the entry name is only ever compared as a string, never used to build a filesystem path, so a malicious entry name cannot write outside the intended location; decompression is capped to bound a zip bomb. Adding another format is one function; see the extension point below.24hGLUCAVA_RETRY_BACKOFF | waits before each retry of a failed job, comma-separated (default 1m,3m,10m) |
GLUCAVA_NOTIFY_COOLDOWN, GLUCAVA_NOTIFY_MAX_AGE | one notification per event and activity per cooldown (default 6h); undeliverable events older than max age are dropped (default 24h) |
GLUCAVA_STRAVA_SELECTOR_DESCRIPTION, GLUCAVA_STRAVA_SELECTOR_SAVE | JSON array of CSS selectors for Strava's edit page, tried before the built-in ones, so a Strava markup change is a config fix, not a release. Check with glucava strava check |
GLUCAVA_USER_AGENT | browser user agent for the Strava session (default built in) |
GLUCAVA_ADMIN_UI=1 | expose PocketBase admin UI and API (off by default) |
GLUCAVA_NO_SANDBOX=1 | Chrome --no-sandbox (set in the Docker image) |
CHROME_PATH | Chrome binary |