Умный планировщик питания на холодильнике с drag-and-drop, автосохранением сессий и вкусным и полезным продуктовым меню

от автора

Хочу поделиться опытом создания веб-приложения для планирования питания на неделю. Задача казалась простой: календарь, блюда, ингредиенты. Но в процессе всплыло столько нюансов — от UX на холодильнике до управления сессиями на shared-хостинге, — что решил оформить это в статью.

Что получилось

PWA для планирования питания с фичами:

  • Drag-and-drop перемещение блюд между днями

  • Автоматический расчет КБЖУ и списка покупок

  • Работа на холодильнике (крупный UI, офлайн-режим)

  • Remember Me на 30 дней даже на shared-хостинге

  • Фото блюд с превью

  • Скрембл-режим (случайное блюдо, когда не знаешь что готовить)

Умный холодильник – Дисплей на магнитах на холодильнике с запущенным на полный экран веб-приложением

Умный холодильник – Дисплей на магнитах на холодильнике с запущенным на полный экран веб-приложением

Стек: PHP 8.5 + PDO + MySQL + Vanilla JS (без фреймворков)

Что это в итоге такое

Распбери для задач не подошел, так как под рукой была 3 версия, 5-я стоит уже 20000 рублей, ближайшие аналоги вида Babana Pi стоят от 9000 рублей, а Mac Mini за 54000 рублей для этих целей покупать уже не очень хотелось. В итоге я взял старый ноутбук за 2000 рублей, где стоит новый SSD 256 Гб и 8 Гб оперативной памяти планка DDR3. С Распбери Пи 3 я устал играться уже на второй день и положил ее назад в коробочку с электроникой.

В итоге все решение – это ноутбук, сенсорный матовый дисплей на 15 дюймов, обвязка для этого железа, веб-приложение с возможностью открытия на полный экран (кнопка в меню).

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

На холодильнике выросли ноутбук и дисплей, но вроде стало удобнее жить

На холодильнике выросли ноутбук и дисплей, но вроде стало удобнее жить

Проблема №1: Shared-хостинг и сессии

Контекст

Приложение стоит на обычном shared-хостинге. Пользователь (моя подруга) заходит с холодильника, а не сидит в телефоне, планирует питание на неделю и… через 24 минуты сессия слетает. Каждый раз вводить пароль — это UX уровня «выключи и включи».

Форма авторизации в разработанном приложении

Форма авторизации в разработанном приложении

Почему это происходит

На shared-хостинге:

  1. session.gc_maxlifetime = 1440 секунд (24 минуты) — дефолт PHP

  2. Папка /tmp периодически чистится хостером

  3. При перезагрузке сервера сессии теряются

Решение: Persistent Tokens

Просто увеличить session.gc_maxlifetime недостаточно — хостер всё равно почистит /tmp. Нужен механизм Remember Me через токены в БД

// === АВТОЛОГИН ПО REMEMBER_ME COOKIE ===if (empty($_SESSION['user_id']) && !empty($_COOKIE['remember_me'])) {    $parts = explode(':', $_COOKIE['remember_me']);    if (count($parts) === 2) {        $selector = $parts[0];        $validator = $parts[1];    // Ищем токен в БД    $stmt = $pdo->prepare("        SELECT rt.id, rt.user_id, rt.hashed_validator, rt.expires_at,               u.family_id, u.name, u.login, f.name as family_name        FROM remember_tokens rt        JOIN users u ON rt.user_id = u.id        JOIN families f ON u.family_id = f.id        WHERE rt.selector = ? AND rt.expires_at > NOW()        LIMIT 1    ");    $stmt->execute([$selector]);    $token = $stmt->fetch();        if ($token && hash_equals($token['hashed_validator'], hash('sha256', $validator))) {        // Токен валиден — восстанавливаем сессию        $_SESSION['user_id'] = $token['user_id'];        $_SESSION['family_id'] = $token['family_id'];        $_SESSION['user_name'] = $token['name'];        $_SESSION['family_name'] = $token['family_name'];                // Sliding expiration: продлеваем токен на 30 дней        $newSelector = bin2hex(random_bytes(16));        $newValidator = bin2hex(random_bytes(32));        $newHashedValidator = hash('sha256', $newValidator);        $newExpires = date('Y-m-d H:i:s', strtotime('+30 days'));                $updateStmt = $pdo->prepare("            UPDATE remember_tokens             SET selector = ?, hashed_validator = ?, expires_at = ?            WHERE id = ?        ");        $updateStmt->execute([$newSelector, $newHashedValidator, $newExpires, $token['id']]);                // Обновляем cookie        setcookie('remember_me', $newSelector . ':' . $newValidator, [            'expires' => time() + (30 * 24 * 60 * 60),            'path' => '/',            'httponly' => true,            'samesite' => 'Lax',            'secure' => false  // ← false для HTTP        ]);    }}

Структура таблицы

CREATE TABLE remember_tokens (    id INT AUTO_INCREMENT PRIMARY KEY,    user_id INT NOT NULL,    selector VARCHAR(64) NOT NULL UNIQUE,    hashed_validator VARCHAR(255) NOT NULL,    expires_at DATETIME NOT NULL,    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,    INDEX idx_selector (selector),    INDEX idx_user (user_id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

Безопасность

Угроза

Защита

Перехват cookie

httponly=true (JS не читает)

CSRF

samesite=Lax

Кража токена

Validator хешируется в БД

Брутфорс

Selector + Validator (64+64 бита энтропии)

Результат: Пользователь заходит раз в месяц, всё остальное время работает без пароля.

Проблема №2: Drag-and-drop на холодильнике

Контекст

На холодильник магнитами закрепляется дисплей с тачскрином. Пользователь должен перетаскивать блюда между днями. Но:

  • Тач-события ≠ мышь

  • Нужно отличать клик от drag

  • На тач-устройствах скролл страницы конфликтует с drag

Перемещение блюда на другой день и в другой прием пищи

Перемещение блюда на другой день и в другой прием пищи

Решение: Универсальный обработчик

const initDnD = () => {    const board = $('#board');    const THRESHOLD = 5; // пикселей для отличия клика от drag    let dragTarget = null;    let startX = 0, startY = 0;    let moved = false;        const onStart = e => {        const dish = e.target.closest('.dish');        if (!dish) return;                dragTarget = {            el: dish,            id: parseInt(dish.dataset.id),            dishId: parseInt(dish.dataset.dish)        };                const pt = e.touches ? e.touches[0] : e;        startX = pt.clientX;        startY = pt.clientY;        moved = false;                dish.style.opacity = '0.6';        dish.style.transition = 'none';                document.addEventListener('mousemove', onMove);        document.addEventListener('touchmove', onMove, { passive: false });        document.addEventListener('mouseup', onEnd);        document.addEventListener('touchend', onEnd);    };        const onMove = e => {        if (!dragTarget) return;                const pt = e.touches ? e.touches[0] : e;        const dx = pt.clientX - startX;        const dy = pt.clientY - startY;                // Определяем: это drag или клик?        if (!moved && Math.hypot(dx, dy) > THRESHOLD) {            moved = true;        }                if (moved) {            if (e.cancelable) e.preventDefault();                        dragTarget.el.style.transform = `translate(${dx}px, ${dy}px) scale(1.03)`;                        // Подсветка drop-зоны            $$('.drop-zone').forEach(zone => {                const rect = zone.getBoundingClientRect();                const over = (                    pt.clientX >= rect.left &&                    pt.clientX <= rect.right &&                    pt.clientY >= rect.top &&                    pt.clientY <= rect.bottom                );                zone.classList.toggle('over', over);            });        }    };        const onEnd = () => {        document.removeEventListener('mousemove', onMove);        document.removeEventListener('touchmove', onMove);        document.removeEventListener('mouseup', onEnd);        document.removeEventListener('touchend', onEnd);                if (!dragTarget) return;                dragTarget.el.style.opacity = '';        dragTarget.el.style.transform = '';        dragTarget.el.style.transition = '';                if (moved) {            // Это был drag → ищем drop-зону            const dropZone = $('.drop-zone.over');            if (dropZone) {                const toType = parseInt(dropZone.dataset.t);                const toDay = parseInt(dropZone.dataset.d);                                api('move_item', {                    item_id: dragTarget.id,                    to_type: toType,                    to_day: toDay                }).then(() => {                    loadWeek().then(render);                    toast('✓ Перемещено', 'ok');                });            }        } else {            // Это был клик → открываем карточку блюда            openDishDetail(dragTarget.dishId);        }                $$('.drop-zone').forEach(z => z.classList.remove('over'));        dragTarget = null;    };        board.addEventListener('mousedown', onStart);    board.addEventListener('touchstart', onStart, { passive: true });};

UX-фишки

1. Порог срабатывания (THRESHOLD = 5px)

  • Если перемещение < 5px → это клик

  • Если ≥ 5px → это drag

  • На тач-устройствах это критично: палец всегда немного дрожит

2. Визуальная обратная связь

.dish {    transition: transform 0.15s, opacity 0.15s;}.dish.dragging {    opacity: 0.6;    transform: scale(1.03);    z-index: 100;    box-shadow: 0 8px 24px rgba(0,0,0,0.3);}.drop-zone.over {    height: 40px;    background: rgba(0, 184, 148, 0.2);    border: 2px dashed var(--g);    border-radius: 6px;}

3. Отмена скролла при drag

if (e.cancelable) e.preventDefault();

Без этого на тач-устройствах страница скроллится вместе с перетаскиванием блюда.

Проблема №3: COLLATE и кодировка

Контекст

При авторизации ошибка:

SQLSTATE[42000]: COLLATION 'utf8mb4_unicode_ci' is not valid for CHARACTER SET 'utf8mb3'

Причина

В БД поля login и email имеют кодировку utf8mb3, а в запросе я указывал COLLATE utf8mb4_unicode_ci. MySQL не может применить utf8mb4 collation к utf8mb3 полю.

Решение

Можно задавать формат с помощью функции COLLATE, это точно покажет, в чем еще проблема, если уже напрочь вымучил строчку сохранения в базу данных и не можешь добиться искомого результата.

$u = db_row($pdo, "    SELECT u.*, f.name fn     FROM users u     JOIN families f ON u.family_id = f.id     WHERE COALESCE(u.login, '') = ? COLLATE utf8mb4_unicode_ci        OR u.email = ? COLLATE utf8mb4_unicode_ci", [$identifier, $identifier]);

Исправление кодировки таблицы

ALTER TABLE users CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;ALTER TABLE remember_tokens CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

Проблема №4: Фото блюд и загрузка

Контекст

Пользователь хочет загрузить фото блюда. Но:

  • Фото должно сохраняться в папку /dishes_photos/

  • Нужно проверить размер (макс 5 МБ) и тип (только изображения)

  • После загрузки фото должно появиться в сетке недели

Решение

Backend (api.php):

if ($act == 'upload_dish_photo') {    $dishId = (int)($_POST['dish_id'] ?? 0);    if (!$dishId) send(['error' => 'Не указан ID блюда'], 400);        if (!isset($_FILES['photo']) || $_FILES['photo']['error'] !== UPLOAD_ERR_OK) {        send(['error' => 'Файл не загружен'], 400);    }        $file = $_FILES['photo'];        // Проверка размера    if ($file['size'] > 5 * 1024 * 1024) {        send(['error' => 'Размер файла превышает 5 МБ'], 400);    }        // Проверка MIME-типа    $finfo = finfo_open(FILEINFO_MIME_TYPE);    $mimeType = finfo_file($finfo, $file['tmp_name']);    finfo_close($finfo);        $allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];    if (!in_array($mimeType, $allowedTypes)) {        send(['error' => 'Недопустимый тип файла'], 400);    }        // Сохранение    $extMap = [        'image/jpeg' => 'jpg',        'image/png' => 'png',        'image/gif' => 'gif',        'image/webp' => 'webp'    ];    $ext = $extMap[$mimeType];    $filename = 'dish_' . $dishId . '_' . time() . '.' . $ext;    $uploadDir = __DIR__ . '/dishes_photos/';        if (!is_dir($uploadDir)) {        mkdir($uploadDir, 0755, true);    }        $targetPath = $uploadDir . $filename;        if (!move_uploaded_file($file['tmp_name'], $targetPath)) {        send(['error' => 'Ошибка сохранения файла'], 500);    }        // Удаляем старое фото    $oldPhoto = db_val($pdo, "SELECT photo FROM dishes WHERE id=?", [$dishId]);    if ($oldPhoto && file_exists(__DIR__ . '/dishes_photos/' . $oldPhoto)) {        unlink(__DIR__ . '/dishes_photos/' . $oldPhoto);    }        // Сохраняем в БД    db_exec($pdo, "UPDATE dishes SET photo=? WHERE id=?", [$filename, $dishId]);        send(['success' => 1, 'photo' => $filename]);}

Frontend (автозагрузка после выбора):

window.uploadDishPhoto = dishId => {    const input = document.getElementById('e-photo-' + dishId);    if (!input || !input.files[0]) return;        const file = input.files[0];    const formData = new FormData();    formData.append('photo', file);    formData.append('dish_id', dishId);        fetch(API + '?action=upload_dish_photo', {        method: 'POST',        body: formData    })    .then(r => r.json())    .then(data => {        if (data.error) throw new Error(data.error);        toast('✓ Фото загружено', 'ok');                // Обновляем библиотеку и перерисовываем сетку        loadDishes().then(() => {            render();            openDishDetail(dishId);        });    })    .catch(e => toast('Ошибка: ' + e.message, 'err'));};

Вот так выглядел интерфейс до внедрения фотографий — стало явно интереснее.

Интерфейс веб-приложения до добавления фотографий блюд

Интерфейс веб-приложения до добавления фотографий блюд

Проблема №5: Кто загружает базовые блюда и редактирует меню

Контекст

Пользователь открывает приложение, смотрит на пустую неделю и думает: «Что приготовить?». Нужен режим «случайное блюдо». Эту функцию сделать несложно, но пока проблема была более тривиальной: как не допустить кучи одинаковых однотипных блюд в базе данных от разных пользователей?

Решение

Меню и продукты берутся у семьи №1, то есть по факту от первого пользователя. Они не привязаны к семье по факту, однако семья играет роль администратора. Другие пользователи могут создавать и редактировать новые блюда, но не могут удалить или испортить уже имеющиеся.

Блюда в меню, которые можно добавить к себе в рацион

Блюда в меню, которые можно добавить к себе в рацион

Что еще реализовано в приложении

Можно, разумеется, добавить новое блюдо:

Можно отмечать съеденные блюда, на основании этого рассчитывается, сколько внутрь тебя попало необходимых жиров, белков, углеводов, витаминов и минералов:

Нехватка пищевой ценности в принятой пище

Нехватка пищевой ценности в принятой пище

Структура не запрещает добавлять не только приемы пищи, но и время приема таблеток, протеинов или стероидов, но я этим не увлекаюсь.

Приемы пищи полностью кастомизируемые

Приемы пищи полностью кастомизируемые

На основе меню формируется список покупок. Вот подходишь ты с утра к холодильнику, смотришь, что на завтрах омлет с помидорами, открываешь холодильник… А там – что угодно, но только не искомое. Можно поменять с ходу желанное на нежеланное, сварить рис, но если пойти в магазин, то точно купишь все необходимые на неделю продукты.

Продукты на сегодня, завтра или на неделю

Продукты на сегодня, завтра или на неделю

Итоги

Что получилось

Меню на неделю с подсчетом пищевой ценности

Меню на неделю с подсчетом пищевой ценности

Метрика

Значение

Строк кода

~2500 (PHP + JS + CSS)

Время разработки

3 недели (вечерами)

Размер БД

17 таблиц, ~1000 строк

Производительность

Первая загрузка < 1с, subsequent < 200мс

Чему научился

  1. Remember Me на shared-хостинге — токены в БД + sliding expiration

  2. Универсальный drag-and-drop — мышь + тач, порог 5px

  3. COLLATE — не указывать явно, использовать дефолт поля

  4. Загрузка фото — FormData + проверка MIME

  5. UX на холодильнике — крупные кнопки, минимум действий

Что можно улучшить

  • Service Worker для офлайн-режима

  • Push-уведомления «Пора готовить обед»

  • Интеграция с Яндекс.Лавкой (автоматический заказ продуктов)

  • Голосовой ввод рецептов


Ссылки

  • Демо: по запросу направлю

  • GitHub: открою, если будет интерес


Спасибо за внимание! Если есть вопросы по реализации — задавайте в комментариях. Если статья наберёт рейтинг — выложу полный исходный код.

P.S. Жена теперь планирует питание на месяц вперёд. Считайте, миссия выполнена.

ссылка на оригинал статьи https://habr.com/ru/articles/1058020/