Skip to content
ilodezisPublic

About

Self-hosted health data lake & AI wellness navigator. Tracks Garmin, Hevy, GLP-1, nutrition, labs with MCP tools.

Resources

Contributing

Security policy

Stars

12 stars

Watchers

0 watching

Forks

Latest commit

 

History

312 Commits

Folders and files

Repository files navigation

Vitals

Self-hosted personal health data lake & dashboard with 75 MCP tools for Claude.ai
React PWA · EN/RU interface · 15 Domains · Weight & BIA · HRT/TRT · GLP-1 · Garmin · Hevy · Nutrition · Labs · Genetics · Skincare · Timeline · Signals & Telegram · AI Digests · Doctor Report

Stars Last Commit Python FastAPI PostgreSQL Redis Docker React TypeScript Vite MCP PWA License

Claude querying Vitals via MCP

ru Русский  ·  en English


Vitals - Личный кабинет здоровья и data lake

Vitals — персональный дашборд здоровья и полноценное «озеро данных» (data lake) для одного пользователя. Система спроектирована для долгосрочного отслеживания биомаркеров, рекомпозиции тела, контроля терапии GLP-1, прохождения курсов гормональной терапии (ГЗТ / TRT), анализа спортивных показателей и построения еженедельных AI-отчётов с помощью языковых моделей через OpenRouter.

Интерфейс — React-приложение (PWA) с тёплой сливово-угольной темой, а не «клинический терминал»: поверхности никогда не чисто-чёрные и не белые, янтарный акцент (#F5A623) тратится только на «сейчас / ты здесь / главное действие», шрифты — Geologica и Golos Text (кириллица из коробки, без моноширинных). Тревоги идут лесенкой note → info → warn → block, а не стеной красного. Дизайн-система целиком описана в docs/DESIGN_SYSTEM.md.

Главное отличие от фитнес-трекеров — принцип максимального сохранения сырых данных. Vitals — умный навигатор здоровья, который подсвечивает неочевидные взаимосвязи между сном, тренировками, медикаментами и весом, помогая принимать взвешенные решения. Не надзиратель — штурман.

С недавних пор у Vitals есть голос: проактивный слой в Telegram присылает утренний бриф, вечером спрашивает про завтра и ловит свободный текст («голова раскалывается», «кофе в 22») в отдельный домен сигналов. Дашборд больше не ждёт, пока в него зайдут.

Зачем я это сделал

Мои данные были разбросаны по многим приложениям: Garmin, Hevy, заметки по GLP-1 и ГЗТ, таблица с анализами, файл с добавками. Ни одно из них не умело сопоставлять сон с восстановлением после тренировки или связывать побочные эффекты терапии с питанием — всё приходилось держать в голове.

Vitals написан с Claude в качестве основного инструмента разработки — но модель данных, архитектура и доменная структура мои. MCP-слой — ключевая часть: Claude может в реальном времени читать и записывать данные по всем 15 доменам, отвечая на вопросы вроде «как мой сон за последнюю неделю влияет на восстановление?» с конкретными цифрами.

Что умеет

Vitals dashboard walkthrough

Vitals.-.promo.mp4

📊 Данные и аналитика

  • 15 независимых доменов здоровья, терапии и хронологии
  • Автосинк Garmin Connect + Hevy API
  • OCR-парсинг анализов через LLM (Gemini)
  • Vision-парсинг бланков состава тела InBody / МедАсс (BIA)
  • Движок конфликтов: 116 курируемых правил (усвоение, фармакогенетика, дерматология, безопасность анализов, GLP-1, ГЗТ, противопоказания)
  • 7-дневное скользящее среднее + линейная регрессия
  • Детекция плато на GLP-1
  • Кросс-доменная хронология событий (Timeline) + флажки-аннотации на графиках
  • Кастомные графики: наложение любых метрик друг на друга

🤖 AI и интеграции

  • Еженедельные AI-дайджесты (Claude / GPT) из версионированного контекста за закрытый период: Garmin и Hevy, GLP-1 и ГЗТ, состав тела, анализы, питание и остальные включённые домены
  • Проактивный канал в Telegram: утренний бриф, вечерний блок, подсказки по условию
  • 75 MCP-инструментов для Claude.ai (чтение + запись + override)
  • OAuth 2.0 + PKCE авторизация MCP
  • Полный экспорт данных для LLM (copy-paste ready)
  • Атомарный бэкап и восстановление БД
  • Настраиваемые модели через OpenRouter

📱 Мобильный опыт

  • PWA с установкой на Home Screen (iOS/Android)
  • Двуязычный интерфейс (русский / английский) — переключение в настройках
  • Адаптация под все размеры (Mobile / Tablet / Desktop)
  • React + TanStack Router — мгновенные переходы между экранами, жесты назад на телефоне
  • Офлайн-кэш последних данных и установка как приложение (Service Worker)
  • Собственные SVG-графики, отрисованные под ширину экрана

🔒 Приватность и контроль

  • Self-hosted, single-user, behind VPN
  • Bcrypt-авторизация + подписанные сессии
  • Двухфакторный вход (TOTP) — по желанию, включается в настройках
  • CSRF + Origin Guard + CSP
  • Модульный дашборд — включай/выключай домены в настройках
  • Никаких внешних CDN — шрифты и стили полностью локальные
  • Отчёт для врача: снимок за период по ссылке с паролем и сроком жизни

📖 Содержание


🧭 Экраны приложения

Приложение живёт на собственных адресах — /today, /weight, /recovery и так далее. Экран выключенного модуля отправляет на «Сегодня»; старые адреса (/app/..., /garmin, /hevy) постоянно перенаправляются, так что закладки и установленное PWA не ломаются.

Экран Адрес Что там
Сегодня /today Старт дня: ключевые цифры всех включённых доменов и активные предупреждения
Вес /weight, /weight/measures Тренд, скользящее среднее, прогноз даты цели, замеры тела, BIA-сканы, прогресс-фото
Восстановление /recovery (+ /sleep/<дата>, /nights, /activities) Garmin: сон и его фазы, HRV, пульс покоя, стресс, Body Battery; страница каждой ночи; нормы считаются по твоей же истории
Тренировки · Питание /workouts, /nutrition Hevy и КБЖУ с целями
GLP-1 · ГЗТ /glp1, /hrt Протокол, карта инъекций, курсы, кривая активного релиза
Анализы · Генетика /labs, /genetics Фото/PDF → биомаркеры; VCF → варианты
Добавки · Уход · Взаимодействия /supplements, /skincare, /interactions Каталог и рутина, плюс весь каталог правил конфликтов с переключателями
Сигналы /signals Всё, что бот поймал из свободного текста
Хронология · Отчёты · Графики /timeline, /reports, /charts События, дайджесты и брифы, конструктор графиков
Отчёт врача /share Снимок за период по ссылке с паролем
Настройки /settings Модули, язык, 2FA, интеграции, проактивный слой, бэкап

🧠 Философия и ключевые принципы

Note

1. Сохранность сырых данных (Raw Payloads Store) Все интеграции сохраняют исходные ответы API в JSONB-таблицу raw_payloads параллельно со структурированными записями: Garmin, Hevy, анализы, BIA-бланки, исходный VCF и входящие сообщения бота. Если завтра обновится парсер — вся история перепарсится без потерь, и это не обещание на словах: ночная задача raw_payload_sweep (03:30) заново разбирает всё, что помечено «нужен перепарс», по каждому домену независимо.

Tip

2. Движок конфликтов (Conflict Engine) Данные, не код: 116 курируемых правил с источниками и уровнем доказательности (A/B/C) в vitals/data/conflict_rules.yaml, покрывающих усвоение нутриентов, фармакогенетику, дерматологию, безопасность по анализам, GLP-1, ГЗТ/TRT и противопоказания. Правила понимают не только точное совпадение, но и диапазоны (доза ≥ 2 мг), списки, «любое из», и разносят предупреждение по времени приёма (утро/вечер/с едой), а не только по факту совместности. Несовместимая добавка с генетикой или анализом вернёт 409 Conflict; можно нажать «Записать всё равно (Override)» — причина запишется в лог. Каталог просматривается и включается/выключается на странице /interactions.

Important

3. Локализованное время (Timezone Engine) Время «сегодня» и границы дат строго в зоне пользователя (VITALS_TIMEZONE). Никаких сбитых графиков при вечерних тренировках. Всё время через vitals/utils/timeutils.

Caution

4. Self-hosted & Single-User Privacy Биометрические данные принадлежат только вам. Деплой на собственный сервер за VPN. Сессионные куки, bcrypt-пароль, CSRF-защита.

Tip

5. Полная переносимость данных (Data Portability) Система не запирает данные. Полная резервная копия БД (включая сырые ответы API) для миграции. Компактный текстовый экспорт (LLM-ready) для вставки в чат с Claude/ChatGPT. Атомарное восстановление из бэкапа в одной транзакции.

Отдельная дорога наружу — отчёт для врача (/share): выбираешь разделы и период, приложение замораживает снимок и отдаёт ссылку с паролем. Ссылка живёт заданное число дней, отзывается в один клик, считает открытия и в бэкап не попадает. Тот же документ скачивается одним самодостаточным HTML-файлом — без скриптов и внешних запросов, открывается офлайн двойным кликом и печатается. Снимок неизменяемый: данные поменялись — создаёшь новый отчёт, а не подменяешь тот, что уже у врача на руках.


📊 Домены данных

Все домены объединены общим интерфейсом InsightsMixin (date, domain, source + составной индекс):

⚖️ 1. Вес и состав тела
  • Модели: WeightLog, BodyMeasurement, NoiseMarker, ProgressPhoto
  • Расчёт % жира по формуле Navy (шея, талия, бёдра) и безжировой массы тела (LBM)
  • 7-дневное скользящее среднее с исключением шумовых периодов (NoiseMarker)
  • Линейная регрессия: скорость кг/неделю и прогноз даты цели
  • Прогресс-фотографии с привязкой к дате (пакетная загрузка до 5 штук)
  • Приоритет источников веса: ручной ввод ≈ BIA-скан > Garmin (Garmin никогда не перекрывает)
📊 2. Состав тела InBody / МедАсс (BIA-сканы)
  • Модели: BodyScan, BodyScanMetric
  • Загрузка PDF / фото листка анализатора (InBody / МедАсс) → LLM vision-модель распознаёт все доступные метрики (скелетная мускулатура, минералы, белок, висцеральный жир, внеклеточная вода, фазовый угол, сегментарный анализ, общий балл)
  • Интерактивная таблица предпросмотра для валидации данных перед сохранением
  • Жир% и LBM от BIA-скана и Navy сосуществуют на одном графике как разные источники
  • Реестр метрик описан в vitals/services/analytics/body_metrics.py (не требует БД-каталога)
⚗️ 3. Гормональная терапия (ГЗТ / TRT)
  • Модели: HrtCompound, HrtDose, HrtCycle, HrtCycleItem, HrtCycleTemplate, HrtSideEffect
  • Курируемый каталог из 73 препаратов (hrt_compounds.yaml): эфиры тестостерона, 19-норы, оральные ААС, ингибиторы ароматазы / SERM, ХГЧ, ГР / IGF-1 / пептиды — с эфиром, путём введения, периодом полураспада и долей активного гормона
  • Лог приёмов с пересчётом мл → мг (объём × концентрация) и полями серого рынка (бренд / лаба / партия / фактическая концентрация); ротация мест инъекций
  • Полноценные курсы (HrtCycle): два типа — курс (любой протокол на экзогенных гормонах) и ПКТ (свой каденс анализов), движок расписания (сегменты + линейная рампа, фиксированная сетка, дробные интервалы вроде E3.5D) и server-side кривая активного релиза (сумма экспоненциальных распадов по периоду полураспада × доле активного гормона)
  • Напоминания: контроль анализов на курсе (гормональная панель в каталоге Labs) и пропущенные инъекции; soft_warn-правила hrt↔labs (оральные 17-αα + повышенные АЛТ/АСТ, тестостерон + гематокрит, 19-нор + пролактин) через Conflict Engine
  • Сложные многонедельные протоколы: у каждого препарата своя неделя старта внутри курса (оффсет от начала); шаблоны курсов — сохранение плана как повторно используемого шаблона без дат и обмен шаблонами между инстансами через портируемый JSON (экспорт/импорт со строгой валидацией)
  • Только учёт, без рекомендаций по дозам (harm reduction)
💉 4. Протокол GLP-1
  • Модели: Injection, DosePhase, SideEffect
  • Хронологический лог инъекций (Semaglutide / Tirzepatide) с интерактивной картой точек инъекции (ротация мест для избежания липогипертрофии)
  • Линейная шкала фаз дозировок для наложения на графики веса
  • Детекция плато: если доза >14 дней и тренд <100 г/неделю → автоматическое предупреждение
⌚ 5. Garmin Connect
  • Модели: GarminDaily, GarminActivity, GarminIntraday, GarminWeightExport
  • Автосинк: сон, фазы сна, HRV (вариабельность пульса), пульс покоя, стресс, Body Battery, Training Readiness + внутридневные кривые стресса и Body Battery
  • Сессия garminconnect (библиотека пиновая — 0.3.7): токены в Redis + бэкап на диск, том с токенами попадает в backup.sh рядом с дампом БД
  • Предохранитель логина: вход по паролю рационируется (3 раза в 24 ч, потом пауза 6 ч, счётчики в Redis) — Garmin блокирует аккаунт за частые попытки, и каждый ретрай продлевает блок. Живой токен предохранителя не касается; MFA и «залогинились слишком часто» различаются в алертах
  • Расписание опроса — не в .env, а на карточке «Проактивный слой» в /settings: интервал полного синка + лёгкий пульс (шаги за сегодня) между ними, применяется без перезапуска контейнера
  • Вес обратно в Garmin (opt-in) — карточка Garmin отправляет только самое свежее прямое измерение (ручное, MCP или BIA), никогда не возвращает импорт Garmin обратно и не переносит историю. Интервал (по умолчанию 15 минут) и окно свежести (не больше 30 дней) настраиваются на живом планировщике без перезапуска; кнопка «Отправить сейчас» запускает явную сверку. Новые логин и пароль начинают действовать в текущем процессе, а без реквизитов фоновая задача тихо ничего не делает
  • Консервативная запись веса — перед каждым POST Vitals заново читает день Garmin и пишет только в пустой день. Одна уже существующая запись с тем же весом отмечается как внешнее совпадение, а не как принадлежащая Vitals; другой вес, несколько записей или неполный ответ Garmin дают видимый конфликт без добавления дубля и без удаления чужих данных. Коррекция заменяет только точный samplePk, подтверждённый для POST Vitals, и лишь после проверки, что день снова пуст
  • Неопределённость и удаление — до сетевого POST Vitals фиксирует в БД durable-маркер «не проверено» с зарезервированной миллисекундной меткой, поэтому crash/rollback не возвращает операцию в очередь на повторную отправку. Обычно Garmin отвечает 204 без samplePk; тогда владение подтверждается только одной-единственной read-back записью с точным совпадением этой метки, источника MANUAL и веса. Совпадения только по весу недостаточно: планировщик и кнопка «Отправить сейчас» продолжают безопасную сверку и никогда не повторяют неопределённый POST. Удаление локального веса ставит в очередь удаление только подтверждённой записи Garmin, а монотонный курсор не позволяет после удаления или повторного включения выгрузить более старое измерение. Запись идёт через неофициальный web API garminconnect, поэтому функция по умолчанию выключена
  • Полностью переработанный раздел сна с детализированной страницей каждой ночи
  • Резервный канал: импорт JSON-экспортов из Health Auto Export
🏋️ 6. Тренировки Hevy
  • Модели: HevyWorkout, HevyExercise, HevySet
  • Синхронизация силовых тренировок по API (упражнения, веса, подходы, повторения)
  • Упражнения сворачиваются через details/summary для максимальной компактности на мобильных
  • Сопоставление объёма тренировок с показателями восстановления Garmin
🍏 7. Питание
  • Модели: MealLog
  • Учёт приёмов пищи с калориями и макронутриентами (Б/Ж/У)
  • Точное время приёма (eaten_at) и текстовые заметки
  • Настраиваемые цели по КБЖУ через настройки с визуализацией прогресса
💊 8. Каталог добавок
  • Модели: Supplement
  • Каталог витаминов и ноотропов: дозировка, время приёма, уровень доказательности (A/B/C)
  • Название автоматически распознаётся в стабильный ключ через словарь синонимов (RU/EN, ~90 добавок) — работает и с кириллицей
  • Группировка протокола по времени суток: утро / день / вечер / ночь
  • Проверка конфликтов с генетикой, анализами и другими добавками через Conflict Engine
🧴 9. Уход за кожей
  • Модели: SkincareProduct, SkincareLog, SkincareObservation
  • Дневник утреннего / вечернего ухода с указанием активов (ретиноид, азелаиновая кислота, пилинг, ниацинамид+SPF, витамин C, бензоилпероксид)
  • Логирование состояния кожи (жирность, сухость, воспаления, зоны высыпаний)
  • Автопредупреждение при конфликте активов (ретиноид+пилинг, бензоилпероксид+вит.C и т.д.)
🧪 10. Анализы и OCR
  • Модели: LabResult, LabMarker
  • Загрузка PDF / фото анализов → LLM-парсинг биомаркеров (рекомендуется gemini-2.5-flash)
  • Расчёт отклонений (Low / High / Critical) и графики изменения показателей
  • Возможность прямой записи через MCP панели анализов или отдельных маркеров с тем же дедуплированием и raw-хранилищем
🧬 11. Генетика (VCF)
  • Модели: GeneticVariant
  • Парсер VCF-файлов → сопоставление SNP с каталогом предрасположенностей (HFE, MTHFR, G6PD, CYP1A2, COMT и др.)
  • Источник данных для Conflict Engine — фармакогенетические правила при назначении добавок (например, гемохроматоз блокирует железо)
  • Исходные строки VCF (до 50k на импорт) складываются в raw_payloads — расширили словарь интерпретаций, старый файл перечитывается без повторной загрузки
📈 12. Кастомные графики
  • Инструмент-конструктор для гибкого сопоставления любых числовых метрик из разных доменов на одной шкале
  • Оверлеи событий из хронологии (Timeline)
📅 13. Хронология (Timeline)
  • Модели: Annotation
  • Единый кросс-доменный лог событий: ручные аннотации (болезнь, поездка, смена протокола) + автоматически извлекаемые события (смена дозы GLP-1, дозы ГЗТ, сдачи анализов, BIA-сканы, шумные периоды, закрытые цели)
  • Отображение событий как флажков-оверлеев прямо на графиках (включая кастомные)
🎯 14. Цели и вехи
  • Модели: Milestone
  • Интерактивные карточки с целевыми значениями и дедлайнами
  • Динамический расчёт % выполнения и прогноз оставшегося времени до цели
💬 15. Сигналы и проактивный канал (Telegram)
  • Модели: Signal, DayContext, NotificationLog (журнал отправок)
  • Домен, который объясняет цифры остальных четырнадцати: всё, что случилось «в моменте» и не имеет формы — «спать хочу», «голова болит», «кофе в 22». Свободный текст в Telegram → сырое сообщение в raw_payloads → разбор в строки Signal трёх видов (state / symptom / exposure) с эхо-подтверждением и кнопкой «не то» для отката всей пачки
  • Ключи сигналов сознательно свободные на период обкатки: хранятся как их написала модель и сворачиваются к каноничному имени на чтении (KEY_ALIASES) — консолидация словаря будет правкой словаря, а не миграцией
  • Страница /signals: лента захваченного, таблица частот ключей с реальными формулировками под каждым (материал для будущего реестра) и точечное удаление строки, которую парсер понял неправильно. Ввод — только через бота: приложение не второй бот, бот не вторая форма ввода
  • Сообщения, на которых парсер споткнулся (модель упала, кончился баланс), не теряются: утренний бриф перед сборкой делает второй заход — неделя назад, до 20 сообщений
  • DayContext — одна строка на день: удалёнка/офис, зал, нагрузка. Вечерний блок в 23:45 спрашивает про завтра, шаблон недели заранее заполняет то, что предсказуемо по дню недели; каждая кнопка несёт свою дату, так что ответ после полуночи попадает в нужный день
  • Утренний бриф: детерминированные блоки собирает код из того же кросс-доменного контекста, что и недельный дайджест, модель добавляет ровно один абзац интерпретации — упала модель, бриф всё равно придёт; пустой день = молчание, а не пустой бриф. Хранится в weekly_digests (kind='daily_brief'), виден в /reports (там же кнопки «Собрать бриф» и «Отправить тестовое»)
  • Нуджи (подсказки в течение дня) — список спецификаций (условие, текст, кулдаун, категория-переключатель), один движок обходит реестр; добавить подсказку = одна запись. Сейчас три категории: активность, питание, свежесть данных
  • Единые ворота отправки: выключенный модуль, дедуп, тихие часы (только для нудджей) и дневной бюджет сообщений; ответы на вопросы владельца из бюджета исключены
  • Ответы на вопросы: реплай на сообщение бота или просто «почему hrv просел?» — модель видит то сообщение, на которое отвечают, и контекст последнего брифа, и ничего больше. Глубокие разборы — в Claude.ai через MCP, там 75 инструментов и модель получше
  • Сигналы и day_context попадают в контекст и недельного дайджеста, и брифа — с временем суток, потому что «кофе в 22» и «кофе в 9» это разные факты с одним ключом
  • Канал — за протоколом Notifier, выше него никто не знает про Telegram. Модуль signals по умолчанию выключен и работает как рубильник всего проактивного слоя: выключен — бот молчит совсем. Настройки (время брифа и вечернего блока, тихие часы, бюджет, категории нуджей, частота опроса Garmin, интервал и окно свежести экспорта веса, шаблон недели) — на карточке в /settings, сохранение перевешивает задачи на живом планировщике без перезапуска
  • MCP: get_signals, log_signal, get_day_context — Клод видит то, что владелец сказал боту
  • Настройка: VITALS_TELEGRAM_BOT_TOKEN, VITALS_TELEGRAM_CHAT_ID, VITALS_TELEGRAM_WEBHOOK_PATH, VITALS_TELEGRAM_WEBHOOK_SECRET (см. .env.example) — слушается единственный chat id, вебхук проверяется по секретному заголовку

🏗️ Архитектура системы

graph TD
    Garmin[Garmin Connect] -->|автосинк| Raw[raw_payloads · JSONB]
    Hevy[Hevy REST API] -->|автосинк| Raw
    Labs[PDF-анализы / Фото] -->|LLM-парсинг| Raw
    BIA[PDF/Фото InBody] -->|LLM-парсинг| Raw
    VCF[Генетические VCF] -->|импорт| Raw
    TGin[Telegram: свободный текст] -->|вебхук| Raw

    Raw --> CE[Conflict Engine]
    CE --> IL[InsightsMixin Layer]
    Scheduler[APScheduler] -->|Redis-локи| CE

    IL --> DB[(PostgreSQL 15)]
    IL --> Cache[(Redis)]

    DB & Cache --> FastAPI[FastAPI Router + Auth + CSRF]

    FastAPI --> UI[JSON API -> React PWA]
    FastAPI --> MCP[FastMCP · streamable HTTP]
    Scheduler --> PL[Проактивный слой]
    PL -->|ворота: бюджет, тихие часы, дедуп| TGout[Telegram: бриф, вечер, нуджи]

    UI --> Charts[SVG charts]
    UI --> AI[AI-Дайджесты · LLM]
    MCP <-->|OAuth 2.0 + PKCE| Claude[Claude.ai]
Loading
Слой Ответственность
vitals/ (ядро) Модели, сервисы, бизнес-логика. Не знает о FastAPI. Импортируется в скрипты и тесты.
web/ (доставка) FastAPI: JSON API /api/v1, авторизация, CSRF, отдача оболочки React-приложения; немногие серверные страницы (вход, OAuth, отчёт врача). Вызывает сервисы, не содержит бизнес-логику.
vitals/services/proactive/ Проактивный слой четырьмя швами: channels (как сообщение уходит), delivery (можно ли его отправить), compose/brief (что сказано), inbound (что пришло обратно), nudges (когда повод есть), day_plan и prefs. Ничего выше channels не знает слова «telegram».
Фронтенд frontend/: React 19 + TanStack Router/Query, Vite, PWA. Типизированный клиент генерируется из схемы API (npm --prefix frontend run gen). Дизайн-система — DESIGN_SYSTEM.md. Тексты — через vitals/i18n.py (RU/EN), словарь выгружается в приложение.

Фоновые задачи (APScheduler)

Каждая задача идёт под Redis-локом (один исполнитель на всех воркеров) и штампует heartbeat, который видит /health.

Задача Расписание Что делает
keepalive каждую минуту Пульс планировщика — по нему /health понимает, что он жив
glp1_plateau 06:00 Проверка плато на GLP-1, пассивный алерт
hrt_reminders 07:00 Анализы на курсе + пропущенные инъекции
nutrition_day_end 23:00 Итог по КБЖУ за день (предупреждения не считаются с половины дня)
raw_payload_sweep 03:30 Перепарс всего, что помечено «нужен перепарс» (Garmin, Hevy); неподтверждённые анализы и BIA-бланки не трогает
hevy_sync каждые 6 ч Синк тренировок
garmin_sync каждые N ч (настройка, по умолчанию 6) Полный синк Garmin
garmin_pulse интервал в активные часы (настройка) Лёгкий пульс: шаги за сегодня, один запрос без логина
garmin_weight_export каждые N мин (настройка, по умолчанию 15) Безопасная сверка последнего подходящего локального веса с Garmin; выключенная или ненастроенная интеграция ничего не делает
daily_brief время из настроек (по умолчанию 11:00) Синк Garmin → сборка брифа → отправка под бюджетом
evening_block время из настроек (по умолчанию 23:45) Итог дня + вопросы про завтра
nudges ежечасно в :05 Обход реестра подсказок: условие, кулдаун, категория
weekly_digest понедельник, 08:00 AI-дайджест

Время брифа, вечернего блока, частота опроса Garmin, интервал экспорта веса и его окно свежести живут в БД (карточка в /settings), а не в .env — сохранение перерегистрирует задачи на работающем планировщике, перезапуск не нужен.


🔗 MCP-интеграция с Claude.ai

Встроенный сервер FastMCP server доступен на /mcp/ (транспорт streamable HTTP). Авторизация: OAuth 2.0 с верификацией Bearer-токенов. PKCE (S256) обязателен — запрос на /oauth/authorize без code_challenge отклоняется, а не проходит мимо проверки. Метаданные защищённого ресурса отдаются на /.well-known/oauth-protected-resource (RFC 9728), и ответ 401 указывает на них через WWW-Authenticate: Bearer resource_metadata="...", чтобы клиент сам нашёл, где авторизоваться. Настраивается в веб-интерфейсе (раздел настроек).

75 инструментов — Claude может полноценно читать и записывать данные во все домены. Плюс 2 ресурса (vitals://profile, vitals://digest/latest) и промпт weekly_review. Список инструментов фильтруется по включённым модулям: выключенный домен не показывается в tools/list (он и так отклонял запись), так что контекст разговора не тратится на схемы того, что владелец не отслеживает. Включили модуль обратно — инструменты вернутся при следующем подключении коннектора, без перезапуска.

Чтение (33 инструмента)

Инструмент Описание
get_user_profile Физические параметры, цели, программа тренировок
get_weight_logs История взвешиваний, замеры тела, периоды шума
get_glp1_logs Инъекции, фазы дозировок GLP-1, побочные эффекты
get_hrt_logs Дозы ГЗТ/TRT, побочные эффекты, активный курс и его план
get_hrt_cycles Список всех курсов ГЗТ с их подетальными планами
get_garmin_metrics HRV, сон, пульс покоя, активность. intraday=true — поминутные кривые дня и ночи, sleep_detail=true — гипнограмма ночи и события дыхания
get_hevy_workouts Силовые тренировки с подходами и весами
get_supplements_catalog Каталог добавок с дозировками и уровнями доказательности
get_skincare_logs Рутина ухода и наблюдения за состоянием кожи
get_genetics_snps Генетические варианты с фильтром по гену и/или rsid («покажи мой rs1801133»)
get_active_alerts Нерешённые предупреждения и уведомления о конфликтах
get_weekly_digests Архив AI-дайджестов
check_supplement_conflicts Проверка добавки (по названию, RU/EN) на конфликты с генетикой, анализами, другими добавками и уходом за кожей
list_conflict_rules Список курируемых правил конфликтов, фильтр по домену и/или категории
check_conflicts Проверка произвольного состояния любого домена на активные правила конфликтов (без сохранения)
get_nutrition_summary Суточная сводка питания (КБЖУ) и прогресс по целям
search_meals Поиск приёмов пищи по имени и/или дате
get_measurements Замеры тела (шея, талия, бёдра, % жира, LBM)
get_notes Записи с заметками из всех доменов
get_body_scans Замеры состава тела InBody / МедАсс со всеми метриками
get_body_scan Один замер состава тела с полным листком метрик
get_body_metric_history История одной метрики состава тела (SMM, фазовый угол…)
get_lab_results Результаты анализов (биомаркер, значение, референс, флаг отклонения)
get_timeline Кросс-доменная лента событий (ручные аннотации + авто-события)
get_full_snapshot Кросс-доменный срез за период одним вызовом
export_everything Экспорт истории по всем доменам за один вызов (по умолчанию — последние 90 дней; вся история и отдельные домены — явными аргументами)
get_data_overview Карта данных: по каждому домену число строк, диапазон дат, дата обновления
get_milestones Карточки целей с расчётом прогресса
get_modules Какие опциональные модули включены
get_trend Тренд метрики: наклон (день/неделя), скользящее среднее, прогноз даты до цели
get_signals Сигналы из бота: состояния, симптомы, воздействия (фильтры по виду, ключу, датам)
get_day_context Какой это был день: удалёнка/офис, зал, нагрузка
get_proactive_state Состояние проактивного слоя: включён ли, расписание, шаблон недели, недавние отправки (только чтение — перенастройка бота остаётся за владельцем)

Запись (40 инструментов)

Инструмент Описание
log_meal Запись приёма пищи с калориями и макро (Б/Ж/У)
update_meal Обновление существующей записи о еде
log_weight Запись веса (перекрывает Garmin за ту же дату)
log_glp1 Запись инъекции GLP-1 (препарат, доза, место)
log_hrt_dose Запись приема дозы ГЗТ (расчет мл -> мг; бренд/лаборатория/партия; конфликт-гейт)
add_hrt_cycle Начать новый курс ГЗТ (тип, дата начала, название; закрывает прошлый открытый)
add_hrt_cycle_item Добавить препарат и план его приема в курс ГЗТ (разовые дозы, интервал, неделя старта)
update_hrt_dose Редактировать дозу ГЗТ (непереданные поля сохраняются; конфликт-гейт)
log_hrt_side_effect Записать побочный эффект ГЗТ (тип, тяжесть 1–5)
close_hrt_cycle Закрыть курс ГЗТ датой окончания (по умолчанию — сегодня)
log_skincare Запись/обновление дневного чек-листа ухода (upsert)
log_measurement Запись замеров тела (авто-расчёт Navy % жира)
log_note Добавление заметки к записи любого домена (включая анализы)
log_body_scan Запись замера состава тела из метрик (мост в домен веса)
log_lab_result Запись одного биомаркера (авто-расчёт флага отклонения)
log_lab_results Запись всей панели анализов за раз (список маркеров → результаты + дедупликация)
update_lab_result Редактировать результат анализа (пересчёт флага отклонения, обновление алертов)
log_event Ручная аннотация хронологии (поездка, болезнь, смена протокола)
update_event Редактировать событие хронологии (непереданные поля сохраняются)
create_milestone Создать карточку цели (целевое значение, дедлайн)
update_milestone Обновить цель (в т.ч. статус: достигнута/пропущена/пауза)
update_glp1 Редактировать инъекцию GLP-1 (с конфликт-гейтом)
log_side_effect Записать побочный эффект GLP-1 (тип, тяжесть 1–5)
add_dose_phase Добавить фазу дозировки GLP-1
log_skincare_observation Записать наблюдение за кожей (воспаление, ПВГ, зона)
add_supplement Добавить добавку в каталог (конфликт-гейт)
update_supplement Обновить добавку в каталоге
set_supplement_active Включить/выключить добавку (конфликт-гейт при включении)
update_measurement Редактировать замер тела (пересчёт Navy % жира / LBM)
add_noise_marker Отметить период как шумовой (исключается из тренда веса)
upsert_genetic_variant Добавить или обновить генетический вариант (по паре ген + rsid)
set_module Включить/выключить опциональный модуль
log_signal Записать сигнал (состояние / симптом / воздействие) — через тот же сервис, что и бот
mark_signal_misparse Пометить разбор сообщения как ошибочный — то же, что кнопка «не то» в боте (по batch_id)
log_day_context Записать, какой это был день — те же ответы, что владелец нажимает в вечернем блоке (догадка шаблона сохраняется рядом с ответом)
set_week_template Задать шаблон недели: чем считается каждый будний день, пока владелец не сказал иначе
resolve_alert Закрыть предупреждение — оно исчезает из get_active_alerts
override_alert Отметить блокирующее предупреждение как «принято, делаю всё равно»
generate_digest_now Сгенерировать свежий еженедельный AI-дайджест сейчас
delete_record Удалить одну запись любого домена по ID (domain + record_id) — вес, замер, шумовой период, анализ, цель, еда, GLP-1 (инъекция / побочка / фаза), ГЗТ (доза / курс / препарат курса), состав тела, событие хронологии, наблюдение за кожей, добавка, генетический вариант, сигнал

Синхронизация (2 инструмента)

Инструмент Описание
sync_garmin Подтянуть свежие данные Garmin прямо сейчас — дневные метрики и активности за последние days (по умолчанию 2, максимум 30)
sync_hevy Подтянуть свежие тренировки из Hevy прямо сейчас

Note

Лимит — 3 вызова в сутки на каждый. Синхронизация — это исходящий запрос в чужой API (Garmin к тому же троттлит логины), а планировщик и так опрашивает оба источника несколько раз в день. Эти инструменты нужны на случай дыры в данных («последние два дня пустые»), а не перед каждым чтением. Счётчик живёт в Redis и считается по календарному дню; когда лимит исчерпан, инструмент возвращает {"error": ...} и никуда не ходит. Плановая синхронизация от лимита не зависит и продолжает работать.

Tip

Override-флоу. Все записывающие инструменты, проходящие движок конфликтов (вес, GLP-1, ГЗТ, добавки, кожа, замеры, состав тела), принимают override=true. При жёстком блоке инструмент возвращает {"blocked": true, "violations": [...]} вместо сохранения — повтор вызова с override=true сохраняет (аналог кнопки «Записать всё равно» в UI).

Important

Правки частичные. Все update_* меняют только те поля, которые переданы в вызове; остальные берутся из существующей строки, а не обнуляются. Дата не передана — остаётся дата самой записи, а не «сегодня». Веб-формы работают иначе (шлют всё, очистка поля действительно очищает колонку) — расхождение намеренное и живёт на границе MCP.

Периметр записи. Запись в выключенный опциональный модуль отклоняется на общей точке входа, а не в каждом инструменте по отдельности: новый инструмент наследует проверку, а не «не забудь дописать».

Провенанс. Записи через коннектор помечаются источником mcp. В приоритете источников веса mcp равен ручному вводу — то есть перекрывает Garmin и не перекрывается им; между собой решает свежесть. Старые записи задним числом не перекрашиваются.

Note

Ответ платит только за то, о чём спросили. Схемы 75 инструментов пересылаются с каждым сообщением разговора, а чтение по умолчанию отдаёт сотню строк — поэтому в ответах нет ничего, чем модель не может воспользоваться.

  • Строка — это данные, а не служебка. serialize_row не отдаёт колонки, которые ни один инструмент не принимает обратно (domain, created_at, updated_at, raw_payload_id), и не отдаёт незаполненные поля: отсутствующий ключ и null читаются одинаково, а null стоит токенов на каждой строке. id, date и source остаются — правки и удаления адресуются по id, а провенанс сам по себе ответ. Строка ужимается на 39–59%.
  • Гипнограмма — по запросу. Поминутная раскладка фаз сна и события дыхания — это ~70% дневной строки Garmin, и раньше они ехали на каждом чтении последней сотни ночей. По умолчанию сворачиваются в счётчик с подсказкой ("28 entries — call again with sleep_detail=True"), а не исчезают: молчание модель прочитала бы как «фаз сна нет». Флаг отдельный от intraday — вопрос про форму одной ночи не тянет за собой все кривые окна. Чтение за 100 дней: ~96k → ~24k токенов.
  • Пустая колонка молчит. Ночь без гипнограммы не получает подсказку вовсе — «не измерено» и «свёрнуто» остаются различимы.

Пример диалога с Claude

Пользователь: Как у меня дела за последние 2 недели? Вес, тренировки, сон — общая картина.

Claude: [вызывает get_weight_logs, get_hevy_workouts, get_garmin_metrics]

Вес: 89.2 → 87.8 кг за 14 дней (−1.4 кг, ~0.7 кг/неделю — в рамках плана).

Тренировки: 4 сессии (2× верх, 2× ноги). Прогрессия: жим лёжа +5 кг,
тяга верхнего блока +7.5 кг.

Сон: в среднем 7ч 12мин, HRV снижается последние 3 дня — вероятно,
накопленная усталость после тренировок. Body Battery восстанавливается нормально.

Рекомендация: recovery выглядит достаточным, следующую тренировку можно проводить.

🚀 Быстрый старт

1. Клонируйте и настройте окружение

git clone https://github.com/ilodezis/vitals.git
cd vitals
cp .env.example .env

2. Сгенерируйте секреты

# Ключ для подписи сессионных кук
python -c "import secrets; print(secrets.token_urlsafe(48))"

# Bcrypt-хеш пароля для входа
python -c "import bcrypt; print(bcrypt.hashpw(b'ваш-пароль', bcrypt.gensalt(4)).decode())"

3. Заполните .env

VITALS_SESSION_SECRET="результат-первой-команды"
VITALS_AUTH_USERNAME="your-username"
VITALS_AUTH_PASSWORD_HASH="результат-второй-команды"

# Пароль PostgreSQL внутри Docker-сети — обязателен, без него compose не стартует
VITALS_DB_PASSWORD="любой-длинный-пароль"

# Секрет OAuth для подключения Claude.ai по MCP
VITALS_MCP_CLIENT_SECRET="ещё-один-случайный-секрет"

4. Запустите

docker compose up -d --build

Note

Миграции Alembic применяются автоматически при старте vitals_app. PostgreSQL 15 — внутри Docker-сети.

5. Проверьте

Дашборд: http://127.0.0.1:8000

curl -s http://127.0.0.1:8000/health

6. Проактивный слой в Telegram (опционально)

Без этих четырёх переменных приложение работает ровно как раньше: бот молчит, вебхук отвечает 401.

  1. Создайте бота у @BotFather → VITALS_TELEGRAM_BOT_TOKEN.
  2. Узнайте свой id у @userinfobot → VITALS_TELEGRAM_CHAT_ID. Это единственный чат, который бот слушает.
  3. Сгенерируйте секретный сегмент пути и секрет заголовка:
    python -c "import secrets; print(secrets.token_urlsafe(24))"
    → VITALS_TELEGRAM_WEBHOOK_PATH и VITALS_TELEGRAM_WEBHOOK_SECRET.
  4. Зарегистрируйте вебхук один раз (домен должен быть доступен Telegram снаружи):
    curl -X POST "https://api.telegram.org/bot<TOKEN>/setWebhook" -d "url=https://<ваш-хост>/tg/<PATH>" -d "secret_token=<SECRET>"
  5. Включите модуль Сигналы в /settings — он же рубильник всего проактивного слоя — и настройте время брифа, тихие часы и бюджет сообщений на карточке «Проактивный слой».

🛡️ Безопасный деплой (Сетап автора)

Поскольку Vitals содержит чувствительные медицинские, генетические и персональные биометрические данные, категорически не рекомендуется оставлять приложение открытым для всего интернета. По умолчанию в docker-compose.yml порты привязаны только к локальному адресу (127.0.0.1:8000), чтобы никто извне не мог получить доступ напрямую.

Вариант А: Cloudflare Tunnel (Рекомендуется автором)

Это самый простой и надежный способ опубликовать приложение с SSL-сертификатом без необходимости открывать порты наружу (ваш сервер остается невидимым для сканеров портов).

  1. Регистрация домена: Добавьте свой домен в Cloudflare.
  2. Создание туннеля: В панели Cloudflare Zero Trust перейдите в Access -> Tunnels и создайте новый туннель (например, vitals-tunnel).
  3. Установка cloudflared: Скопируйте предоставленную Cloudflare команду установки для вашей ОС (Ubuntu/Debian) и выполните ее на VPS. Она установит и запустит демон cloudflared как системную службу.
  4. Маршрутизация: В настройках туннеля во вкладке Public Hostname укажите:
    • Subdomain/Domain: Например, vitals.yourdomain.com
    • Service Type: HTTP
    • URL: localhost:8000 (or 127.0.0.1:8000)
  5. Дополнительная защита (Cloudflare Access):
    • Чтобы защитить приложение еще до страницы авторизации, в панели Zero Trust перейдите в Access -> Applications и создайте приложение для вашего домена (например, vitals.yourdomain.com).
    • Настройте политику доступа (Access Policy), разрешающую вход только вашему email-адресу через одноразовые коды (One-Time PIN) или социальные провайдеры (Google/GitHub). Теперь перед входом в Vitals нужно будет подтвердить свою личность в Cloudflare.

Вариант Б: Классический Nginx реверс-прокси + SSL + VPN/Basic Auth

Классический подход с веб-сервером Nginx в качестве прокси-сервера.

  1. Настройка DNS: Направьте A-запись домена (например, vitals.yourdomain.com) на публичный IP-адрес вашей VPS.
  2. Установка Nginx:
    sudo apt update
    sudo apt install nginx -y
  3. Конфигурация хоста: Создайте файл /etc/nginx/sites-available/vitals со следующим содержимым:
    server {
        listen 80;
        server_name vitals.yourdomain.com;
    
        location / {
            proxy_pass http://127.0.0.1:8000;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
            
            # Отключаем ограничение на размер загружаемых PDF-анализов и фото
            client_max_body_size 20M;
        }
    }
    Активируйте конфиг и перезапустите Nginx:
    sudo ln -s /etc/nginx/sites-available/vitals /etc/nginx/sites-enabled/
    sudo nginx -t && sudo systemctl restart nginx
  4. Установка SSL-сертификата (Let's Encrypt):
    sudo apt install certbot python3-certbot-nginx -y
    sudo certbot --nginx -d vitals.yourdomain.com
  5. Ограничение доступа (на выбор):
    • Через VPN (Tailscale/WireGuard): Настройте Nginx так, чтобы он слушал порт 443 только на внутреннем IP-адресе VPN-интерфейса (например, listen 100.x.x.x:443 ssl), а не на внешнем интерфейсе сервера.
    • Через HTTP Basic Auth: Защитите роут паролем. Сгенерируйте файл с паролями:
      sudo apt install apache2-utils -y
      sudo htpasswd -c /etc/nginx/.htpasswd username
      Добавьте в блок location / конфигурации Nginx:
      auth_basic "Private Vitals Access";
      auth_basic_user_file /etc/nginx/.htpasswd;

Нужен просто публичный доступ, без VPN и Basic Auth? Пропустите шаг 5, поставьте домен под прокси Cloudflare (оранжевое облако — реальный IP сервера скрыт) и включите режим шифрования Full (strict) в SSL/TLS → Overview. Вместо Let's Encrypt можно взять Origin-сертификат Cloudflare (SSL/TLS → Origin Server). Учтите: в этом случае страница входа Vitals — единственное, что отделяет ваши медицинские данные от интернета. Именно для такой установки и стоит включить двухфакторный вход (Настройки → Двухфакторная защита): тогда одного утёкшего пароля будет мало.


⚙️ Параметры конфигурации

Инфраструктура
Переменная Описание Дефолт
VITALS_DATABASE_URL PostgreSQL (asyncpg) postgresql+asyncpg://...
VITALS_DB_USER Пользователь PostgreSQL (для контейнера БД) vitals
VITALS_DB_PASSWORD Пароль PostgreSQL Обязательный
VITALS_DB_NAME Имя базы vitals_db
VITALS_REDIS_URL Redis для кэша и локов redis://vitals_redis:6379/0
VITALS_TIMEZONE Зона пользователя Europe/Chisinau
VITALS_BACKUP_RETENTION_DAYS Сколько дней хранить авто-бэкапы 7

Тонкая настройка пула соединений и таймаутов (VITALS_DB_POOL_SIZE, VITALS_DB_MAX_OVERFLOW, VITALS_DB_POOL_TIMEOUT, VITALS_DB_POOL_RECYCLE, VITALS_DB_STATEMENT_TIMEOUT_MS) — с рабочими дефолтами в .env.example, трогать не обязательно.

Авторизация и безопасность
Переменная Описание Дефолт
VITALS_SESSION_SECRET Ключ подписи сессий Обязательный
VITALS_AUTH_USERNAME Имя пользователя Обязательный
VITALS_AUTH_PASSWORD_HASH Bcrypt-хеш пароля Обязательный
VITALS_SESSION_TTL Время жизни сессии (сек) 2592000 (30 дней)
VITALS_COOKIE_SECURE Флаг Secure для кук true
VITALS_COOKIE_SAMESITE Политика SameSite lax

Двухфакторный вход переменной не имеет: он выключен по умолчанию и включается в настройках (Настройки → Двухфакторная защита). Ключ хранится в базе, а не в .env, и в бэкап не попадает — значит после переезда на новый сервер 2FA нужно включить заново.

Профиль пользователя
Переменная Описание Дефолт
VITALS_HEIGHT_CM Рост (см) 190
VITALS_SEX Пол (male / female) male
VITALS_USER_AGE Возраст 18
VITALS_BODY_FAT_SOURCE Главный источник жира%/LBM: latest / navy / bia latest
VITALS_USER_PROGRAM Описание программы Встроено
VITALS_USER_GOALS Цели (через запятую) снижение жира, сохранение мышц
Цели питания
Переменная Описание Дефолт
VITALS_NUTRITION_PROTEIN_TARGET_G Суточный белок (г) 150
VITALS_NUTRITION_CALORIES_MIN Минимум ккал 1300
VITALS_NUTRITION_CALORIES_MAX Максимум ккал 1700
Интеграции и AI
Переменная Описание Дефолт
VITALS_OPENROUTER_API_KEY API-ключ OpenRouter Опционально
VITALS_OPENROUTER_BASE_URL Адрес API OpenRouter https://openrouter.ai/api/v1
VITALS_LLM_MODEL_DIGEST Модель для дайджестов anthropic/claude-sonnet-4.6
VITALS_LLM_MODEL_PARSER Модель для OCR анализов и BIA google/gemini-2.5-flash
VITALS_LLM_MODEL_BRIEF Модель утреннего брифа (пусто → модель дайджеста) Опционально
VITALS_HEVY_API_KEY API-ключ Hevy Опционально
VITALS_HEVY_BASE_URL Адрес API Hevy https://api.hevyapp.com
VITALS_GARMIN_EMAIL Email Garmin Connect Опционально
VITALS_GARMIN_PASSWORD Пароль Garmin Connect Опционально
VITALS_GARMIN_TOKEN_DIR Путь к токенам Garmin /data/garmin_session
VITALS_MCP_CLIENT_ID OAuth Client ID для MCP vitals-claude-connector
VITALS_MCP_CLIENT_SECRET OAuth Client Secret для MCP Обязательно
VITALS_MCP_REDIRECT_HOSTS Разрешённые хосты OAuth-callback'ов (через запятую, только https) claude.ai,chatgpt.com,oauth-redirect.googleusercontent.com
VITALS_EXTERNAL_API_TOKEN Токен для сервер-сервер Glance API Опционально
VITALS_TELEGRAM_BOT_TOKEN Токен бота от @BotFather Опционально
VITALS_TELEGRAM_CHAT_ID Единственный чат, который бот слушает Опционально
VITALS_TELEGRAM_WEBHOOK_PATH Секретный сегмент URL вебхука (/tg/<path>) Опционально
VITALS_TELEGRAM_WEBHOOK_SECRET Секрет из заголовка X-Telegram-Bot-Api-Secret-Token Опционально

Расписаний здесь нет намеренно: время брифа и вечернего блока, тихие часы, дневной бюджет сообщений, категории нуджей, частота опроса Garmin, интервал и окно свежести экспорта веса живут в БД и правятся на карточке «Проактивный слой» в /settings — эти настройки должны применяться без перезапуска контейнера.


🛠️ Разработка и тесты

Локальное окружение

python -m venv .venv
.venv\Scripts\activate          # Windows
source .venv/bin/activate       # macOS/Linux
pip install -r requirements-dev.txt
npm --prefix frontend ci

Dev-сервер (без Docker)

Сначала соберите приложение — сервер отдаёт готовую сборку из web/static/app, без неё на экранах будет frontend not built:

npm --prefix frontend run build
python run_local.py

Автоматически поднимает SQLite + FakeRedis. Логин по умолчанию: timur / password. Адрес: http://127.0.0.1:8000.

Правите фронтенд — запустите рядом Vite с горячей перезагрузкой (проксирует API на :8000):

npm --prefix frontend run dev     # http://127.0.0.1:5173

Поменяли API — перегенерируйте типизированный клиент и словарь переводов одной командой: npm --prefix frontend run gen.

Демо-данные

python scripts/seed_demo.py

Засевает локальную базу правдоподобным набором данных по всем доменам (вес, Garmin, тренировки, питание, GLP-1, ГЗТ, анализы, добавки, генетика, уход за кожей). Идемпотентный — можно запускать повторно.

Тесты

# Юнит-тесты (SQLite, мгновенно)
python -m pytest -q

# Интеграционные (Postgres Docker)
bash scripts/test_postgres.sh

# Фронтенд: типы + тесты + сборка + бюджет размера
npm --prefix frontend run check

☕ Поддержать проект

Tip

Если Vitals помогает вам отслеживать показатели здоровья и облегчает жизнь, вы можете поддержать проект:

  • Карты РФ (CloudTips): pay.cloudtips.ru/p/5bb82648
  • Международные карты (Ko-fi): ko-fi.com/ilodezis
  • USDT (TRC-20): TV6bMburcEjgkinkZBvt9DonkyiaP7TqHG
  • USDT (Arbitrum One): 0x3eac15f5d07bba100d4038cad603e420a84753bb

📄 Лицензия

PolyForm Noncommercial License 1.0.0 — использование и модификация разрешены в некоммерческих целях. Коммерческое использование запрещено. Подробности: LICENSE.



Vitals - Personal Health Dashboard and Data Lake

Vitals is a personal health dashboard and data lake designed for a single user. Built for long-term tracking of biomarkers, body recomposition, GLP-1 therapy, hormone replacement therapy (HRT / TRT) cycles, athletic performance, and AI-powered weekly analytical digests via OpenRouter LLMs.

The interface is a React app (PWA) with a warm plum-charcoal theme — a health companion, not a clinical terminal: surfaces are never pure black or white, the honey-amber accent (#F5A623) is spent only on "now / you are here / the one action", and type is Geologica + Golos Text (Cyrillic out of the box, no monospace). Alerts climb a ladder, note → info → warn → block, instead of a wall of red. The full design system is documented in docs/DESIGN_SYSTEM.md.

Unlike typical fitness trackers, Vitals prioritizes preserving raw historical data. It serves as a smart wellness navigator — uncovering correlations between sleep, workouts, supplements, and body composition. Not a watchdog — a co-pilot.

Vitals now has a voice: a proactive layer over Telegram delivers a morning brief, asks about tomorrow in the evening, and catches free text ("headache", "coffee at 22:00") into its own signals domain. The dashboard no longer waits to be opened.

Why I built this

My data was scattered across many apps: Garmin, Hevy, GLP-1 and HRT notes, a spreadsheet with lab results, a file with supplements. None of them could cross-reference sleep with training recovery or link therapy side effects to nutrition — I had to keep it all in my head.

Built with Claude as the primary coding tool — but the data model, architecture, and domain design are mine. The MCP layer is the key part: Claude can read and write data across all 15 domains in real time, answering questions like "how did my sleep this week affect my recovery?" with actual numbers.

Claude querying Vitals via MCP

What it does

Vitals dashboard walkthrough

Vitals.-.promo.mp4

📊 Data & Analytics

  • 15 independent health, therapy and event domains
  • Auto-sync Garmin Connect + Hevy API
  • OCR lab report parsing via LLM (Gemini)
  • Vision-parsing of InBody / МедАсс (BIA) body composition reports
  • Conflict engine: 116 curated rules (absorption, pharmacogenomics, dermatology, lab safety, GLP-1, HRT, contraindications)
  • 7-day moving average + linear regression
  • GLP-1 plateau detection
  • Cross-domain event timeline (Timeline) + chart annotation flags
  • Custom charts: overlay any metrics across domains on a single chart

🤖 AI & Integrations

  • Weekly AI digests (Claude / GPT via OpenRouter) from versioned closed-period context: Garmin and Hevy, GLP-1 and HRT, body composition, labs, nutrition, and the other enabled domains
  • Proactive Telegram channel: morning brief, evening block, condition-driven nudges
  • 75 MCP tools for Claude.ai (read + write + override)
  • OAuth 2.0 + PKCE authorization for MCP
  • Full data export for LLM (copy-paste ready)
  • Atomic database backup & restore
  • Configurable models via OpenRouter

📱 Mobile Experience

  • PWA with Home Screen install (iOS/Android)
  • Bilingual interface (English / Russian) — switchable in Settings
  • Fully responsive across Mobile / Tablet / Desktop
  • React + TanStack Router — instant screen changes, swipe-back on the phone
  • An offline copy of the latest data and install-as-app (Service Worker)
  • Hand-drawn SVG charts laid out at the screen's width

🔒 Privacy & Control

  • Self-hosted, single-user, behind VPN
  • Bcrypt auth + signed session cookies
  • Optional two-factor sign-in (TOTP), switched on in Settings
  • CSRF + Origin Guard + CSP
  • Modular dashboard — toggle domains on/off in Settings
  • No external CDNs — fonts and assets are hosted locally
  • Doctor report: a snapshot for a period, behind a password-protected link that expires

📖 Table of Contents


🧭 App Screens

The app lives on its own addresses — /today, /weight, /recovery, and so on. A screen of a switched-off module sends you to Today; the old addresses (/app/..., /garmin, /hevy) redirect permanently, so bookmarks and an installed PWA keep working.

Screen Address What's there
Today /today The start of the day: key numbers from every enabled domain and the active warnings
Weight /weight, /weight/measures Trend, moving average, goal-date projection, body measurements, BIA scans, progress photos
Recovery /recovery (+ /sleep/<date>, /nights, /activities) Garmin: sleep and its stages, HRV, resting heart rate, stress, Body Battery; a page for every night; norms computed from your own history
Workouts · Nutrition /workouts, /nutrition Hevy and macros against targets
GLP-1 · HRT /glp1, /hrt The protocol, injection-site map, cycles, the active-release curve
Labs · Genetics /labs, /genetics Photo/PDF → biomarkers; VCF → variants
Supplements · Skincare · Interactions /supplements, /skincare, /interactions Catalog and routine, plus the whole conflict-rule catalog with toggles
Signals /signals Everything the bot caught from free text
Timeline · Reports · Charts /timeline, /reports, /charts Events, digests and briefs, the chart builder
Doctor report /share A snapshot for a period behind a password-protected link
Settings /settings Modules, language, 2FA, integrations, the proactive layer, backup

🧠 Core Philosophy

Note

1. Raw Data Preservation All API integrations preserve original JSON payloads in a JSONB raw_payloads table alongside normalized records: Garmin, Hevy, lab reports, BIA sheets, the source VCF, and every inbound bot message. If a parser changes — the entire history can be re-processed with zero loss, and that isn't only a promise: a nightly raw_payload_sweep (03:30) re-derives everything flagged for re-parsing, each domain committing independently.

Tip

2. Resilient Conflict Engine Data, not code: 116 curated rules with sources and an evidence tier (A/B/C) in vitals/data/conflict_rules.yaml, covering nutrient absorption, pharmacogenomics, dermatology, lab safety, GLP-1, HRT, and contraindications. Rules understand more than exact matches — comparisons (dose ≥ 2mg), lists, "any of", and time-of-day separation (morning/evening/with food), not just simultaneous presence. A supplement clashing with genetics or a lab result returns 409 Conflict; users can explicitly override with an audit note. Browse and toggle the whole catalog on /interactions.

Important

3. Localized Time Boundaries Daily resets and calendar math resolve against the user's timezone (VITALS_TIMEZONE), not the server clock. No more split days from late-night workouts. All time via vitals/utils/timeutils.

Caution

4. Single-User Self-Hosted Privacy Your biometrics belong to you. Designed for deployment behind a VPN. Secure session cookies, bcrypt passwords, CSRF protection, and an optional TOTP second factor for installs that face the open internet.

Tip

5. End-to-End Data Portability Full database backup (including raw API payloads) for migration. Curated, secret-free text export designed for pasting into LLM chats (Claude, ChatGPT). Atomic restore within a single database transaction.

A separate way out is the doctor report (/share): pick the sections and the period, and the app freezes a snapshot behind a password-protected link. The link expires on a schedule you set, is revocable in one click, counts its openings, and never reaches a backup. The same document downloads as a single self-contained HTML file — no scripts, no outside requests — which opens offline on a double-click and prints. The snapshot is immutable: when the data changes you create a new report rather than swapping the one already in a doctor's hands.


📊 Supported Domains

All domains share the InsightsMixin interface (date, domain, source + composite index):

⚖️ 1. Weight & Body Composition
  • Models: WeightLog, BodyMeasurement, NoiseMarker, ProgressPhoto
  • US Navy body fat % formula (neck, waist, hips) + Lean Body Mass (LBM)
  • 7-day moving average with noise period exclusion (NoiseMarker)
  • Linear regression: weekly rate (kg/week) + goal date projection
  • Progress photos with calendar linking (batch upload up to 5 at once)
  • Weight source priority: manual ≈ BIA scan > Garmin (Garmin never supersedes)
📊 2. InBody / МедАсс (BIA scans)
  • Models: BodyScan, BodyScanMetric
  • Upload InBody/МедАсс analyzer sheets (PDF/photo) → LLM vision model extracts every biomarker (skeletal muscle, minerals, protein, visceral fat, extracellular water, phase angle, segmental analysis, score)
  • Interactive preview table for validation before final save
  • BIA scans and Navy circumference measurements coexist on charts as distinct data sources
  • Metric definitions live dynamically in vitals/services/analytics/body_metrics.py (no DB catalog required)
⚗️ 3. Hormone Therapy (HRT / TRT)
  • Models: HrtCompound, HrtDose, HrtCycle, HrtCycleItem, HrtCycleTemplate, HrtSideEffect
  • Curated catalog of 73 compounds (hrt_compounds.yaml): testosterone esters, 19-nors, oral AAS, aromatase inhibitors / SERMs, HCG, GH / IGF-1 / peptides — each with ester, route, half-life and active-hormone fraction
  • Dose log with ml → mg computation (volume × concentration) and grey-market fields (brand / lab / batch / measured concentration); injection-site rotation
  • Full cycles (HrtCycle): two kinds — course (any exogenous-hormone protocol) and PCT (its own bloodwork cadence), a schedule engine (segment lists + linear ramp, fixed grid, fractional intervals like E3.5D), and a server-rendered active-release curve (sum of exponential decays by half-life × active fraction)
  • Reminders: bloodwork-due while on cycle (a hormone panel seeded into the Labs catalog) and missed injections; soft_warn hrt↔labs rules (oral 17-αα + high ALT/AST, testosterone + hematocrit, 19-nor + prolactin) via the Conflict Engine
  • Complex multi-week protocols: each compound gets its own start week within the cycle (offset from the start); cycle templates — save a plan as a reusable, date-free template and share templates across instances via portable JSON (export/import with strict validation)
  • Tracking only — no dosing advice (harm reduction)
💉 4. GLP-1 Protocol
  • Models: Injection, DosePhase, SideEffect
  • Chronological injection log (Semaglutide / Tirzepatide) with interactive injection site body map
  • Dose phase overlays on weight charts for correlation
  • Plateau detection: dose >14 days + trend <100g/week → automatic warning
⌚ 5. Garmin Connect
  • Models: GarminDaily, GarminActivity, GarminIntraday, GarminWeightExport
  • Auto-sync: sleep, sleep stages, HRV, resting HR, stress, Body Battery, Training Readiness + intraday stress / Body Battery curves
  • garminconnect session (pinned to 0.3.7): tokens cached in Redis + disk backup, and the token volume is archived by backup.sh next to the SQL dump
  • Login breaker: credential logins are rationed (3 per 24h, then a 6h pause, both in Redis) — Garmin rate-limits logins per account and every retry extends the block. A healthy token never touches it; MFA and "throttled" are reported apart from bad credentials
  • The poll schedule is not an env var: the full-sync interval and the light pulse (today's steps) between syncs live on the "Proactive layer" card in /settings and apply without a container restart
  • Weight back to Garmin (opt-in) — the Garmin card sends only the latest direct measurement (manual, MCP, or BIA), never echoes a Garmin import, and never backfills history. Its interval (15 minutes by default) and freshness window (at most 30 days) update the running scheduler without a restart; Send now starts an explicit reconciliation. Newly saved credentials take effect in the current process, while the background job quietly no-ops when credentials are absent
  • Conservative weight writes — before every POST, Vitals freshly reads the Garmin day and writes only when that day is empty. One equal pre-existing entry is recorded as an external match, not as Vitals-owned; a different value, multiple entries, or an incomplete Garmin response becomes a visible conflict without adding a duplicate or deleting external data. A correction replaces only the exact samplePk confirmed for Vitals' own POST, and only after confirming that the day is empty again
  • Ambiguity and deletion — before the network POST, Vitals commits a durable unverified marker with a reserved millisecond timestamp, so a crash or rollback cannot put the request back into the send queue. Garmin normally answers with 204 and no samplePk; ownership is then established only from one sole read-back record matching that timestamp, the MANUAL source, and the exact weight. Weight equality alone is insufficient: scheduled runs and Send now continue safe reconciliation and never repeat an unverified POST. Deleting a local weight queues removal only for a confirmed Vitals-owned Garmin record, while a monotonic cursor prevents deletion or re-enabling from exposing an older measurement for export. The write path uses garminconnect's unofficial web API, so it is off by default
  • Fully reworked sleep analysis with detailed individual night pages
  • Fallback: direct Health Auto Export JSON import
🏋️ 6. Hevy Workouts
  • Models: HevyWorkout, HevyExercise, HevySet
  • API sync: exercises, sets, reps, weights
  • Workouts collapse via details/summary for mobile compactness
  • Cross-reference training volume with Garmin recovery metrics
🍏 7. Nutrition
  • Models: MealLog
  • Meal logging with calories and macros (protein, fat, carbs)
  • Wall-clock consumption time (eaten_at) + text notes
  • Configurable macro targets via Settings with dashboard progress gauges
💊 8. Supplements Catalog
  • Models: Supplement
  • Supplement database with dosages, timing, evidence tiers (A/B/C)
  • Free-text names resolve to a stable key via an alias dictionary (RU/EN, ~90 supplements) — works with Cyrillic names too
  • Grouped by morning / day / evening / night timing buckets
  • Conflict checks against genetics, labs, and concurrent substances via the Conflict Engine
🧴 9. Skincare Log
  • Models: SkincareProduct, SkincareLog, SkincareObservation
  • Morning / evening routine logs with product actives (retinoid, azelaic acid, peel, niacinamide+SPF, vitamin C, benzoyl peroxide)
  • Skin status tracking (hydration, oiliness, breakouts, location mapping)
  • Auto-warning for conflicting actives (retinoid+peel, benzoyl peroxide+vitamin C, etc.)
🧪 10. Lab Results & OCR
  • Models: LabResult, LabMarker
  • Upload PDF / image → LLM parses biomarkers (recommended: gemini-2.5-flash)
  • Out-of-range flagging (Low / High / Critical) + interactive history charts
  • Directly record complete lab panels or single markers via MCP
🧬 11. Genetics (VCF)
  • Models: GeneticVariant
  • VCF file parser → health-relevant SNPs (HFE, MTHFR, G6PD, CYP1A2, COMT, and more)
  • Feeds pharmacogenomic rules into the Conflict Engine (e.g. hemochromatosis carrier status blocks iron supplementation)
  • The source VCF rows (up to 50k per import) are kept in raw_payloads — extending the interpretation dictionary re-reads the old file without asking for a re-upload
📈 12. Custom Charts
  • Chart builder/studio to overlay any metrics across domains on a single timeline
  • Overlaid annotations from the Timeline
📅 13. Timeline
  • Models: Annotation
  • Central event timeline combining manual life logs (trips, sickness, protocols) and automated system events (dose changes, HRT doses, lab draws, BIA scans, achieved goals)
  • Renders events as clickable flag-overlays across weight and custom charts
🎯 14. Milestones & Goals
  • Models: Milestone
  • Interactive goal cards with numeric targets and deadlines
  • Real-time progress % based on active weight metrics
💬 15. Signals & Proactive Channel (Telegram)
  • Models: Signal, DayContext, NotificationLog (the send journal)
  • The domain that explains the numbers in the other fourteen: everything that happens "in the moment" and has no shape — "can't keep my eyes open", "headache", "coffee at 22:00". Free text in Telegram → the raw message into raw_payloads → parsed into Signal rows of three kinds (state / symptom / exposure), echoed back with a "wrong" button that undoes the whole batch
  • Signal keys stay deliberately free during the shake-out period: stored exactly as the parser wrote them and folded to a canonical name on read (KEY_ALIASES) — consolidating the vocabulary is a dict edit, not a migration
  • The /signals page: the capture feed, a key-frequency table showing the actual phrasings behind each key (the raw material for a future closed registry), and per-row deletion when the parser got one wrong. Capture happens through the bot only — the app is not a second bot, the bot is not a second input form
  • Messages the parser choked on (model down, no balance) aren't lost: the morning brief takes a second pass before it assembles — one week back, up to 20 messages
  • DayContext — one row per day: remote/office, gym, workload. The evening block asks about tomorrow at 23:45; the week template pre-fills what a weekday can actually predict. Every button carries its own date, so a tap after midnight still answers the right day
  • Morning brief: the deterministic blocks are built by code from the same cross-domain context the weekly digest assembles, and the model contributes exactly one interpretation paragraph — if it fails, the brief still arrives; an empty day is silence, not an empty brief. Stored in weekly_digests (kind='daily_brief'), visible in /reports along with "Build brief" and "Send a test message" buttons
  • Nudges — a list of specs (condition, text, cooldown, category toggle) walked by one engine; adding a nudge is a single entry. Three categories today: activity, nutrition, data freshness
  • One gate for everything outgoing: module off, dedupe, quiet hours (nudges only) and a daily message budget; replies to the owner are deliberately exempt from the budget
  • Answering questions: a reply to one of the bot's messages, or just "why is my hrv down?" — the model sees the message being replied to plus the context the last brief was built on, and nothing else. Deep analysis belongs in Claude.ai over MCP, which has 75 tools and a better model
  • Signals and day_context reach both the weekly digest and the brief, with the hour attached — "coffee at 22:00" and "coffee at 09:00" are opposite facts wearing the same key
  • The channel sits behind a Notifier protocol — nothing above it knows about Telegram. The signals module is off by default and doubles as the master switch for the whole proactive layer: off means the bot says nothing at all. Brief and evening times, quiet hours, budget, nudge categories, the Garmin poll rate, the weight-export interval and freshness window, and the week template live on a card in /settings, and saving re-registers the jobs on the running scheduler without a restart
  • MCP: get_signals, log_signal, get_day_context — Claude can see what the owner told the bot
  • Setup: VITALS_TELEGRAM_BOT_TOKEN, VITALS_TELEGRAM_CHAT_ID, VITALS_TELEGRAM_WEBHOOK_PATH, VITALS_TELEGRAM_WEBHOOK_SECRET (see .env.example) — exactly one chat id is listened to, and the webhook is verified by a secret header

🏗️ Technical Architecture

graph TD
    Garmin[Garmin Connect] -->|auto-sync| Raw[raw_payloads · JSONB]
    Hevy[Hevy REST API] -->|auto-sync| Raw
    Labs[Lab PDFs / Images] -->|LLM parsing| Raw
    BIA[InBody PDFs / Images] -->|LLM parsing| Raw
    VCF[Genetics VCF] -->|import| Raw
    TGin[Telegram: free text] -->|webhook| Raw

    Raw --> CE[Conflict Engine]
    CE --> IL[InsightsMixin Layer]
    Scheduler[Scheduler Locks · Redis] --> CE

    IL --> DB[(PostgreSQL 15)]
    IL --> Cache[(Redis)]

    DB & Cache --> FastAPI[FastAPI Router + Auth + CSRF]

    FastAPI --> UI[JSON API -> React PWA]
    FastAPI --> MCP[FastMCP · streamable HTTP]
    Scheduler --> PL[Proactive layer]
    PL -->|gate: budget, quiet hours, dedupe| TGout[Telegram: brief, evening, nudges]

    UI --> Charts[SVG charts]
    UI --> AI[AI Digests · LLM]
    MCP <-->|OAuth 2.0 + PKCE| Claude[Claude.ai]
Loading
Layer Responsibility
vitals/ (core) Models, services, business logic. Zero web dependencies. Importable in scripts and tests.
web/ (delivery) FastAPI: the JSON API /api/v1, auth, CSRF, the React app shell, and the few server-rendered pages (sign-in, OAuth, the doctor report). Calls services — contains no business logic.
vitals/services/proactive/ The proactive layer along four seams: channels (how a message leaves), delivery (whether it may), compose/brief (what is said), inbound (what comes back), nudges (when there's a reason at all), plus day_plan and prefs. Nothing above channels knows the word "telegram".
Frontend frontend/: React 19 + TanStack Router/Query, Vite, PWA. A typed client is generated from the API schema (npm --prefix frontend run gen). Design system: DESIGN_SYSTEM.md. All copy flows through vitals/i18n.py (EN/RU) and its dictionary is exported to the app.

Background jobs (APScheduler)

Every job runs under a Redis lock (one runner across workers) and stamps a heartbeat that /health watches.

Job Schedule What it does
keepalive every minute Scheduler pulse — how /health knows it's alive
glp1_plateau 06:00 GLP-1 plateau check, passive alert
hrt_reminders 07:00 Bloodwork due on cycle + missed injections
nutrition_day_end 23:00 End-of-day macro totals (warnings never fire off a partial day)
raw_payload_sweep 03:30 Re-parse everything flagged pending (Garmin, Hevy); unconfirmed lab and BIA uploads are left alone
hevy_sync every 6h Workout sync
garmin_sync every N hours (setting, default 6) Full Garmin sync
garmin_pulse interval, active hours (setting) Light pulse: today's steps, one request, no login
garmin_weight_export every N minutes (setting, default 15) Safely reconcile the latest eligible local weight with Garmin; no-op while disabled or unconfigured
daily_brief from settings (default 11:00) Garmin sync → assemble the brief → send under budget
evening_block from settings (default 23:45) Day summary + questions about tomorrow
nudges hourly at :05 Walks the nudge registry: condition, cooldown, category
weekly_digest Mondays, 08:00 AI digest

Brief time, evening time, the Garmin poll rate, the weight-export interval and its freshness window live in the database (the /settings card), not in .env — saving re-registers the jobs on the running scheduler, no restart needed.


🔗 MCP Integration with Claude.ai

Built-in FastMCP server at /mcp/ (streamable HTTP transport). Authorization: OAuth 2.0 with signed Bearer token verification. PKCE (S256) is mandatory — an /oauth/authorize request without a code_challenge is rejected rather than skipping the check. Protected-resource metadata is served at /.well-known/oauth-protected-resource (RFC 9728), and a 401 points at it via WWW-Authenticate: Bearer resource_metadata="..." so the client can discover where to authorize. Configured in the web dashboard settings.

75 tools — Claude can fully read and write data across all domains. Plus 2 resources (vitals://profile, vitals://digest/latest) and a weekly_review prompt. The listing is filtered by enabled modules: a switched-off domain is not shown in tools/list at all (it already refused writes), so a conversation does not spend its context on schemas for what the owner does not track. Switch a module back on and its tools return on the connector's next reconnect, no restart needed.

Read (33 tools)

Tool Description
get_user_profile Physical profile, goals, training program
get_weight_logs Weight history, body measurements, noise markers
get_glp1_logs GLP-1 injections, dose phases, side effects
get_hrt_logs HRT/TRT doses, side effects, and the active cycle with its plan
get_hrt_cycles All HRT cycles with their per-compound plans
get_garmin_metrics HRV, sleep, resting HR, activities. intraday=true for the per-minute day and night curves, sleep_detail=true for the night's hypnogram and breathing events
get_hevy_workouts Strength workouts with sets, reps, weights
get_supplements_catalog Supplements with dosages and evidence tiers
get_skincare_logs Skincare routine logs and skin observations
get_genetics_snps Genetic SNPs filtered by gene and/or rsid ("show me my rs1801133")
get_active_alerts Unresolved warnings and conflict notifications
get_weekly_digests Historical AI digest archive
check_supplement_conflicts Check a supplement (by name, RU/EN) against genetics, labs, other supplements, and skincare
list_conflict_rules List the curated conflict-rule catalog, filterable by domain and/or category
check_conflicts Check an arbitrary state against active conflict rules for any domain (read-only, no save)
get_nutrition_summary Daily nutrition summary (calories, macros) and goal progress
search_meals Search meals by name and/or date
get_measurements Body measurements (neck, waist, hips, body-fat %, LBM)
get_notes Records with notes across all domains
get_body_scans InBody / МедАсс body-composition scans with every metric
get_body_scan A single body-composition scan with its full metric sheet
get_body_metric_history History of one body-composition metric (SMM, phase angle…)
get_lab_results Lab results (biomarker, value, reference range, out-of-range flag)
get_timeline Cross-domain event timeline (manual annotations + auto-events)
get_full_snapshot One cross-domain snapshot for a period (weight trend, GLP-1, recent labs, activity/recovery, workouts, nutrition, skincare, goals)
export_everything The entire history as one compact, LLM-ready export across all domains
get_data_overview Per-domain map: row count, date range, last-updated timestamp
get_milestones Goal cards with computed progress (filter by status)
get_modules Which optional modules are enabled
get_trend Metric trend: slope (day/week), latest rolling mean, projected date-to-target
get_signals Signals captured by the bot: states, symptoms, exposures (filter by kind, key, dates)
get_day_context What kind of day it was: remote/office, gym, workload
get_proactive_state State of the proactive layer: on/off, schedule, week template, recent sends (read-only — retiming or muting the bot stays with the owner)

Write (40 tools)

Tool Description
log_meal Record a meal with calories and macros
update_meal Update an existing meal entry
log_weight Record weight (overrides Garmin for the same date)
log_glp1 Record a GLP-1 injection (drug, dose, site)
log_hrt_dose Record an HRT dose (ml × concentration → mg; brand/lab/batch; conflict-gated)
add_hrt_cycle Start an HRT cycle (kind; closes the previous open one)
add_hrt_cycle_item Add a compound to a cycle (schedule: segments/ramp, or dose + interval)
update_hrt_dose Edit an HRT dose (fields left out are preserved; conflict-gated)
log_hrt_side_effect Record an HRT side effect (type, severity 1–5)
close_hrt_cycle Close an HRT cycle with an end date (defaults to today)
log_skincare Record/update daily skincare checklist (upsert)
log_measurement Record body measurements (auto-computes Navy body-fat %)
log_note Add a note to any domain record (including lab results)
log_body_scan Record a body-composition scan from metrics (bridges into weight)
log_lab_result Record a single biomarker (auto-computes the out-of-range flag)
log_lab_results Record a whole lab panel at once (a list of markers → results + dedup)
update_lab_result Edit a lab result (recomputes the out-of-range flag, refreshes alerts)
log_event Manual timeline annotation (trip, illness, protocol change)
update_event Edit a timeline event (fields left out are preserved)
create_milestone Create a goal card (target value, deadline)
update_milestone Update a goal (incl. status: achieved/missed/paused)
update_glp1 Edit a GLP-1 injection (conflict-gated)
log_side_effect Record a GLP-1 side effect (type, severity 1–5)
add_dose_phase Add a GLP-1 dose phase (overlaid on the weight chart)
log_skincare_observation Record a skin observation (inflammation, PIH, zone)
add_supplement Add a supplement to the catalog (conflict-gated)
update_supplement Update a catalog supplement
set_supplement_active Toggle a supplement active (conflict-gated on enable)
update_measurement Edit a body measurement (recomputes Navy body-fat % / LBM)
add_noise_marker Mark a range as noise (excluded from the weight trend)
upsert_genetic_variant Add or update a genetic variant (keyed on gene + rsid)
set_module Enable/disable an optional module
log_signal Record a signal (state / symptom / exposure) — through the same service the bot uses
mark_signal_misparse Flag one message's parse as wrong — the bot's "не то" button, by batch_id
log_day_context Record what kind of day it was — the same answers the owner taps in the evening block (the template's guess is kept next to the answer)
set_week_template Set the week template: what each weekday is assumed to be until the owner says otherwise
resolve_alert Resolve an alert — it disappears from get_active_alerts
override_alert Mark a blocking alert overridden — "noted, doing it anyway"
generate_digest_now Generate a fresh weekly AI digest now
delete_record Delete one record from any domain by ID (domain + record_id) — weight, measurement, noise marker, lab result, goal, meal, GLP-1 (injection / side effect / dose phase), HRT (dose / cycle / cycle item), body scan, timeline event, skin observation, supplement, genetic variant, signal

Sync (2 tools)

Tool Description
sync_garmin Pull fresh Garmin data now — daily metrics and activities for the last days (default 2, up to 30)
sync_hevy Pull the latest Hevy workouts now

Note

Capped at 3 calls a day each. A sync is an outbound call to someone else's API (Garmin throttles logins on top of that), and the scheduler already polls both sources several times a day. These tools are for the gap case ("the last two days are empty"), not for use before every read. The counter lives in Redis and resets on the calendar day; once the cap is spent the tool returns {"error": ...} without going anywhere. The scheduled sync is unaffected and keeps running.

Tip

Override flow. Every write tool that runs the conflict engine (weight, GLP-1, HRT, supplements, skincare, measurements, body composition) accepts override=true. On a hard block the tool returns {"blocked": true, "violations": [...]} instead of saving — retry the same call with override=true to save anyway (the equivalent of the UI's "Save anyway" button).

Important

Edits are partial. Every update_* tool changes only the fields the call actually passes; the rest are carried over from the existing row instead of being nulled. Omit the date and the record keeps its own date rather than jumping to today. The web forms behave differently on purpose (they submit everything, and clearing a field there really does clear the column) — the divergence is resolved at the MCP boundary.

Write perimeter. Writing into a disabled optional module is refused at one shared entry point rather than tool by tool: a new tool inherits the check instead of having to remember it.

Provenance. Records written through the connector are stamped source = "mcp". In the weight source priority mcp ranks equal to manual entry — it overrides a Garmin import and is not overridden by one; between equals, recency decides. Existing rows are not relabelled retroactively.

Note

A response pays only for what was asked. The 75 tool schemas are re-sent with every message of a conversation, and a read answers with a hundred rows by default — so responses carry nothing the model cannot act on.

  • A row is data, not bookkeeping. serialize_row omits the columns no tool accepts back (domain, created_at, updated_at, raw_payload_id) and every unset field: an absent key and a null read the same, and the null costs tokens on every row. id, date and source stay — edits and deletes address rows by id, and provenance is an answer in its own right. Rows shrink 39–59%.
  • The hypnogram is opt-in. The per-minute sleep-stage timeline and breathing events are ~70% of a Garmin daily row, and used to ride along on every read of the last hundred nights. They now fold to a count plus a hint ("28 entries — call again with sleep_detail=True") rather than vanishing: silence would read as "this night has no stages". The switch is separate from intraday, so asking about the shape of one night does not pull every curve in the window. A 100-day read drops from ~96k to ~24k tokens.
  • An empty column stays silent. A night without a hypnogram gets no hint at all — "never measured" and "folded away" remain distinguishable.

Example interaction

User: Summarize my last 2 weeks — weight trend, workouts, sleep quality.

Claude: [calls get_weight_logs, get_hevy_workouts, get_garmin_metrics]

Weight: 89.2 → 87.8 kg over 14 days (−1.4 kg, ~0.7 kg/week — on track).

Workouts: 4 sessions completed (2× upper, 2× lower). Progressive overload:
bench press +5 kg, lat pulldown +7.5 kg.

Sleep: avg 7h 12min, HRV trending down last 3 days — likely accumulated
post-training fatigue. Body Battery recovering normally.

Recommendation: recovery looks sufficient, next session is fine to proceed.

🚀 Quick Start

1. Clone and configure

git clone https://github.com/ilodezis/vitals.git
cd vitals
cp .env.example .env

2. Generate secrets

# Session cookie signing key
python -c "import secrets; print(secrets.token_urlsafe(48))"

# Bcrypt password hash
python -c "import bcrypt; print(bcrypt.hashpw(b'your-password', bcrypt.gensalt(4)).decode())"

3. Update .env

VITALS_SESSION_SECRET="your-session-secret"
VITALS_AUTH_USERNAME="your-username"
VITALS_AUTH_PASSWORD_HASH="your-bcrypt-hash"

# PostgreSQL password inside the Docker network — required, compose refuses to start without it
VITALS_DB_PASSWORD="any-long-password"

# OAuth secret for connecting Claude.ai over MCP
VITALS_MCP_CLIENT_SECRET="another-random-secret"

4. Launch

docker compose up -d --build

Note

Alembic migrations run automatically when vitals_app starts. PostgreSQL 15 runs inside the Docker network.

5. Verify

Dashboard: http://127.0.0.1:8000

curl -s http://127.0.0.1:8000/health

6. Proactive Telegram layer (optional)

Leave these four empty and the app behaves exactly as before: the bot never sends, and the webhook fails closed with 401.

  1. Create a bot with @BotFather → VITALS_TELEGRAM_BOT_TOKEN.
  2. Get your own id from @userinfobot → VITALS_TELEGRAM_CHAT_ID. This is the only chat that is listened to.
  3. Generate the secret URL segment and the header secret:
    python -c "import secrets; print(secrets.token_urlsafe(24))"
    → VITALS_TELEGRAM_WEBHOOK_PATH and VITALS_TELEGRAM_WEBHOOK_SECRET.
  4. Register the webhook once (the host must be reachable by Telegram):
    curl -X POST "https://api.telegram.org/bot<TOKEN>/setWebhook" -d "url=https://<your-host>/tg/<PATH>" -d "secret_token=<SECRET>"
  5. Enable the Signals module in /settings — it is the master switch for the whole proactive layer — then set brief time, quiet hours and the daily message budget on the "Proactive layer" card.

🛡️ Secure Deployment (Creator's Setup)

Since Vitals stores sensitive medical history, genetic profiles, and personal biometric logs, it is strongly advised not to leave the application exposed to the public internet. By default, the port mapping in docker-compose.yml binds only to the loopback interface (127.0.0.1:8000), keeping the application hidden from outside traffic.

The project creator deploys Vitals on an Ubuntu VPS and secures access using one of two methods:

Option A: Cloudflare Tunnel (Recommended)

This is the simplest and most secure method to publish the app with a valid SSL certificate without exposing any ports on your firewall or router (your server's IP remains hidden).

  1. Add Domain: Set up your domain in Cloudflare.
  2. Create Tunnel: In the Cloudflare Zero Trust Dashboard, navigate to Access -> Tunnels and create a new tunnel (e.g., vitals-tunnel).
  3. Install cloudflared: Copy the installation command provided by Cloudflare for your server's OS (Ubuntu/Debian) and run it on the VPS. This installs the cloudflared daemon as a system service.
  4. Route Traffic: In the tunnel's configuration under the Public Hostname tab, add:
    • Subdomain/Domain: e.g., vitals.yourdomain.com
    • Service Type: HTTP
    • URL: localhost:8000 (or 127.0.0.1:8000)
  5. Additional Protection (Cloudflare Access):
    • To secure the app before the user even sees the Vitals login screen, go to Access -> Applications in the Zero Trust dashboard and add a self-hosted application for your domain (e.g., vitals.yourdomain.com).
    • Define an Access Policy allowing access only to your email address via One-Time PINs (OTP) or authentication providers like Google/GitHub.

Option B: Classic Nginx Reverse Proxy + SSL + VPN/Basic Auth

A traditional setup using Nginx as a reverse proxy.

  1. DNS Setup: Point your domain's A-record (e.g., vitals.yourdomain.com) to your VPS's public IP.
  2. Install Nginx:
    sudo apt update
    sudo apt install nginx -y
  3. Configure virtual host: Create a file at /etc/nginx/sites-available/vitals with the following configuration:
    server {
        listen 80;
        server_name vitals.yourdomain.com;
    
        # Allow uploading larger lab reports and photos
        client_max_body_size 20M;
    
        location / {
            proxy_pass http://127.0.0.1:8000;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }
    }
    Activate the configuration and restart Nginx:
    sudo ln -s /etc/nginx/sites-available/vitals /etc/nginx/sites-enabled/
    sudo nginx -t && sudo systemctl restart nginx
  4. Provision SSL Certificate (Let's Encrypt):
    sudo apt install certbot python3-certbot-nginx -y
    sudo certbot --nginx -d vitals.yourdomain.com
  5. Restrict Access (Pick one):
    • Via VPN (Tailscale/WireGuard): Configure Nginx to listen for SSL traffic only on your private VPN IP interface (e.g., listen 100.x.x.x:443 ssl), rather than the public interface.
    • Via HTTP Basic Auth: Generate a password file:
      sudo apt install apache2-utils -y
      sudo htpasswd -c /etc/nginx/.htpasswd username
      Add the following directives to your Nginx location / block:
      auth_basic "Private Vitals Access";
      auth_basic_user_file /etc/nginx/.htpasswd;

Just want plain public access, without VPN or Basic Auth? Skip step 5, put the domain behind the Cloudflare proxy (orange cloud — your server's real IP stays hidden) and set the encryption mode to Full (strict) under SSL/TLS → Overview. Instead of Let's Encrypt you can use a Cloudflare Origin Certificate (SSL/TLS → Origin Server). Keep in mind: in that case the Vitals login page is the only thing between your medical data and the internet. That is exactly the setup worth turning two-factor sign-in on for (Settings → Two-factor authentication) — a leaked password alone is then not enough.


⚙️ Configuration

Infrastructure
Variable Description Default
VITALS_DATABASE_URL PostgreSQL (asyncpg) postgresql+asyncpg://...
VITALS_DB_USER PostgreSQL user (for the DB container) vitals
VITALS_DB_PASSWORD PostgreSQL password Required
VITALS_DB_NAME Database name vitals_db
VITALS_REDIS_URL Redis for cache and locks redis://vitals_redis:6379/0
VITALS_TIMEZONE User timezone Europe/Chisinau
VITALS_BACKUP_RETENTION_DAYS How many days of automatic backups to keep 7

Connection-pool and timeout tuning (VITALS_DB_POOL_SIZE, VITALS_DB_MAX_OVERFLOW, VITALS_DB_POOL_TIMEOUT, VITALS_DB_POOL_RECYCLE, VITALS_DB_STATEMENT_TIMEOUT_MS) ships with working defaults in .env.example — you don't need to touch it.

Authentication & Security
Variable Description Default
VITALS_SESSION_SECRET Session cookie signing key Required
VITALS_AUTH_USERNAME Dashboard username Required
VITALS_AUTH_PASSWORD_HASH Bcrypt password hash Required
VITALS_SESSION_TTL Session duration (seconds) 2592000 (30 days)
VITALS_COOKIE_SECURE Secure cookie flag true
VITALS_COOKIE_SAMESITE SameSite policy lax

Two-factor sign-in has no variable: it is off by default and switched on in the dashboard (Settings → Two-factor authentication). The key lives in the database rather than .env, and backups never carry it — so after moving to a new server, 2FA has to be turned on again.

User Profile
Variable Description Default
VITALS_HEIGHT_CM Height (cm) 190
VITALS_SEX Sex (male / female) male
VITALS_USER_AGE Age 18
VITALS_BODY_FAT_SOURCE Primary body-fat%/LBM source: latest / navy / bia latest
VITALS_USER_PROGRAM Program description Embedded
VITALS_USER_GOALS Goals (comma-separated) снижение жира, сохранение мышц
Nutrition Targets
Variable Description Default
VITALS_NUTRITION_PROTEIN_TARGET_G Daily protein target (g) 150
VITALS_NUTRITION_CALORIES_MIN Minimum daily kcal 1300
VITALS_NUTRITION_CALORIES_MAX Maximum daily kcal 1700
Integrations & AI
Variable Description Default
VITALS_OPENROUTER_API_KEY OpenRouter API key Optional
VITALS_OPENROUTER_BASE_URL OpenRouter API base URL https://openrouter.ai/api/v1
VITALS_LLM_MODEL_DIGEST Digest model anthropic/claude-sonnet-4.6
VITALS_LLM_MODEL_PARSER Lab OCR model google/gemini-2.5-flash
VITALS_LLM_MODEL_BRIEF Morning-brief model (empty → the digest model) Optional
VITALS_HEVY_API_KEY Hevy API key Optional
VITALS_HEVY_BASE_URL Hevy API base URL https://api.hevyapp.com
VITALS_GARMIN_EMAIL Garmin Connect email Optional
VITALS_GARMIN_PASSWORD Garmin Connect password Optional
VITALS_GARMIN_TOKEN_DIR Garmin session token path /data/garmin_session
VITALS_MCP_CLIENT_ID MCP OAuth Client ID vitals-claude-connector
VITALS_MCP_CLIENT_SECRET MCP OAuth Client Secret Required
VITALS_MCP_REDIRECT_HOSTS Allowed OAuth callback hosts (comma-separated, https only) claude.ai,chatgpt.com,oauth-redirect.googleusercontent.com
VITALS_EXTERNAL_API_TOKEN Token for server-server Glance API Optional
VITALS_TELEGRAM_BOT_TOKEN Bot token from @BotFather Optional
VITALS_TELEGRAM_CHAT_ID The only chat the bot listens to Optional
VITALS_TELEGRAM_WEBHOOK_PATH Secret segment of the webhook URL (/tg/<path>) Optional
VITALS_TELEGRAM_WEBHOOK_SECRET Secret in the X-Telegram-Bot-Api-Secret-Token header Optional

No schedules here, on purpose: brief and evening times, quiet hours, the daily message budget, nudge categories, the Garmin poll rate, and the weight-export interval and freshness window live in the database and are edited on the "Proactive layer" card in /settings — those settings have to apply without restarting the container.


🛠️ Development & Testing

Local environment

python -m venv .venv
.venv\Scripts\activate          # Windows
source .venv/bin/activate       # macOS/Linux
pip install -r requirements-dev.txt
npm --prefix frontend ci

Dev server (no Docker)

Build the app first — the server serves the finished bundle from web/static/app, and without it the screens answer frontend not built:

npm --prefix frontend run build
python run_local.py

Auto-provisions SQLite + FakeRedis. Default login: timur / password. Address: http://127.0.0.1:8000.

Working on the frontend? Run Vite beside it for hot reload (it proxies the API to :8000):

npm --prefix frontend run dev     # http://127.0.0.1:5173

After changing the API, regenerate the typed client and the translation dictionary in one go: npm --prefix frontend run gen.

Demo data

python scripts/seed_demo.py

Populates the local database with realistic data across all domains (weight, Garmin, workouts, nutrition, GLP-1, HRT, labs, supplements, genetics, skincare). Idempotent — safe to re-run.

Tests

# Unit tests (SQLite, instant)
python -m pytest -q

# Integration tests (Postgres Docker)
bash scripts/test_postgres.sh

# Frontend: types + tests + build + bundle-size budget
npm --prefix frontend run check

☕ Support the Project

Tip

If Vitals helps you track your health markers and makes your life better, consider supporting its development. Contributions go toward keeping the creator caffeinated:


📄 License

PolyForm Noncommercial License 1.0.0 — free for personal and private use. Commercial use prohibited. See LICENSE.

About

Self-hosted health data lake & AI wellness navigator. Tracks Garmin, Hevy, GLP-1, nutrition, labs with MCP tools.

Resources

Contributing

Security policy

Stars

12 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages