Умный холодильник

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

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

УгрозаЗащита
Перехват cookiehttponly=true (JS не читает)
CSRFsamesite=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. Жена теперь планирует питание на месяц вперёд. Считайте, миссия выполнена.

Теги:

Хабы:

Was this article helpful?
YesNo

от Евгений Рычков

Основатель сообщества разработчиков Технофея, фулстек-программист, тим лидер, инженер 1 категории, в прошлом главный специалист по робототехнике в Кванториум Мосгормаш,

0 0 голоса
Рейтинг статьи
Подписаться
Уведомить о
guest
0 комментариев
Старые
Новые Популярные
0
Оставьте комментарий! Напишите, что думаете по поводу статьи.x