Хочу поделиться опытом создания веб-приложения для планирования питания на неделю. Задача казалась простой: календарь, блюда, ингредиенты. Но в процессе всплыло столько нюансов — от 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. Жена теперь планирует питание на месяц вперёд. Считайте, миссия выполнена.