PlateMate to aplikacja do planowania posiłków, która działa jako PWA (Progressive Web App) - instalujesz ją na telefonie prosto z przeglądarki, bez App Store. Jedną z jej funkcji jest system rekomendacji - użytkownik klika "zaproponuj posiłek" i dostaje sugestię dopasowaną do swoich preferencji, historii i diety. W tym artykule opisuję jak ten system działa od środka.
Stack i architektura
Aplikacja stoi na Laravel 12 z Livewire 3. Wybrałem Livewire zamiast SPA (React/Vue) z jednego powodu: cała logika w PHP, zero konieczności utrzymywania osobnego API. Komponenty Livewire dają interaktywność (drag & drop w planerze, live search, real-time aktualizacje) bez pisania JavaScript.
Logika biznesowa jest wydzielona do serwisów:
app/Services/
MealRecommendationService.php # algorytm rekomendacji
MealStatisticsService.php # analityka
ShoppingListService.php # agregacja listy zakupów
GusApiService.php # integracja z API GUS
Service Layer zamiast tłustych kontrolerów. Każdy serwis robi jedną rzecz, jest testowalny osobno i reużywalny między komponentami Livewire.
Algorytm rekomendacji
Nie ma tu żadnej sieci neuronowej ani fine-tunowanego modelu. System rekomendacji to algorytm scoringowy - każdy posiłek dostaje punkty na podstawie kilku czynników, a wynik sortujemy od najwyższego.
Czynniki scoringu
Posiłek oznaczony jako ulubiony: +10 punktów. To najsilniejszy sygnał - użytkownik jawnie powiedział "lubię to".
Przepis od obserwowanego kucharza: +5 punktów. Jeśli obserwujesz kogoś, to znaczy że jego przepisy Ci pasują.
Twoja ocena tego posiłku: pomnożona razy 2. Ocena 5 gwiazdek = +10 punktów, 3 gwiazdki = +6. Historyczne oceny mówią wprost co użytkownikowi smakowało.
Średnia ocena społeczności: +1 do +5 punktów. Słabszy sygnał niż osobista ocena, ale pomaga przy nowych posiłkach, których użytkownik jeszcze nie próbował.
Lubiane składniki: +1 punkt za każdy składnik, który użytkownik lubi. Jeśli ktoś lubi bazylię i mozzarellę, caprese dostanie +2.
Na końcu element losowy (RAND()) zapobiega temu, żeby te same posiłki wracały w identycznej kolejności.
Filtry wykluczające
Zanim algorytm policzy punkty, wycina z puli posiłki które nie powinny się pojawić:
- Posiłki gotowane w ostatnich 7 dniach (żeby nie jeść tego samego ciągle)
- Posiłki już zaplanowane na dany dzień (nie chcesz dwóch obiadów)
- Posiłki z nielubianymi składnikami (deklarowane w preferencjach)
- Posiłki niezgodne z dietą (wegetariańska, wegańska, bezglutenowa)
Implementacja w SQL
Cały scoring działa w jednym zapytaniu SQL z CASE WHEN i podzapytaniami. Nie ciągnę wszystkich posiłków do PHP żeby je posortować - baza danych robi to szybciej.
SELECT meals.*,
(CASE WHEN meals.id IN (
SELECT meal_id FROM user_favorites WHERE user_id = ?
) THEN 10 ELSE 0 END) +
(CASE WHEN meals.user_id IN (
SELECT followed_id FROM user_follows WHERE follower_id = ?
) THEN 5 ELSE 0 END) +
COALESCE((
SELECT rating FROM meal_ratings
WHERE meal_id = meals.id AND user_id = ?
), 0) * 2 +
COALESCE((
SELECT AVG(rating) FROM meal_ratings
WHERE meal_id = meals.id
), 0) as score
FROM meals
ORDER BY score DESC, RAND()
Można by to zrobić w Elasticsearch albo z cache'owanymi wektorami, ale przy 180+ przepisach to przerost formy nad treścią. SQL wystarczy.
Integracja z API GUS
Jedną z funkcji PlateMate jest szacowanie kosztów przepisów. Żeby to robić z realnymi cenami (a nie hardkodowanymi wartościami), zintegrowałem aplikację z API Banku Danych Lokalnych GUS.
Skąd biorę ceny
GUS udostępnia publiczne API z cenami produktów spożywczych. Dwa źródła:
| Źródło | Produkty | Aktualizacja |
|---|---|---|
| Ceny detaliczne (P1466) | ok. 60 produktów | Miesięczna |
| Ceny targowiskowe (P3156) | ok. 20 produktów | Półroczna |
Razem 80 produktów: nabiał, mięso, wędliny, ryby, warzywa, owoce, pieczywo, mąka, cukier, olej.
Mapowanie składników
Problem: składnik w przepisie to "masło", a w GUS to "Masło 82.5% tłuszczu" z kodem 4981. Trzeba zmapować jedno do drugiego.
Rozwiązanie: mapa słów kluczowych. "mleko 3" mapuje na ID 4975, "jajko" na ID 4993, "masło" na ID 4981. 130+ mapowań.
protected function findBestGusMatch(string $ingredientName): ?int
{
$keywordMap = [
'mleko 3' => 4975,
'mleko 2' => 4976,
'jajko' => 4993,
'masło' => 4981,
// ... 130+ mapowań
];
foreach ($keywordMap as $keyword => $variableId) {
if (str_contains($normalized, $keyword)) {
return $variableId;
}
}
return null;
}
Synchronizacja uruchamiana komendą artisan: php artisan gus:sync-prices --auto-map --create-missing.
Przeliczanie jednostek
Kolejny problem: GUS podaje cenę masła za 200g, a przepis wymaga 50g. Przelicznik:
public const KNOWN_PRODUCTS = [
4981 => [
'name' => 'Masło 82.5%',
'unit' => 'kg',
'gus_unit' => '200g',
'multiplier' => 5 // 200g x 5 = 1kg
],
];
Model Ingredient ma metodę calculateCost() która bierze ilość z przepisu, przelicza jednostki i mnoży przez cenę z GUS. Efekt: użytkownik widzi "Naleśniki: ok. 5.75 zł za porcję" z realnymi cenami.
Livewire: interaktywność bez JS
Planer tygodniowy to najbardziej interaktywny komponent w aplikacji. Drag & drop posiłków między dniami, podgląd przepisu w modalu, losowanie nowego posiłku jednym kliknięciem.
Wszystko to działa w Livewire 3 z kilkoma rozwiązaniami wartymi uwagi:
URL binding - filtry w bibliotece posiłków są bindowane do URL przez atrybut #[Url]. Użytkownik może zapisać link z aktualnymi filtrami.
#[Url]
public string $search = '';
#[Url]
public string $category = '';
Computed properties z cache - lista kategorii ładuje się raz i jest cache'owana na godzinę.
#[Computed]
public function categories(): array
{
return Cache::remember('meal_categories', 3600, fn() =>
Category::orderBy('order')->pluck('name', 'slug')->toArray()
);
}
Toast notifications - ujednolicony system powiadomień przez dispatch:
$this->dispatch('toast', message: 'Posiłek dodany do planera', type: 'success');
AI w procesie developmentu
Cały projekt zbudowałem przy wsparciu Claude Code. Nie w sensie "AI wygenerował mi boilerplate" - w sensie partnerstwa programistycznego na każdym etapie.
Architektura bazy danych, relacje między tabelami, wybór wzorców (Service Layer) - konsultowane z AI. Implementacja algorytmu rekomendacji - AI zaproponowało podejście scoringowe zamiast collaborative filtering, bo przy małej bazie danych scoring działa lepiej. Integracja z GUS - AI przeprowadził research dostępnych API, znalazł GUS BDL i zaimplementował klienta HTTP.
Tradycyjna implementacja czegoś takiego to 2-3 miesiące solo. Z Claude Code zajęło 2 tygodnie. Różnica nie bierze się z "szybszego pisania kodu" - bierze się z tego, że AI pokrywa edge case'y, pisze dokumentację na bieżąco i robi code review przy każdym commicie.
| Aspekt | Bez AI | Z AI |
|---|---|---|
| Czas implementacji jednej funkcji | 2-3 dni | 2-4 godziny |
| Pokrycie edge case'ów | Podstawowe, dopracowywane w kolejnych iteracjach | Znacznie szersze od pierwszego commita |
| Dokumentacja | "zrobię później" | Na bieżąco |
| Refaktoryzacja | Osobny sprint | W trakcie implementacji |
Czego się nauczyłem
Algorytm scoringowy w SQL wystarczy na małą bazę danych. Nie trzeba Elasticsearch, Redis ani ML pipeline'u żeby dawać trafne rekomendacje. Ważniejsze jest dobre zdefiniowanie czynników i ich wag.
Integracja z GUS to darmowe źródło cen, ale dane targowiskowe aktualizują się co pół roku. Ceny detaliczne co miesiąc. Trzeba to komunikować użytkownikowi jako "szacunek", nie "dokładną cenę".
Livewire 3 nadaje się do aplikacji z interaktywnością na poziomie planera tygodniowego. Drag & drop, live search, real-time kalkulacje - wszystko działa. Gdzie bym nie użył Livewire: gry, edytory graficzne, cokolwiek z animacjami powyżej 60fps.
PlateMate działa jako PWA - użytkownik instaluje aplikację na telefonie prosto z przeglądarki, ma pełny ekran bez paska, działa offline (przepis sprawdzisz w sklepie bez internetu). Jedna baza kodu, trzy platformy. Efekt natywnej apki bez kosztu publikacji w App Store.
Sprawdź sam: plate-mate.pl
Buduję aplikacje webowe w Laravel i integruję je z AI - więcej na stronie backend development.