Хочу поделиться опытом создания веб-приложения для планирования питания на неделю. Задача казалась простой: календарь, блюда, ингредиенты. Но в процессе всплыло столько нюансов — от 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-хостинге:
-
session.gc_maxlifetime= 1440 секунд (24 минуты) — дефолт PHP -
Папка
/tmpпериодически чистится хостером -
При перезагрузке сервера сессии теряются
Решение: 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 |
|
|
CSRF |
|
|
Кража токена |
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мс |
Чему научился
-
Remember Me на shared-хостинге — токены в БД + sliding expiration
-
Универсальный drag-and-drop — мышь + тач, порог 5px
-
COLLATE — не указывать явно, использовать дефолт поля
-
Загрузка фото — FormData + проверка MIME
-
UX на холодильнике — крупные кнопки, минимум действий
Что можно улучшить
-
Service Worker для офлайн-режима
-
Push-уведомления «Пора готовить обед»
-
Интеграция с Яндекс.Лавкой (автоматический заказ продуктов)
-
Голосовой ввод рецептов
Ссылки
-
Демо: по запросу направлю
-
GitHub: открою, если будет интерес
Спасибо за внимание! Если есть вопросы по реализации — задавайте в комментариях. Если статья наберёт рейтинг — выложу полный исходный код.
P.S. Жена теперь планирует питание на месяц вперёд. Считайте, миссия выполнена.
ссылка на оригинал статьи https://habr.com/ru/articles/1058020/