Хочу поделиться опытом создания веб-приложения для планирования питания на неделю. Задача казалась простой: календарь, блюда, ингредиенты. Но в процессе всплыло столько нюансов — от 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 | 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мс |
Чему научился
- Remember Me на shared-хостинге — токены в БД + sliding expiration
- Универсальный drag-and-drop — мышь + тач, порог 5px
- COLLATE — не указывать явно, использовать дефолт поля
- Загрузка фото — FormData + проверка MIME
- UX на холодильнике — крупные кнопки, минимум действий
Что можно улучшить
- Service Worker для офлайн-режима
- Push-уведомления «Пора готовить обед»
- Интеграция с Яндекс.Лавкой (автоматический заказ продуктов)
- Голосовой ввод рецептов
Ссылки
- Демо: по запросу направлю
- GitHub: открою, если будет интерес
Спасибо за внимание! Если есть вопросы по реализации — задавайте в комментариях. Если статья наберёт рейтинг — выложу полный исходный код.
P.S. Жена теперь планирует питание на месяц вперёд. Считайте, миссия выполнена.
Теги:
- Умный холодильник
- электронная книга рецептов
- меню на день
- умные устройства
- электроника
- IT-проекты
- DIY
- автоматизация
- программное обеспечение
- веб-приложения
Хабы: