Статьи

Создаем фитнес-трекер на Python с помощью Reflex

Веб-разработка
Хотите создать полноценное веб-приложение без JavaScript, используя только Python? В этой статье мы с нуля создадим простой фитнес-трекер, используя исключительно Python и Reflex, и увидим, как легко можно объединить бэкенд и фронтенд логику, работать с базой данных и создавать реактивный пользовательский интерфейс.

Что мы создадим?

Наше приложение будет:
  • отслеживать количество тренировок в неделю;
  • показывать прогресс в виде интерактивного графика;
  • позволять просматривать историю за предыдущие недели;
  • Автоматически сохранять данные в SQLite.

Вот как это будет выглядеть:
Почему Reflex?
Reflex (ранее известный как Pynecone) это open-source фреймворк, который объединяет фронтенд и бэкенд инструментарий на Python. Если вы работали с React или Streamlit, многие концепции покажутся знакомыми, но здесь всё реализовано через декларативный подход. Главные плюсы:
  • Чистый Python. Весь код – от логики до интерфейса – пишется на Python. И не нужно переключаться между языками.
  • Реактивность. Изменения в состоянии приложения (бэкенде) автоматически отражаются в интерфейсе (фронтенде) без сложного управления DOM или ручного обновления.
  • Быстрая разработка. Позволяет быстро прототипировать и создавать полнофункциональные приложения.
  • Компонентный подход. UI строится из готовых компонентов, что упрощает разработку и поддержку.

Подготовка окружения и установка Reflex

Устанавливаем фреймворк: `pip install reflex`, создаём директорию для проекта, после чего инициализируем Relfex-проект: `reflex init`.
Reflex предложит выбрать шаблон:
Get started with a template:
(0) blank (https://blank-template.reflex.run) - A blank Reflex app.
(1) ai - Generate a template using AI [Experimental]
(2) choose templates - Choose an existing template.
Which template would you like to use? (0): 0
Для нашего примера выберем опцию (0) blank, чтобы начать с чистого листа.
После инициализации структура вашего проекта будет выглядеть примерно так:
Reflex сгенерировал для нас базовый шаблон в файле, который соответствует названию проекта, в моем случае это `fitness_reflex/fitness_reflex.py`. Давайте взглянем на него (я немного упрощу для ясности):
"""Welcome to Reflex! This file outlines the steps to create a basic app."""

import reflex as rx

from rxconfig import config


class State(rx.State):
    """The app state."""

    ...


def index() -> rx.Component:
    # Welcome Page (Index)
    return rx.container(
        rx.color_mode.button(position="top-right"),
        rx.vstack(
            rx.heading("Welcome to Reflex!", size="9"),
            rx.text(
                "Get started by editing ",
                rx.code(f"{config.app_name}/{config.app_name}.py"),
                size="5",
            ),
            rx.link(
                rx.button("Check out our docs!"),
                href="https://reflex.dev/docs/getting-started/introduction/",
                is_external=True,
            ),
            spacing="5",
            justify="center",
            min_height="85vh",
        ),
        rx.logo(),
    )


app = rx.App()
app.add_page(index)
Ключевые моменты:
  1. Класс `State`: это сердце нашего приложения. Здесь хранятся данные (переменные состояния) и методы для их изменения (обработчики событий). Можно думать об этом как о бэкенде нашего UI. Важно: переменные в State должны быть базовых типов (`str`, `int`, `list`, `dict` и т.д.) или моделей данных Reflex.
  2. Функции, возвращающие `rx.Component`. Они определяют, как выглядит наш интерфейс. `index()` — это функция для главной страницы (/). Можно создавать и другие функции для разных страниц или переиспользуемых частей UI.
  3. `rx.App`: Экземпляр нашего приложения, к которому мы добавляем страницы (`add_page`).
Теперь настроим подключение к базе данных. Для простоты будем использовать SQLite. Откроем файл `rxconfig.py` и добавим URL базы данных:
# rxconfig.py
import reflex as rx

config = rx.Config(
    app_name="fitness_reflex",
    db_url="sqlite:///reflex.db", # <-- Добавляем эту строку
)
Убедимся, что все настроено правильно. Запустим сервер: `reflex run`.
После этого увидим что-то вроде:
─────────────────────────────────────────────────────────────────────── Starting Reflex App ───────────────────────────────────────────────────────────────────────
[13:05:26] Compiling: ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100% 16/16 0:00:00
─────────────────────────────────────────────────────────────────────────── App Running ───────────────────────────────────────────────────────────────────────────
App running at: http://localhost:3000
Backend running at: http://0.0.0.0:8000
Открываем `http://localhost:3000` (или указанный адрес) в браузере. Если все ок, то мы должны увидеть стартовую страницу Reflex. Двигаемся дальше!

Модель данных для сохранения тренировок

Наше приложение будет хранить информацию о тренировках. Для этого создадим модель `Workout` с использованием `sqlmodel` — ORM, встроенного в Reflex. Нам нужно только одно поле — дата и время выполнения тренировки.
Добавляем следующий код в файл проекта:
from datetime import datetime, timezone, timedelta
from sqlmodel import Field
import sqlalchemy

# Определяем модель данных для таблицы 'workout'
class Workout(rx.Model, table=True):
    """Модель для хранения записи о тренировке в БД."""
    # Поле для хранения времени завершения тренировки
    # Используем sqlalchemy.Column для установки значения по умолчанию на стороне БД
    completed: datetime = Field(
        sa_column=sqlalchemy.Column(
            sqlalchemy.DateTime(timezone=True), # Тип данных в БД с таймзоной
            server_default=sqlalchemy.func.now(), # Значение по умолчанию - текущее время БД
            nullable=False # Поле не может быть пустым
        )
    )
    
# --- Остальной код (State, index, app) ниже ---
Здесь мы определили модель `Workout`, которая будет соответствовать таблице `workout` в базе данных. У нее всего одно поле `completed` типа `datetime`. Чтобы поле `completed` автоматически заполнялось текущим временем при создании записи в БД, мы используем `server_default=sqlalchemy.func.now()` внутри `sqlalchemy.Column`. Простое `default=datetime.now()` в `Field` установило бы время запуска приложения, а не время создания записи, что нам не подходит. Иногда приходится смешивать синтаксис SQLModel и SQLAlchemy для таких вещей.
Теперь нужно создать соответствующую таблицу в нашей SQLite базе данных. Reflex интегрирован с Alembic для управления миграциями БД. Выполним следующие команды в терминале :
# 1. Инициализация Alembic
reflex db init

# 2. Создание файла миграции на основе изменений в моделях
reflex db makemigrations --message "Добавляем модель Workout"

# 3. Применение миграции к базе данных
reflex db migrate
Теперь в нашей базе `reflex.db` появилась таблица `workout`.

Логика приложения

Теперь давайте добавим логику для загрузки тренировок из базы данных. Это будет происходить в классе `State` — месте, где Reflex хранит состояние приложения и методы для его изменения.
# fitness_tracker/fitness_tracker.py
# ... (импорты и модель Workout)

WEEKLY_GOAL = 5 # Наша цель - 5 тренировок в неделю

class State(rx.State):
    """Состояние приложения (бэкенд-логика и данные)."""
    workouts: list[str] = [] # Список строк с датами тренировок для отображения
    target: int = WEEKLY_GOAL # Еженедельная цель
    current_week_offset: int = 0 # Смещение текущей недели (0 - текущая, -1 - прошлая, 1 - следующая)

    # --- Методы для загрузки данных ---
    def load_workouts_for_week(self, week_offset: int):
        """Загружает тренировки для недели со смещением week_offset."""
        today = datetime.now(timezone.utc)
        # Вычисляем начало и конец нужной недели
        start_of_current_week = today - timedelta(days=today.weekday())
        start_date = start_of_current_week + timedelta(weeks=week_offset)
        # Устанавливаем время на начало дня
        start_date = start_date.replace(hour=0, minute=0, second=0, microsecond=0)
        end_date = start_date + timedelta(days=7)

        # Запрос к БД с использованием сессии Reflex
        with rx.session() as session:
            db_workouts = (
                session.query(Workout)
                .filter(
                    Workout.completed >= start_date,
                    Workout.completed < end_date,
                )
                .order_by(Workout.completed) # Сортируем для порядка
                .all()
            )
            # Форматируем даты в строки для отображения (State не хранит сложные объекты)
            self.workouts = [
                w.completed.strftime("%Y-%m-%d %H:%M") for w in db_workouts
            ]


# --- Остальной код (index, app) ниже ---
В `State` мы создали: `workouts` (список строк дат), `target` (цель), `current_week_offset` (смещение недели). А метод `load_workouts_for_week` получает данные из БД с помощью `rx.session()` и фильтрует их по дате. Даты форматируются в строки перед сохранением в `self.workouts`, так как `State` работает с простыми типами.

Вычисляем прогресс с помощью свойств

Reflex позволяет создавать вычисляемые свойства с помощью декоратора `@rx.var`. Это переменные, которые автоматически обновляются при изменении данных. Добавим несколько таких свойств. `progress`, `progress_percentage`, `goal_reached`, `is_current_week_view`, `current_week_display` автоматически пересчитываются, когда изменяются данные, от которых они зависят (`workouts`, `target`, `current_week_offset`). Они идеальны для отображения динамической информации в UI.
# --- Вычисляемые свойства (Computed Properties) ---
# Эти свойства автоматически пересчитываются при изменении зависимых данных
@rx.var
def progress(self) -> int:
    """Количество выполненных тренировок на выбранной неделе."""
    return len(self.workouts)

@rx.var
def progress_percentage(self) -> int:
    """Прогресс выполнения цели в процентах."""
    if self.target == 0: # Избегаем деления на ноль
        return 100 if self.progress > 0 else 0
    return int(min(100, (self.progress / self.target) * 100)) # Ограничиваем 100%

@rx.var
def goal_reached(self) -> bool:
    """Достигнута ли еженедельная цель?"""
    return self.progress >= self.target

@rx.var
def is_current_week_view(self) -> bool:
    """Просматривается ли текущая календарная неделя?"""
    return self.current_week_offset == 0

@rx.var
def current_week_display(self) -> str:
    """Строка для отображения номера недели и года."""
    # Рассчитываем дату внутри отображаемой недели
    display_date = datetime.now(timezone.utc) + timedelta(weeks=self.current_week_offset)
    # Используем isocalendar для получения номера недели по ISO
    year, week, _ = display_date.isocalendar()
    return f"{year} - Неделя {week:02}"
Эти свойства помогут нам отображать прогресс и управлять интерфейсом. Например, `progress_percentage` будет обновлять прогресс-бар, а `goal_reached` покажет, достигли ли мы цели.

Навигация по неделям

Чтобы пользователи могли листать недели, добавим методы для переключения:
def load_current_week_workouts(self):
    """Загружает тренировки для текущей отображаемой недели."""
    self.load_workouts_for_week(self.current_week_offset)


# --- Обработчики событий (Event Handlers) ---
def show_previous_week(self):
    """Переключиться на предыдущую неделю."""
    self.current_week_offset -= 1
    self.load_current_week_workouts() # Перезагружаем данные

def show_next_week(self):
    """Переключиться на следующую неделю."""
    # Ограничим переход в будущее, если не хотим разрешать просмотр будущих недель
    # if self.current_week_offset < 0: # Пример ограничения
    self.current_week_offset += 1
    self.load_current_week_workouts() # Перезагружаем данные
Методы `show_previous_week`, `show_next_week` будут вызываться при нажатии на кнопки в интерфейсе. Они изменяют состояние `current_week_offset`), а затем вызывают `load_current_week_workouts`, чтобы обновить список тренировок.
Добавим возможность записывать новые тренировки:
def log_workout(self):
    """Записать новую тренировку в БД."""
    # Проверяем, что мы на текущей неделе, чтобы не логировать в прошлом/будущем
    if not self.is_current_week_view:
         return # Ничего не делаем, если пытаемся логировать не на текущей неделе

    with rx.session() as session:
        new_workout = Workout() # Создаем экземпляр, 'completed' заполнится автоматически
        session.add(new_workout)
        session.commit() # Сохраняем в БД
        session.refresh(new_workout) # Обновляем объект из БД (опционально)

    # Перезагружаем данные, чтобы увидеть новую тренировку
    self.load_current_week_workouts()
Этот метод создает новую запись в базе с текущей датой и обновляет список тренировок.

Интерфейс: собираем компоненты

Теперь, когда логика готова, соберем интерфейс. Reflex предоставляет компоненты, такие как `rx.vstack` (вертикальный стек), `rx.button` (кнопка) и `rx.progress` (прогресс-бар). Вместо того чтобы писать весь UI внутри функции `index`, разобьем его на небольшие, переиспользуемые функции-компоненты. Это улучшает читаемость кода.
Отображение прогресса:
def progress_display() -> rx.Component:
    """Отображает заголовок недели и прогресс-бар."""
    return rx.vstack( # Вертикальный стек
        rx.heading(State.current_week_display, size="5", margin_bottom="0.5em"), # Используем вычисляемое свойство
        rx.progress(value=State.progress_percentage, width="100%", color_scheme="green", size="3"), # Используем вычисляемое свойство
        rx.text(f"{State.progress} из {State.target} тренировок", size="2", margin_top="0.2em"), # Показываем числовой прогресс
        align="center", # Центрируем элементы внутри vstack
        width="80%", # Задаем ширину блока
        margin_bottom="1.5em"
    )
Кнопки навигации:
def week_navigation_buttons() -> rx.Component:
    """Кнопки для переключения недель."""
    return rx.hstack( # Горизонтальный стек
        rx.button("⬅️ Прошлая неделя", on_click=State.show_previous_week, size="2"), # Кнопка вызывает метод State
        rx.spacer(), # Распорка для расталкивания кнопок
        # Опционально: отключаем кнопку "Следующая", если мы на текущей неделе
        rx.button(
            "Следующая неделя ➡️",
            on_click=State.show_next_week,
            size="2",
            disabled=State.is_current_week_view # Отключаем кнопку, если это текущая неделя
        ),
        spacing="4", # Пространство между элементами
        width="80%", # Задаем ширину блока
        justify="center", # Центрируем содержимое
        margin_bottom="1em"
    )
Кнопка логирования с условием:
def conditional_workout_logging_section() -> rx.Component:
    """Отображает кнопку 'Записать' или сообщение о достижении цели."""
    return rx.vstack( # Используем vstack для центрирования
        # Условное отображение с помощью rx.cond
        rx.cond(
            State.goal_reached, # Если цель достигнута...
            rx.text("🎉 Отлично! Цель на неделю выполнена! 💪", size="4", color_scheme="green", weight="bold"),
            # Иначе (цель не достигнута)...
            rx.cond(
                State.is_current_week_view, # Проверяем, текущая ли это неделя...
                rx.button( # Если да, показываем кнопку
                    "✅ Записать тренировку",
                    on_click=State.log_workout, # Кнопка вызывает метод State
                    size="3",
                    color_scheme="grass", # Зеленая кнопка
                ),
                # Иначе (не текущая неделя)...
                rx.text("") # Ничего не показываем 
            )
        ),
        align="center",
        width="80%",
        margin_top="1.5em",
        min_height="3em" # Резервируем место, чтобы UI не прыгал
    )
Список тренировок:
def workout_list() -> rx.Component:
    """Отображает список выполненных тренировок."""
    return rx.vstack(
        rx.heading("История тренировок", size="6", margin_bottom="0.5em"),
        # Отображение списка с помощью rx.foreach
        rx.cond( # Показываем сообщение, если список пуст
            State.workouts, # Условие: есть ли элементы в списке?
            rx.vstack( # Если список не пуст, отображаем его
               rx.foreach(
                    State.workouts, # Итерируемся по списку строк-дат
                    lambda workout_date: rx.box( # Оборачиваем каждую строку в Box для стилизации
                        rx.text(f"✅ {workout_date}"),
                        border="1px solid #e2e8f0",
                        padding="0.5em",
                        border_radius="md",
                        width="100%"
                    )
                ),
               spacing="2", # Пространство между записями
               align="stretch", # Растягиваем элементы на всю ширину
            ),
            rx.text("На этой неделе тренировок еще не было.", color_scheme="gray") # Сообщение, если список пуст
        ),
        align="center",
        width="80%",
        margin_top="1em",
        min_height="10em" # Минимальная высота, чтобы избежать скачков интерфейса
    )
Теперь объединим компоненты в главную страницу:
def index() -> rx.Component:
    """Главная страница приложения."""
    return rx.container( # Используем контейнер для центрирования и ограничения ширины
        rx.vstack(
            rx.heading("🏃‍♂️ Фитнес-трекер", size="8", margin_bottom="1em"),
            progress_display(), # Наш компонент прогресса
            week_navigation_buttons(), # Наши кнопки навигации
            workout_list(), # Наш список тренировок
            conditional_workout_logging_section(), # Наша кнопка или сообщение о цели
            # Общие стили для вертикального стека
            spacing="5", # Расстояние между основными блоками
            align="center", # Центрируем все блоки по горизонтали
            padding_y="2em", # Вертикальные отступы сверху/снизу
        ),
        max_width="600px", # Ограничиваем максимальную ширину контейнера
        center_content=True, # Центрируем содержимое контейнера
        min_height="100vh", # Минимальная высота на весь экран
    )

# --- Конфигурация приложения ---
app = rx.App()
app.add_page(
    index,
    title="Фитнес-трекер | Reflex", # Заголовок вкладки браузера
    # Загружаем данные при первой загрузке страницы
    on_load=State.load_current_week_workouts
)
Что мы используем при создании этих функций?
  • Компоненты Reflex: `rx.vstack`, `rx.hstack`, `rx.heading`, `rx.text`, `rx.progress`, `rx.button`, `rx.spacer`, `rx.box`, `rx.container`. Их имена говорят сами за себя.
  • Привязка к State: мы обращаемся к переменным и вычисляемым свойствам `State` прямо в UI-функциях (например, `State.progress_percentage`, `State.current_week_display`).
  • Обработчики событий: атрибут `on_click` у кнопок привязан к методам из `State` (например, `on_click=State.log_workout`). Reflex сам позаботится о вызове нужного метода на бэкенде и обновлении UI.
  • Условное отображение: вместо `if`/`else` в Python коде для показа/скрытия элементов используется `rx.cond`.
  • Отображение списков: для рендеринга списка элементов (наших тренировок) используется `rx.foreach`.
  • Стилизация: Reflex позволяет добавлять CSS-свойства прямо как аргументы компонентам (`size`, `width`, `color_scheme`, `margin_bottom`, `padding`, `border_radius` и т.д.).
  • Предзагрузка данных: в `app.add_page` мы добавили `on_load=State.load_current_week_workouts`. Это гарантирует, что данные для текущей недели будут загружены сразу при открытии приложения.

Результат: что получилось?

Запустите приложение еще раз (`reflex run`). Откройте его в браузере и увидите результат!

  • Попробуйте нажать кнопку "Записать тренировку". Прогресс-бар должен обновиться, и в списке появится новая запись.
  • Достигните цели (нажмите кнопку 5 раз). Кнопка должна исчезнуть, и появится поздравительное сообщение.
  • Переключитесь на прошлую или следующую неделю. Список тренировок должен очиститься (если там еще ничего нет). Кнопка записи должна быть неактивна или отсутствовать на не-текущих неделях.

Это уже хорошая база, которую можно улучшать. Вот пара идей:
  • Настраиваемая цель: можно добавить поле `rx.input` для установки своей еженедельной цели (`State.target`). Reflex автоматически обновит все зависимые части UI.
  • Ручной ввод даты: позволить пользователю выбирать дату тренировки, чтобы можно было записывать пропущенные дни.
  • Визуализация: добавить график (например, с помощью `rx.recharts`) для отображения статистики тренировок по неделям.
  • Редактирование/Удаление: Добавить возможность удалять ошибочно добавленные тренировки.
  • Более сложная модель: Учитывать тип тренировки, длительность, калории и т.д.
  • Экспорт данных в CSV/Excel.
Итого: стоит ли пробовать Relfex?
Это хороший инструмент для питонистов создавать веб-приложения без лишней сложности. По сравнению с Streamlit, Reflex дает больше контроля над интерфейсом, а по сравнению с Django — меньше кода для простых задач.
Плюсы:
  • Чистый Python, низкий порог входа для Python-разработчиков.
  • Быстрая разработка прототипов и несложных приложений.
  • Отличная система состояния и реактивности.
  • Хорошая интеграция с базами данных через SQLModel.
  • Активно развивается.

Минусы:
  • Молодой фреймворк (могут быть изменения в API, меньше готовых решений для сложных задач по сравнению с JS-экосистемой).
  • Для очень сложных и кастомизированных интерфейсов может не хватить гибкости "из коробки" (хотя есть способы интеграции React-компонентов).

Для небольших и средних проектов, внутренних инструментов, дашбордов или быстрого прототипирования Reflex выглядит очень перспективно. Он точно выигрывает у Streamlit/Gradio в гибкости кастомизации UI и управлении состоянием, при этом оставаясь чисто питонячим. Конечно, для больших, высоконагруженных порталов с командой фронтендеров, классический стек (вроде Django/FastAPI + React/Vue/HTMX) пока остается стандартом.

Но для соло-разработчика или бэкендера, которому нужно "сделать просто и интерактивно", Reflex может быть отличным выбором.