- TypeScript 60.9%
- JavaScript 29.2%
- CSS 8.7%
- Dockerfile 0.9%
- HTML 0.3%
Keycloak rejected the token exchange: redirect_uri sent was http://planer.mycld.it/... but the one saved at /login was https://. openid-client derives redirect_uri from the current request URL, which was http — Traefik terminates TLS and forwards to the container over plain http. Override protocol/host from APP_URL before the grant call. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> |
||
|---|---|---|
| .claude | ||
| packages | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| CLAUDE.md | ||
| docker-compose.local.yml | ||
| docker-compose.yml | ||
| Dockerfile | ||
| info.md | ||
| package-lock.json | ||
| package.json | ||
| planer-posilkow-v2.jsx | ||
| README.md | ||
| tsconfig.base.json | ||
Planer posiłków
Planer posiłków dla dwuosobowego gospodarstwa. Zastępuje arkusz Excela: tydzień to
7 dni × 5 posiłków, potrawy z biblioteki, osobno dla dwóch osób, z pamięcią tego,
co już było. Interfejs po polsku, kod po angielsku. Kontekst produktowy: CLAUDE.md.
Architektura
Monorepo (npm workspaces), trzy pakiety:
| pakiet | rola | stack |
|---|---|---|
packages/shared |
model domenowy, helpery dat, klucz komórki, projekcja historii, DTO API | TypeScript, jedyne źródło prawdy |
packages/server |
REST API + persystencja | Hono, Drizzle, SQLite (libSQL) |
packages/web |
interfejs | Vite, React, TypeScript, PWA |
Dane żyją na serwerze (aplikacja jest współdzielona). Zapis idzie per komórka z semantyką last-write-wins — dwie osoby edytujące różne sloty się nie nadpisują. Synchronizacja to polling przy fokusie okna. Auth: Keycloak (OIDC) — logowanie to redirect do istniejącej instancji Keycloaka, po powrocie serwer wystawia własne podpisane ciasteczko sesji.
web (React/PWA) ──HTTP /api──► server (Hono) ──► SQLite
└── repozytorium danych (jeden interfejs, jedna implementacja)
Wymagania
- Node ≥ 20 (testowane na 26)
- npm 11
Uruchomienie deweloperskie
npm install
npm run dev
- API: http://localhost:8787
- Frontend: http://localhost:5173 (proxy
/api→ 8787)
Bez skonfigurowanego Keycloaka (KEYCLOAK_ISSUER_URL/KEYCLOAK_CLIENT_ID/
KEYCLOAK_CLIENT_SECRET) auth jest wyłączony — w devie wchodzisz od razu, bez
ekranu logowania. Baza powstaje jako packages/server/data/planer.sqlite i zostaje
wypełniona przykładowymi potrawami przy pierwszym starcie.
Skrypty
npm run build # zbuduj shared → server → web
npm test # testy shared + server (vitest)
npm run typecheck # tsc --noEmit we wszystkich pakietach
npm run dev # server (tsx watch) + web (vite) równolegle
Zmienne środowiskowe
Patrz .env.example. Najważniejsze:
| zmienna | znaczenie |
|---|---|
KEYCLOAK_ISSUER_URL |
adres realmu Keycloaka. Puste = auth OFF (tylko dev/prywatna sieć). W produkcji ustaw. |
KEYCLOAK_CLIENT_ID / KEYCLOAK_CLIENT_SECRET |
client (confidential) skonfigurowany w Keycloaku |
SESSION_SECRET |
podpis własnego ciasteczka sesji (JWT HS256), min. 32 znaki |
APP_URL |
publiczny adres appki — musi zgadzać się z redirect_uri w Keycloaku (APP_URL/api/auth/callback) |
DB_PATH |
plik SQLite. :memory: w testach. |
PORT |
port API i serwowania frontendu w produkcji |
CORS_ORIGIN |
dozwolone originy (dev). Puste w prod (jeden origin). |
Docker
Jeden kontener serwuje API i zbudowany frontend.
Lokalnie / weryfikacja — port 8787 wprost, bez Traefika:
cp .env.example .env # ustaw KEYCLOAK_*, SESSION_SECRET, APP_URL
docker compose -f docker-compose.local.yml up -d --build
# http://localhost:8787
Produkcja za Traefikiem:
cp .env.example .env # ustaw KEYCLOAK_*, SESSION_SECRET, APP_URL
docker compose up -d --build
docker-compose.yml zakłada istniejącą sieć Traefika web i resolver TLS le —
zmień Host(...), entrypoints i certresolver pod swój homelab. Dane trzyma
wolumen planer-data (/data/planer.sqlite).
API (skrót)
| metoda | ścieżka | opis |
|---|---|---|
| GET | /api/auth/login |
redirect do Keycloaka |
| GET | /api/auth/callback |
powrót z Keycloaka, wystawia ciasteczko sesji |
| GET | /api/auth/logout |
czyści sesję, redirect do wylogowania w Keycloaku |
| GET | /api/bootstrap?from=&to= |
dishes + people + tygodnie zakresu (jedno zapytanie na start) |
| GET | /api/weeks?from=&to= |
sam zakres tygodni (polling) |
| PUT | /api/cell |
zapis jednej komórki (LWW) |
| GET/POST/PATCH/DELETE | /api/dishes |
biblioteka potraw (+ /bulk na wklejanie z Excela) |
| GET/PUT | /api/people |
osoby (edycja imion) |
Spłacone długi prototypu
window.storage→ warstwa repozytorium za interfejsem (packages/web/src/data).- Sekwencyjne ładowanie 30 tygodni → jedno zapytanie o zakres (
/api/bootstrap). - CSS w stringu → pliki w
packages/web/src/styles, tokeny 1:1. - Brak auth → logowanie Keycloak (OIDC). Brak sync → polling + LWW per komórka.
- Zero testów → vitest dla dat, klucza komórki, projekcji historii i repozytorium.
Świadome ograniczenia
- Dokładnie dwie osoby. Model dopuszcza N, ale UI (inicjały
p1/p2, przełącznik „Obie osoby", miernikx/70) zakłada dwie. Uogólnienie to osobna decyzja. - Historia mówi, co zaplanowano, nie co zjedzono — patrz
CLAUDE.md. - PWA offline jest read-only (odczyt z cache); zapisy wymagają sieci.