commit 3ea2c3fd827d1a04354d809cc68b7724c2505cc0 Author: Katsuo Date: Mon Nov 2 00:01:33 2026 +0000 Initial commit - MealPlanner diff --git a/.env.example b/.env.example new file mode 100755 index 0000000..d738cf6 --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +# Copier en .env et définir un mot de passe fort. +# Éviter le caractère $ dans le mot de passe (interpolation Docker Compose). +POSTGRES_PASSWORD=replace_with_a_long_random_password + +# Circulaires régionales Flipp +FLIPP_POSTAL_CODE=J7R6S4 +FLIPP_LOCALE=fr diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..2a9dec8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +cd /mnt/user/appdata/mealplanner-project + +cat > .gitignore <<'EOF' +# Secrets +.env +.env.* +!.env.example + +# Python +__pycache__/ +*.py[cod] +.venv/ +venv/ +.pytest_cache/ + +# Node / Next.js +node_modules/ +.next/ +out/ +dist/ + +# Logs +*.log + +# Éditeurs +.DS_Store +Thumbs.db +.vscode/ +.idea/ +EOF diff --git a/README.md b/README.md new file mode 100755 index 0000000..a2da35f --- /dev/null +++ b/README.md @@ -0,0 +1,31 @@ +# MealPlanner V5 — unités de prix et coupes de viande + +Cette version corrige deux problèmes du générateur V4 : +- les prix au poids ne sont plus traités comme des prix par article; +- les coupes de poulet sont distinguées (poitrine ≠ cuisse). + +## Changements +- L'importateur conserve une unité de prix explicite (`lb`, `kg`, `100g` ou `each`) lorsqu'elle est indiquée dans les champs reçus de Flipp. +- Les prix à la livre sont convertis en prix par gramme pour calculer le coût de la quantité de recette. +- Les recettes de poulet indiquent maintenant explicitement « poitrine de poulet ». +- Une offre de cuisses ou de pilons ne peut plus être associée à une recette demandant des poitrines. +- Les recettes V4 déjà présentes dans la base sont migrées vers « poitrine de poulet » au démarrage. +- Si l'unité du prix n'est pas connue ou incompatible, le calcul est laissé partiel plutôt que de présenter un total trompeur. + +## Limite importante +Flipp ne fournit pas toujours l'unité de prix dans le même champ ou pour chaque offre. Dans ce cas, l'application affiche la promotion, mais ne l'utilise pas dans le calcul du coût. Vérifie les exemples affichés après la mise à jour. Le prix `2,99 $/lb` n'est traité comme un prix à la livre que si l'unité `/lb` est présente dans les données reçues. + +## Mise à jour Unraid +1. Fais une copie de sauvegarde du dossier du projet. +2. Décompresse le ZIP V5 dans `/mnt/user/appdata/mealplanner-project`, en remplaçant les fichiers. +3. Garde ton `.env` existant. +4. Reconstruis : + +```bash +cd /mnt/user/appdata/mealplanner-project +docker compose up -d --build +``` + +Ne supprime pas les volumes Docker et ne lance pas `docker compose down -v`. + +Après le démarrage, déclenche une nouvelle importation Flipp depuis l'interface afin de remplir les unités et coupes sur les nouvelles offres. diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100755 index 0000000..dc68bea --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,8 @@ +FROM python:3.12-slim +WORKDIR /app +ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY main.py . +EXPOSE 8000 +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/main.py b/backend/main.py new file mode 100755 index 0000000..f5bdd88 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,515 @@ +import os +import requests +import psycopg +from psycopg.rows import dict_row +from fastapi import FastAPI, HTTPException +from pydantic import BaseModel, Field +from decimal import Decimal + +app = FastAPI(title="MealPlanner API", version="0.3.0") + + +def connect(): + return psycopg.connect( + host=os.getenv("DB_HOST", "db"), + port=int(os.getenv("DB_PORT", "5432")), + dbname=os.getenv("DB_NAME", "mealplanner"), + user=os.getenv("DB_USER", "mealplanner"), + password=os.environ["DB_PASSWORD"], + row_factory=dict_row, + connect_timeout=5, + ) + + +def ensure_schema(): + # Migration idempotente pour les bases existantes de la V1. + with connect() as conn: + conn.execute("ALTER TABLE stores ADD COLUMN IF NOT EXISTS address TEXT") + conn.execute("ALTER TABLE stores ADD COLUMN IF NOT EXISTS postal_code TEXT") + conn.execute("ALTER TABLE stores ADD COLUMN IF NOT EXISTS store_code TEXT") + conn.execute("ALTER TABLE stores ADD COLUMN IF NOT EXISTS flyer_page_url TEXT") + conn.execute(''' + CREATE TABLE IF NOT EXISTS flyer_sources ( + id SERIAL PRIMARY KEY, + store_id INTEGER NOT NULL REFERENCES stores(id) ON DELETE CASCADE, + source_url TEXT NOT NULL, + active BOOLEAN NOT NULL DEFAULT TRUE, + last_checked_at TIMESTAMPTZ, + last_success_at TIMESTAMPTZ, + last_error TEXT, + UNIQUE(store_id, source_url) + ) + ''') + conn.execute(''' + CREATE TABLE IF NOT EXISTS flyers ( + id SERIAL PRIMARY KEY, + store_id INTEGER NOT NULL REFERENCES stores(id) ON DELETE CASCADE, + source_id INTEGER REFERENCES flyer_sources(id) ON DELETE SET NULL, + title TEXT NOT NULL, + source_url TEXT NOT NULL, + local_path TEXT NOT NULL, + checksum TEXT NOT NULL UNIQUE, + file_size BIGINT NOT NULL DEFAULT 0, + page_count INTEGER NOT NULL DEFAULT 0, + extracted_text TEXT NOT NULL DEFAULT '', + extraction_status TEXT NOT NULL DEFAULT 'pending', + valid_from DATE, + valid_to DATE, + downloaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + ''') + conn.execute(''' + CREATE TABLE IF NOT EXISTS flyer_import_runs ( + id SERIAL PRIMARY KEY, + store_id INTEGER REFERENCES stores(id) ON DELETE SET NULL, + source_url TEXT NOT NULL, + started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + finished_at TIMESTAMPTZ, + status TEXT NOT NULL DEFAULT 'running', + message TEXT + ) + ''') + conn.execute(''' + INSERT INTO stores (name, address, postal_code, store_code, flyer_page_url, enabled) + VALUES + ('IGA', %s, 'J7P 2B1', 'IGA EXTRA Lamoureux', %s, TRUE), + ('Metro', %s, 'J7R 4K3', 'Marché Arthur-Sauvé inc. (magasin 125)', %s, TRUE) + ON CONFLICT (name) DO UPDATE SET + address = EXCLUDED.address, + postal_code = EXCLUDED.postal_code, + store_code = EXCLUDED.store_code, + flyer_page_url = EXCLUDED.flyer_page_url, + enabled = TRUE + ''', ( + "379, boulevard Arthur-Sauvé, Saint-Eustache", + os.getenv("IGA_FLYER_PAGE", "https://www.iga.ca/fr/circulaire"), + "753, boulevard Arthur-Sauvé, Saint-Eustache", + os.getenv("METRO_FLYER_PAGE", "https://www.metro.ca/trouver-une-epicerie/125"), + )) + conn.execute(''' + INSERT INTO flyer_sources (store_id, source_url) + SELECT id, flyer_page_url FROM stores + WHERE name IN ('IGA', 'Metro') AND flyer_page_url IS NOT NULL + ON CONFLICT (store_id, source_url) DO NOTHING + ''') + + + +@app.on_event("startup") +def startup(): + ensure_schema() + + +class Settings(BaseModel): + household_size: int = Field(ge=1, le=30) + weekly_budget: Decimal = Field(ge=0, le=100000) + dinners_per_week: int = Field(ge=1, le=7) + + +@app.get("/health") +def health(): + try: + with connect() as conn: + conn.execute("SELECT 1") + return {"status": "ok", "database": "connected"} + except Exception as exc: + raise HTTPException(status_code=503, detail="Database unavailable") from exc + + +@app.get("/api/settings") +def get_settings(): + with connect() as conn: + row = conn.execute( + "SELECT household_size, weekly_budget, dinners_per_week, currency " + "FROM household_settings WHERE id = 1" + ).fetchone() + if row is None: + raise HTTPException(status_code=404, detail="Settings not initialized") + return dict(row) + + +@app.put("/api/settings") +def update_settings(data: Settings): + with connect() as conn: + row = conn.execute( + "UPDATE household_settings SET household_size=%s, weekly_budget=%s, " + "dinners_per_week=%s WHERE id=1 RETURNING household_size, weekly_budget, " + "dinners_per_week, currency", + (data.household_size, data.weekly_budget, data.dinners_per_week), + ).fetchone() + if row is None: + raise HTTPException(status_code=404, detail="Settings not initialized") + return dict(row) + + +@app.get("/api/stores") +def get_stores(): + with connect() as conn: + rows = conn.execute( + "SELECT id, name, enabled, address, postal_code, store_code, flyer_page_url " + "FROM stores ORDER BY name" + ).fetchall() + return [dict(row) for row in rows] + + +@app.get("/api/flyers") +def get_flyers(): + with connect() as conn: + rows=conn.execute(""" + SELECT f.id,f.title,f.valid_from,f.valid_to,f.imported_at AS downloaded_at, + 0 AS page_count,'flipp_imported' AS extraction_status, + (SELECT COUNT(*) FROM flipp_offers o WHERE o.flyer_id=f.id) AS offer_count, + f.thumbnail_url,f.external_flyer_id,f.merchant,f.postal_code, + s.name AS store_name,s.address,'https://www.flipp.com/' AS source_url + FROM flipp_flyers f JOIN stores s ON s.id=f.store_id + WHERE f.valid_to IS NULL OR f.valid_to>=CURRENT_DATE + ORDER BY f.valid_from DESC NULLS LAST,s.name,f.title LIMIT 30 + """).fetchall() + return [dict(x) for x in rows] + + +@app.get("/api/offers") +def get_offers(): + with connect() as conn: + rows=conn.execute(""" + SELECT o.id,o.item_name,o.brand,o.price,o.price_text,o.image_url,o.valid_from,o.valid_to, + f.merchant,f.title AS flyer_title,s.name AS store_name + FROM flipp_offers o JOIN flipp_flyers f ON f.id=o.flyer_id + JOIN stores s ON s.id=o.store_id + WHERE o.valid_to IS NULL OR o.valid_to>=CURRENT_DATE + ORDER BY s.name,o.price,o.item_name LIMIT 250 + """).fetchall() + return [dict(x) for x in rows] + + +@app.get("/api/flyer-import/status") +def flyer_import_status(): + with connect() as conn: + rows=conn.execute(""" + SELECT s.name AS store_name,s.address,fs.source_url,fs.last_checked_at,fs.last_success_at, + fs.last_error, + (SELECT COUNT(*) FROM flipp_flyers f WHERE f.store_id=s.id + AND (f.valid_to IS NULL OR f.valid_to>=CURRENT_DATE)) AS flyer_count, + (SELECT COUNT(*) FROM flipp_offers o WHERE o.store_id=s.id + AND (o.valid_to IS NULL OR o.valid_to>=CURRENT_DATE)) AS offer_count + FROM flyer_sources fs JOIN stores s ON s.id=fs.store_id + WHERE s.name IN ('IGA','Metro') AND fs.source_url='https://www.flipp.com/' + ORDER BY s.name + """).fetchall() + runs=conn.execute(""" + SELECT r.id,s.name AS store_name,r.started_at,r.finished_at,r.status,r.message + FROM flyer_import_runs r LEFT JOIN stores s ON s.id=r.store_id + ORDER BY r.started_at DESC LIMIT 6 + """).fetchall() + return {"sources":[dict(x) for x in rows],"recent_runs":[dict(x) for x in runs]} + + +@app.post("/api/flyer-import/run") +def trigger_flyer_import(): + try: + response = requests.post( + os.getenv("IMPORTER_URL", "http://importer:8001") + "/run", timeout=8 + ) + response.raise_for_status() + return response.json() + except Exception as exc: + raise HTTPException(status_code=502, detail="Importateur indisponible") from exc + + +# Catalogue de recettes de base. Les coûts sont des estimations pour 4 portions, +# non des prix garantis. Les promotions Flipp servent au classement des recettes. +RECIPE_CATALOG = [ + { + "name": "Poulet citron et pommes de terre au four", + "description": "Poulet rôti avec pommes de terre, carottes et citron.", + "servings": 4, "estimated_cost": 17.00, + "instructions": "Chauffer le four à 200 °C. Couper les pommes de terre et les carottes. Mélanger avec huile, citron et assaisonnements. Ajouter le poulet et cuire jusqu'à ce qu'il soit bien cuit (74 °C au centre).", + "ingredients": [("poitrine de poulet", 700, "g"), ("pommes de terre", 700, "g"), ("carottes", 3, "unités"), ("citron", 1, "unité")] + }, + { + "name": "Boulettes de bœuf et riz", + "description": "Boulettes maison dans une sauce tomate, servies avec du riz.", + "servings": 4, "estimated_cost": 16.00, + "instructions": "Former des boulettes avec le bœuf, l'œuf et la chapelure. Les faire dorer, ajouter la sauce tomate et laisser mijoter jusqu'à cuisson complète. Servir sur le riz cuit.", + "ingredients": [("bœuf haché", 500, "g"), ("riz", 250, "g"), ("sauce tomate", 1, "pot"), ("œufs", 1, "unité")] + }, + { + "name": "Pâtes crémeuses au poulet et brocoli", + "description": "Un souper rapide avec poulet, brocoli et sauce crémeuse.", + "servings": 4, "estimated_cost": 15.00, + "instructions": "Cuire les pâtes. Faire cuire le poulet en morceaux, ajouter le brocoli puis la crème. Mélanger avec les pâtes et le fromage.", + "ingredients": [("poitrine de poulet", 500, "g"), ("pâtes", 350, "g"), ("brocoli", 1, "tête"), ("crème", 250, "ml"), ("fromage râpé", 100, "g")] + }, + { + "name": "Tacos de porc et salade croquante", + "description": "Tacos garnis de porc assaisonné, laitue et tomates.", + "servings": 4, "estimated_cost": 16.00, + "instructions": "Cuire le porc haché avec les épices. Réchauffer les tortillas et garnir de porc, laitue, tomates et fromage.", + "ingredients": [("porc haché", 500, "g"), ("tortillas", 8, "unités"), ("laitue", 1, "petite"), ("tomates", 2, "unités"), ("fromage râpé", 100, "g")] + }, + { + "name": "Saumon au miel et légumes", + "description": "Filets de saumon glacés au miel avec légumes rôtis.", + "servings": 4, "estimated_cost": 24.00, + "instructions": "Déposer le saumon et les légumes sur une plaque. Badigeonner le saumon de miel, citron et moutarde. Cuire au four à 200 °C jusqu'à cuisson désirée et sécuritaire.", + "ingredients": [("saumon", 600, "g"), ("brocoli", 1, "tête"), ("carottes", 3, "unités"), ("citron", 1, "unité")] + }, + { + "name": "Chili rapide au bœuf", + "description": "Chili nourrissant aux haricots, tomates et bœuf haché.", + "servings": 4, "estimated_cost": 14.00, + "instructions": "Faire revenir le bœuf et l'oignon. Ajouter les haricots, les tomates et les épices. Laisser mijoter 25 à 30 minutes.", + "ingredients": [("bœuf haché", 400, "g"), ("haricots", 1, "boîte"), ("tomates en dés", 1, "boîte"), ("oignon", 1, "unité")] + }, + { + "name": "Saucisses et légumes sur plaque", + "description": "Un repas simple avec saucisses, pommes de terre et poivrons.", + "servings": 4, "estimated_cost": 15.00, + "instructions": "Couper les légumes, mélanger avec huile et épices. Ajouter les saucisses et cuire sur une plaque à 200 °C jusqu'à ce que tout soit bien cuit.", + "ingredients": [("saucisses", 500, "g"), ("pommes de terre", 600, "g"), ("poivrons", 2, "unités"), ("oignon", 1, "unité")] + }, + { + "name": "Poulet teriyaki et riz", + "description": "Poulet sauté dans une sauce teriyaki, accompagné de riz.", + "servings": 4, "estimated_cost": 16.00, + "instructions": "Cuire le riz. Faire revenir le poulet en lanières, ajouter la sauce teriyaki et laisser épaissir. Servir avec des légumes et le riz.", + "ingredients": [("poitrine de poulet", 600, "g"), ("riz", 250, "g"), ("brocoli", 1, "tête"), ("sauce teriyaki", 100, "ml")] + }, + { + "name": "Frittata aux légumes et fromage", + "description": "Omelette épaisse au four, idéale pour un souper économique.", + "servings": 4, "estimated_cost": 11.00, + "instructions": "Faire revenir les légumes. Battre les œufs avec un peu de lait et le fromage. Verser dans une poêle allant au four et cuire à 180 °C jusqu'à ce que le centre soit pris.", + "ingredients": [("œufs", 8, "unités"), ("poivrons", 1, "unité"), ("épinards", 100, "g"), ("fromage râpé", 100, "g")] + }, + { + "name": "Porc sauté aux légumes et nouilles", + "description": "Lanières de porc sautées avec légumes et nouilles.", + "servings": 4, "estimated_cost": 17.00, + "instructions": "Cuire les nouilles. Saisir le porc en lanières, ajouter les légumes et la sauce soya. Servir avec les nouilles.", + "ingredients": [("porc", 500, "g"), ("nouilles", 300, "g"), ("poivrons", 1, "unité"), ("brocoli", 1, "tête")] + }, + { + "name": "Pizza maison jambon et légumes", + "description": "Pizza facile avec sauce tomate, fromage, jambon et légumes.", + "servings": 4, "estimated_cost": 13.00, + "instructions": "Étendre la sauce tomate sur la pâte, ajouter le fromage, le jambon et les légumes. Cuire selon les indications de la pâte jusqu'à ce que la croûte soit dorée.", + "ingredients": [("pâte à pizza", 1, "unité"), ("sauce tomate", 150, "ml"), ("fromage râpé", 200, "g"), ("jambon", 150, "g"), ("poivrons", 1, "unité")] + }, + { + "name": "Bols burrito au poulet", + "description": "Bols de riz avec poulet, haricots, maïs et tomates.", + "servings": 4, "estimated_cost": 16.00, + "instructions": "Cuire le riz et le poulet avec les épices. Répartir dans des bols avec les haricots, le maïs et les tomates.", + "ingredients": [("poitrine de poulet", 500, "g"), ("riz", 250, "g"), ("haricots", 1, "boîte"), ("maïs", 1, "boîte"), ("tomates", 2, "unités")] + } +] + +def seed_recipes(): + with connect() as conn: + # Migration des recettes V4 : la mention générique « poulet » devenait + # une correspondance trop large pouvant confondre poitrine et cuisse. + conn.execute(""" + UPDATE recipe_ingredients ri SET ingredient_name='poitrine de poulet' + FROM recipes r + WHERE ri.recipe_id=r.id AND ri.ingredient_name='poulet' + AND r.name IN ('Poulet citron et pommes de terre au four', + 'Pâtes crémeuses au poulet et brocoli', + 'Poulet teriyaki et riz', + 'Bols burrito au poulet') + """) + for recipe in RECIPE_CATALOG: + row = conn.execute("SELECT id FROM recipes WHERE name=%s ORDER BY id LIMIT 1", (recipe["name"],)).fetchone() + if row is None: + row = conn.execute( + "INSERT INTO recipes (name,description,servings,instructions,estimated_cost) " + "VALUES (%s,%s,%s,%s,%s) RETURNING id", + (recipe["name"],recipe["description"],recipe["servings"],recipe["instructions"],recipe["estimated_cost"]) + ).fetchone() + recipe_id = row["id"] + count = conn.execute("SELECT COUNT(*) AS n FROM recipe_ingredients WHERE recipe_id=%s", (recipe_id,)).fetchone()["n"] + if count == 0: + for name, quantity, unit in recipe["ingredients"]: + conn.execute( + "INSERT INTO recipe_ingredients (recipe_id,ingredient_name,quantity,unit) VALUES (%s,%s,%s,%s)", + (recipe_id,name,quantity,unit) + ) + +def normalize_text(value): + import unicodedata, re + value = unicodedata.normalize("NFKD", str(value or "").lower()) + value = "".join(ch for ch in value if not unicodedata.combining(ch)) + return re.sub(r"[^a-z0-9 ]+", " ", value).strip() + +INGREDIENT_TERMS = { + "poitrine de poulet": ["poitrine de poulet", "poulet poitrine", "chicken breast", "breast"], + "cuisse de poulet": ["cuisse de poulet", "haut de cuisse", "chicken thigh", "thigh", "drumstick"], + "bœuf haché": ["boeuf hache", "ground beef"], + "porc haché": ["porc hache", "ground pork"], + "porc": ["porc", "pork"], + "saumon": ["saumon", "salmon"], "saucisses": ["saucisse", "sausage"], + "pommes de terre": ["pomme de terre", "patate", "potato"], + "carottes": ["carotte", "carrot"], "brocoli": ["brocoli", "broccoli"], + "tomates": ["tomate", "tomato"], "tomates en dés": ["tomate", "diced tomato"], + "sauce tomate": ["sauce tomate", "tomato sauce"], "fromage râpé": ["fromage", "cheddar", "mozzarella"], + "œufs": ["oeuf", "egg"], "poivrons": ["poivron", "pepper"], + "laitue": ["laitue", "lettuce"], "épinards": ["epinard", "spinach"], + "haricots": ["haricot", "bean"], "maïs": ["mais", "corn"], + "jambon": ["jambon", "ham"], "crème": ["creme", "cream"], + "riz": ["riz", "rice"], "pâtes": ["pate", "pasta"], "nouilles": ["nouille", "noodle"], + "tortillas": ["tortilla"], "citron": ["citron", "lemon"], +} + +def match_offer(ingredient, offer_name): + text = normalize_text(offer_name) + ingredient_norm = normalize_text(ingredient) + # Les coupes de poulet ne sont jamais interchangeables. + if "poulet" in ingredient_norm or "chicken" in ingredient_norm: + wants_breast = any(x in ingredient_norm for x in ["poitrine", "breast"]) + wants_thigh = any(x in ingredient_norm for x in ["cuisse", "thigh"]) + has_breast = any(x in text for x in ["poitrine", "breast", "supreme"]) + has_thigh = any(x in text for x in ["cuisse", "thigh", "drumstick", "pilons"]) + if wants_breast and (has_thigh or not has_breast): + return False + if wants_thigh and (has_breast or not has_thigh): + return False + if not wants_breast and not wants_thigh: + return False + terms = INGREDIENT_TERMS.get(ingredient, [normalize_text(ingredient)]) + return any(term in text for term in terms) + +def ingredient_cost(ingredient, offer, household_size, servings): + """Retourne un coût calculable seulement si l'unité de vente correspond.""" + qty = float(ingredient.get("quantity") or 0) * household_size / max(int(servings or 4), 1) + unit = normalize_text(ingredient.get("unit") or "") + basis = offer.get("price_unit") or "each" + price = float(offer["price"]) + if basis == "lb" and unit in ("g", "gramme", "grammes"): + return price * qty / 453.59237 + if basis == "kg" and unit in ("g", "gramme", "grammes"): + return price * qty / 1000 + if basis == "100g" and unit in ("g", "gramme", "grammes"): + return price * qty / 100 + if basis == "each" and unit in ("unite", "unites", "unité", "unités"): + return price * qty + return None + +@app.on_event("startup") +def seed_recipe_catalog(): + seed_recipes() + +@app.get("/api/meal-suggestions") +def meal_suggestions(): + with connect() as conn: + recipes = conn.execute(""" + SELECT r.id,r.name,r.description,r.servings,r.instructions,r.estimated_cost, + COALESCE(json_agg(json_build_object('name',ri.ingredient_name,'quantity',ri.quantity,'unit',ri.unit)) + FILTER (WHERE ri.id IS NOT NULL),'[]') AS ingredients + FROM recipes r LEFT JOIN recipe_ingredients ri ON ri.recipe_id=r.id + GROUP BY r.id ORDER BY r.name + """).fetchall() + offers = conn.execute(""" + SELECT o.item_name,o.brand,o.price,o.price_text,o.price_unit,o.product_cut,o.valid_to,s.name AS store_name + FROM flipp_offers o JOIN stores s ON s.id=o.store_id + WHERE o.valid_to IS NULL OR o.valid_to>=CURRENT_DATE + ORDER BY o.price LIMIT 500 + """).fetchall() + settings = conn.execute("SELECT household_size,dinners_per_week,weekly_budget FROM household_settings WHERE id=1").fetchone() + output=[] + household_size = settings["household_size"] if settings else 4 + for recipe in recipes: + matches=[] + seen=set() + calculable_cost=0.0 + calculated_ingredients=0 + for ingredient in recipe["ingredients"]: + for offer in offers: + key=(ingredient["name"],offer["item_name"],offer["store_name"]) + if key not in seen and match_offer(ingredient["name"],offer["item_name"]): + seen.add(key) + cost=ingredient_cost(ingredient,offer,household_size,recipe["servings"]) + matches.append({ + "ingredient":ingredient["name"],"item_name":offer["item_name"], + "price":float(offer["price"]),"price_text":offer["price_text"], + "price_unit":offer["price_unit"],"calculated_cost":round(cost,2) if cost is not None else None, + "store_name":offer["store_name"],"valid_to":str(offer["valid_to"]) if offer["valid_to"] else None + }) + if cost is not None: + calculable_cost += cost + calculated_ingredients += 1 + break + # Les autres ingrédients ne sont pas chiffrés arbitrairement à partir d'un prix/unité. + # On indique le coût des ingrédients effectivement calculables. + output.append({ + **dict(recipe), + "estimated_cost":round(calculable_cost,2) if calculated_ingredients else None, + "cost_is_partial":calculated_ingredients < len(recipe["ingredients"]), + "promotion_matches":matches,"promotion_count":len(matches) + }) + output.sort(key=lambda x:(-x["promotion_count"],x["estimated_cost"],x["name"])) + return {"suggestions":output,"household_size":household_size, + "dinners_per_week":settings["dinners_per_week"] if settings else 7, + "weekly_budget":float(settings["weekly_budget"]) if settings else 150.0} + +class MealPlanInput(BaseModel): + week_start: str + recipe_ids: list[int] = Field(min_length=1, max_length=7) + +@app.post("/api/meal-plan") +def save_meal_plan(data: MealPlanInput): + from datetime import date, timedelta + try: + start=date.fromisoformat(data.week_start) + except ValueError: + raise HTTPException(status_code=400, detail="Date de début invalide") + with connect() as conn: + plan=conn.execute( + "INSERT INTO meal_plans (week_start) VALUES (%s) ON CONFLICT (week_start) DO UPDATE SET week_start=EXCLUDED.week_start RETURNING id", + (start,) + ).fetchone() + plan_id=plan["id"] + conn.execute("DELETE FROM planned_meals WHERE meal_plan_id=%s",(plan_id,)) + for index,recipe_id in enumerate(data.recipe_ids): + exists=conn.execute("SELECT id FROM recipes WHERE id=%s",(recipe_id,)).fetchone() + if not exists: + raise HTTPException(status_code=400,detail="Recette introuvable") + conn.execute( + "INSERT INTO planned_meals (meal_plan_id,meal_date,recipe_id) VALUES (%s,%s,%s)", + (plan_id,start+timedelta(days=index),recipe_id) + ) + return {"saved":True,"week_start":start.isoformat(),"meals_saved":len(data.recipe_ids)} + +@app.get("/api/meal-plan") +def get_meal_plan(week_start: str): + from datetime import date + try: + start=date.fromisoformat(week_start) + except ValueError: + raise HTTPException(status_code=400,detail="Date de début invalide") + with connect() as conn: + rows=conn.execute(""" + SELECT pm.id,pm.meal_date,r.id AS recipe_id,r.name,r.description,r.estimated_cost + FROM meal_plans mp JOIN planned_meals pm ON pm.meal_plan_id=mp.id + LEFT JOIN recipes r ON r.id=pm.recipe_id + WHERE mp.week_start=%s ORDER BY pm.meal_date + """,(start,)).fetchall() + return [dict(x) for x in rows] + +@app.get("/api/shopping-list") +def get_shopping_list(week_start: str): + from datetime import date + try: + start=date.fromisoformat(week_start) + except ValueError: + raise HTTPException(status_code=400,detail="Date de début invalide") + with connect() as conn: + rows=conn.execute(""" + SELECT ri.ingredient_name,ri.unit,SUM(ri.quantity) AS quantity, + COUNT(DISTINCT pm.id) AS recipe_count + FROM meal_plans mp JOIN planned_meals pm ON pm.meal_plan_id=mp.id + JOIN recipe_ingredients ri ON ri.recipe_id=pm.recipe_id + WHERE mp.week_start=%s + GROUP BY ri.ingredient_name,ri.unit + ORDER BY ri.ingredient_name + """,(start,)).fetchall() + return [dict(x) for x in rows] diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100755 index 0000000..d6c146b --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,4 @@ +fastapi==0.115.12 +uvicorn[standard]==0.34.0 +psycopg[binary]==3.2.6 +requests==2.32.3 diff --git a/database/init.sql b/database/init.sql new file mode 100755 index 0000000..09917b5 --- /dev/null +++ b/database/init.sql @@ -0,0 +1,66 @@ +CREATE TABLE IF NOT EXISTS stores ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + enabled BOOLEAN NOT NULL DEFAULT TRUE +); +ALTER TABLE stores ADD COLUMN IF NOT EXISTS address TEXT; +ALTER TABLE stores ADD COLUMN IF NOT EXISTS postal_code TEXT; +ALTER TABLE stores ADD COLUMN IF NOT EXISTS store_code TEXT; +ALTER TABLE stores ADD COLUMN IF NOT EXISTS flyer_page_url TEXT; + +CREATE TABLE IF NOT EXISTS household_settings ( + id INTEGER PRIMARY KEY CHECK (id=1), + household_size INTEGER NOT NULL DEFAULT 4, + weekly_budget NUMERIC(10,2) NOT NULL DEFAULT 150.00, + dinners_per_week INTEGER NOT NULL DEFAULT 7, + currency TEXT NOT NULL DEFAULT 'CAD' +); +INSERT INTO household_settings (id,household_size,weekly_budget,dinners_per_week,currency) +VALUES (1,4,150.00,7,'CAD') ON CONFLICT (id) DO NOTHING; +INSERT INTO stores (name) VALUES ('IGA'),('Metro'),('Costco'),('Maxi'),('Super C'),('Walmart') +ON CONFLICT (name) DO NOTHING; + +CREATE TABLE IF NOT EXISTS products ( + id SERIAL PRIMARY KEY, name TEXT NOT NULL, brand TEXT, unit TEXT +); +CREATE TABLE IF NOT EXISTS flyer_offers ( + id SERIAL PRIMARY KEY, store_id INTEGER REFERENCES stores(id) ON DELETE CASCADE, + product_id INTEGER REFERENCES products(id) ON DELETE SET NULL, + price NUMERIC(10,2), regular_price NUMERIC(10,2), valid_from DATE, valid_to DATE, source_url TEXT +); +CREATE TABLE IF NOT EXISTS recipes ( + id SERIAL PRIMARY KEY, name TEXT NOT NULL, description TEXT, + servings INTEGER NOT NULL DEFAULT 4, instructions TEXT, estimated_cost NUMERIC(10,2) +); +CREATE TABLE IF NOT EXISTS recipe_ingredients ( + id SERIAL PRIMARY KEY, recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE CASCADE, + product_id INTEGER REFERENCES products(id) ON DELETE SET NULL, ingredient_name TEXT NOT NULL, + quantity NUMERIC(10,2), unit TEXT +); +CREATE TABLE IF NOT EXISTS meal_plans ( + id SERIAL PRIMARY KEY, week_start DATE NOT NULL UNIQUE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE TABLE IF NOT EXISTS planned_meals ( + id SERIAL PRIMARY KEY, meal_plan_id INTEGER NOT NULL REFERENCES meal_plans(id) ON DELETE CASCADE, + meal_date DATE NOT NULL, recipe_id INTEGER REFERENCES recipes(id) ON DELETE SET NULL, custom_name TEXT +); +CREATE TABLE IF NOT EXISTS flyer_sources ( + id SERIAL PRIMARY KEY, store_id INTEGER NOT NULL REFERENCES stores(id) ON DELETE CASCADE, + source_url TEXT NOT NULL, active BOOLEAN NOT NULL DEFAULT TRUE, + last_checked_at TIMESTAMPTZ, last_success_at TIMESTAMPTZ, last_error TEXT, + UNIQUE(store_id,source_url) +); +CREATE TABLE IF NOT EXISTS flyers ( + id SERIAL PRIMARY KEY, store_id INTEGER NOT NULL REFERENCES stores(id) ON DELETE CASCADE, + source_id INTEGER REFERENCES flyer_sources(id) ON DELETE SET NULL, + title TEXT NOT NULL, source_url TEXT NOT NULL, local_path TEXT NOT NULL, + checksum TEXT NOT NULL UNIQUE, file_size BIGINT NOT NULL DEFAULT 0, + page_count INTEGER NOT NULL DEFAULT 0, extracted_text TEXT NOT NULL DEFAULT '', + extraction_status TEXT NOT NULL DEFAULT 'pending', valid_from DATE, valid_to DATE, + downloaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE TABLE IF NOT EXISTS flyer_import_runs ( + id SERIAL PRIMARY KEY, store_id INTEGER REFERENCES stores(id) ON DELETE SET NULL, + source_url TEXT NOT NULL, started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + finished_at TIMESTAMPTZ, status TEXT NOT NULL DEFAULT 'running', message TEXT +); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100755 index 0000000..e40b4d8 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,69 @@ +services: + db: + image: postgres:16-alpine + container_name: mealplanner-db + restart: unless-stopped + environment: + POSTGRES_DB: mealplanner + POSTGRES_USER: mealplanner + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env} + volumes: + - postgres_data:/var/lib/postgresql/data + - ./database/init.sql:/docker-entrypoint-initdb.d/001-init.sql:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U mealplanner -d mealplanner"] + interval: 10s + timeout: 5s + retries: 10 + + api: + build: + context: ./backend + container_name: mealplanner-api + restart: unless-stopped + environment: + DB_HOST: db + DB_PORT: "5432" + DB_NAME: mealplanner + DB_USER: mealplanner + DB_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env} + IMPORTER_URL: http://importer:8001 + depends_on: + db: + condition: service_healthy + + importer: + build: + context: ./importer + container_name: mealplanner-importer + restart: unless-stopped + environment: + DB_HOST: db + DB_PORT: "5432" + DB_NAME: mealplanner + DB_USER: mealplanner + DB_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env} + FLYER_STORAGE: /data/flyers + TZ: America/Toronto + RUN_ON_START: "true" + FLIPP_POSTAL_CODE: J7R6S4 + FLIPP_LOCALE: fr + volumes: + - flyer_data:/data/flyers + depends_on: + db: + condition: service_healthy + + frontend: + build: + context: ./frontend + container_name: mealplanner-frontend + restart: unless-stopped + ports: + - "3000:3000" + depends_on: + - api + +volumes: + postgres_data: + flyer_data: diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100755 index 0000000..048c9b8 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,8 @@ +FROM node:22-alpine +WORKDIR /app +COPY package*.json ./ +RUN npm install +COPY . . +RUN npm run build +EXPOSE 3000 +CMD ["npm", "run", "start"] diff --git a/frontend/app/globals.css b/frontend/app/globals.css new file mode 100755 index 0000000..9612057 --- /dev/null +++ b/frontend/app/globals.css @@ -0,0 +1,68 @@ +@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;600;700;800&family=Manrope:wght@600;700;800&display=swap'); +*{box-sizing:border-box}body{margin:0;background:#f7f8f3;color:#203329;font-family:'DM Sans',sans-serif}main{min-height:100vh}aside{position:fixed;inset:0 auto 0 0;width:220px;background:white;border-right:1px solid #e8ece5;padding:30px 22px;display:flex;flex-direction:column;gap:22px}aside h2{font-family:Manrope;color:#183c2d}aside p,small{font-size:10px;letter-spacing:1.4px;color:#819087;font-weight:800}aside a{color:#26734d;text-decoration:none;padding:12px;border-radius:10px}section{margin-left:220px;padding:38px 5vw;max-width:1500px}header{display:flex;justify-content:space-between;align-items:center;margin-bottom:28px}header h1{font:800 30px Manrope;color:#183c2d;margin:8px 0}header p{color:#758078}.hero{border-radius:22px;background:linear-gradient(110deg,#204e38,#397d51);color:white;padding:35px 42px;display:flex;justify-content:space-between;align-items:center}.hero h1{font:800 38px Manrope;line-height:1.15}.hero p{max-width:420px;color:#dcebe0}.hero a{display:inline-block;background:white;color:#24563c;text-decoration:none;padding:12px 17px;border-radius:10px;font-weight:800;font-size:13px}.hero span{font-size:115px}.stats,.stores,.fields{display:grid;grid-template-columns:repeat(3,1fr);gap:15px;margin:22px 0 35px}.stats article,article,.empty,#settings{background:white;border:1px solid #e8ece5;border-radius:15px;padding:18px}.stats h2{font:800 25px Manrope;color:#183c2d;margin:8px 0}section>div:not(.hero){margin-top:35px}section h2{font:800 21px Manrope;color:#183c2d}.empty{padding:25px;color:#758078}.stores article{font-weight:700}#settings{margin-top:35px}.fields{margin:18px 0}.fields label{display:grid;gap:8px;font-size:12px;color:#69776e;font-weight:700}.fields input{padding:12px;border:1px solid #dfe6df;border-radius:9px;font:600 14px 'DM Sans' }button{background:#26734d;border:0;color:white;padding:12px 18px;border-radius:9px;font-weight:700;cursor:pointer}footer{text-align:center;color:#9aa49c;font-size:11px;padding:30px}@media(max-width:750px){aside{width:65px;padding:20px 8px}aside h2{font-size:0}aside a{font-size:0}section{margin-left:65px;padding:20px 14px}.hero{padding:24px}.hero span{font-size:60px}.hero h1{font-size:28px}.stats,.fields{grid-template-columns:1fr}.stores{grid-template-columns:repeat(2,1fr)}header{align-items:flex-start;gap:12px;flex-direction:column}} + +/* Module Circulaires — ajout à la présentation V1 */ +#flyers{margin-top:35px} +.flyer-control{display:flex;align-items:center;justify-content:space-between;gap:18px;flex-wrap:wrap} +.flyer-control p{max-width:650px;color:#758078;font-size:13px;line-height:1.6} +.import-message{width:100%;margin:0} +.flyer-sources{grid-template-columns:repeat(2,1fr)} +.flyer-sources article{display:flex;flex-direction:column;gap:8px} +.store-address{font-size:12px;color:#758078;margin:5px 0} +.source-error{font-size:12px;color:#a34b35;line-height:1.5} +.flyer-list{display:grid;grid-template-columns:repeat(2,1fr);gap:15px;margin-top:16px} +.flyer-list article{display:flex;flex-direction:column;gap:8px} +.flyer-list h3{font:700 16px Manrope;color:#183c2d;margin:0} +.flyer-list p{font-size:12px;color:#758078;margin:0} +.flyer-list a{font-size:12px;color:#26734d;font-weight:700;text-decoration:none} +@media(max-width:750px){.flyer-sources,.flyer-list{grid-template-columns:1fr}.flyer-control{align-items:flex-start}} + +/* Promotions Flipp — ajout au module Circulaires V1 */ +.flyer-thumb{width:100%;max-height:170px;object-fit:cover;border-radius:10px;margin-bottom:5px} +.offers-intro{font-size:13px;color:#758078;line-height:1.6} +.offer-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;margin-top:16px} +.offer-card{display:grid;grid-template-columns:92px minmax(0,1fr);gap:12px;align-items:center;padding:12px;min-width:0} +.offer-card>img,.offer-placeholder{width:92px;height:92px;object-fit:contain;border-radius:10px;background:#f4f6f1} +.offer-placeholder{display:grid;place-items:center;font-size:34px} +.offer-detail{display:flex;flex-direction:column;gap:5px;min-width:0} +.offer-detail small{font-size:9px;letter-spacing:.7px} +.offer-detail strong{font-size:12px;line-height:1.35;color:#203329} +.offer-detail span{font-size:11px;color:#758078} +.offer-detail b{font:800 17px Manrope;color:#26734d} +@media(max-width:1100px){.offer-grid{grid-template-columns:repeat(2,minmax(0,1fr))}} +@media(max-width:750px){.offer-grid{grid-template-columns:1fr}.offer-card{grid-template-columns:78px minmax(0,1fr)}.offer-card>img,.offer-placeholder{width:78px;height:78px}} + + +/* Générateur de soupers — extension de l'interface V1 */ +.meal-generator{padding:22px;margin-top:14px} +.meal-generator-head{display:flex;justify-content:space-between;align-items:center;gap:18px;flex-wrap:wrap} +.meal-generator-head strong{font:800 16px Manrope;color:#183c2d} +.meal-generator-head p,.estimate-note{font-size:12px;color:#758078;line-height:1.6;max-width:700px} +.selection-summary{display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap;margin:22px 0 14px;padding:13px;background:#f4f7f1;border-radius:10px;color:#315640;font-size:12px} +.selection-summary span{color:#758078} +.suggestion-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;margin:14px 0} +.suggestion-card{padding:16px;min-width:0;transition:border-color .15s,box-shadow .15s} +.suggestion-card.selected{border-color:#7db58d;box-shadow:0 0 0 2px #e5f1e7} +.suggestion-top{display:flex;justify-content:space-between;align-items:center;gap:10px} +.suggestion-top label{display:flex;align-items:center;gap:6px;color:#64736a;font-size:11px;font-weight:700} +.suggestion-top input,.shopping-list input{accent-color:#26734d} +.meal-icon{font-size:25px} +.suggestion-card h3,.saved-plan h3{font:800 15px Manrope;color:#183c2d;margin:12px 0 6px} +.suggestion-card>p{font-size:12px;color:#758078;line-height:1.5;margin:0} +.recipe-meta{display:flex;justify-content:space-between;align-items:center;margin:12px 0;font-size:11px;color:#758078} +.recipe-meta b{font:800 16px Manrope;color:#26734d} +.promotion-tags{display:flex;flex-direction:column;gap:5px;padding:10px;background:#f1f7ef;border-radius:9px} +.promotion-tags strong{font-size:11px;color:#26734d} +.promotion-tags small{font-size:10px;letter-spacing:0;color:#63766a} +.no-promo{font-size:10px;letter-spacing:0;color:#89948d} +.suggestion-card details{margin-top:12px;border-top:1px solid #edf0eb;padding-top:10px;font-size:11px;color:#637168} +.suggestion-card summary{cursor:pointer;font-weight:700;color:#26734d} +.suggestion-card details ul{padding-left:18px;line-height:1.7} +.suggestion-card details p{line-height:1.6} +.estimate-note{margin:14px 0} +.saved-plan{margin-top:16px} +.saved-plan>p{font-size:13px;color:#637168} +.shopping-list{display:grid;gap:10px;margin-top:15px;padding-top:14px;border-top:1px solid #edf0eb} +.shopping-list h3{margin:0} +.shopping-list label{display:flex;align-items:center;gap:8px;font-size:12px;color:#405448} +@media(max-width:750px){.suggestion-grid{grid-template-columns:1fr}.meal-generator{padding:16px}.meal-generator-head{align-items:flex-start}.selection-summary{flex-direction:column}} diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx new file mode 100755 index 0000000..e17f052 --- /dev/null +++ b/frontend/app/layout.tsx @@ -0,0 +1,11 @@ +import type { Metadata } from "next"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "MealPlanner", + description: "Planificateur de repas hebdomadaire" +}; + +export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { + return {children}; +} diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx new file mode 100755 index 0000000..febeb37 --- /dev/null +++ b/frontend/app/page.tsx @@ -0,0 +1,156 @@ +"use client"; +import {useEffect,useState} from "react"; + +type Flyer = { + id:number; title:string; source_url:string; valid_from:string|null; valid_to:string|null; + downloaded_at:string; page_count:number; extraction_status:string; store_name:string; address:string|null; + offer_count:number; thumbnail_url:string|null; external_flyer_id:number|null; merchant:string|null; postal_code:string|null; +}; +type Offer = {id:number;item_name:string;brand:string|null;price:number|string;price_text:string|null;image_url:string|null;valid_from:string|null;valid_to:string|null;merchant:string|null;flyer_title:string|null;store_name:string;}; +type Ingredient = {name:string;quantity:number;unit:string}; +type Suggestion = {id:number;name:string;description:string;servings:number;instructions:string;estimated_cost:number;ingredients:Ingredient[];promotion_matches:{ingredient:string;item_name:string;price:number;price_text:string|null;price_unit:string;calculated_cost:number|null;store_name:string;valid_to:string|null}[];promotion_count:number;cost_is_partial:boolean}; +type ShoppingItem = {ingredient_name:string;unit:string;quantity:number;recipe_count:number}; +type SourceStatus = { + store_name:string; address:string; source_url:string; last_checked_at:string|null; + last_success_at:string|null; last_error:string|null; flyer_count:number; offer_count:number; +}; +export default function Home(){ + const [s,setS]=useState({household_size:4,weekly_budget:150,dinners_per_week:7,currency:"CAD"}); + const [stores,setStores]=useState([]); + const [flyers,setFlyers]=useState([]); + const [offers,setOffers]=useState([]); + const [sources,setSources]=useState([]); + const [msg,setMsg]=useState("Connexion…"); + const [importMsg,setImportMsg]=useState(""); + const [importing,setImporting]=useState(false); + const [suggestions,setSuggestions]=useState([]); + const [selectedIds,setSelectedIds]=useState([]); + const [generating,setGenerating]=useState(false); + const [savingPlan,setSavingPlan]=useState(false); + const [planMsg,setPlanMsg]=useState(""); + const [shoppingList,setShoppingList]=useState([]); + const [savedMeals,setSavedMeals]=useState([]); + const [showShopping,setShowShopping]=useState(false); + const today=new Date(); + const monday=new Date(today); + monday.setDate(today.getDate()-((today.getDay()+6)%7)); + const weekStart=`${monday.getFullYear()}-${String(monday.getMonth()+1).padStart(2,"0")}-${String(monday.getDate()).padStart(2,"0")}`; + const selectedRecipes=suggestions.filter(x=>selectedIds.includes(x.id)); + const selectedCost=selectedRecipes.reduce((sum,x)=>sum+(x.estimated_cost===null?0:Number(x.estimated_cost)),0); + const selectedCostsComplete=selectedRecipes.every(x=>!x.cost_is_partial&&x.estimated_cost!==null); + async function loadFlyers(){ + try{ + const [f,st,o]=await Promise.all([ + fetch("/api/flyers").then(r=>r.ok?r.json():[]), + fetch("/api/flyer-import/status").then(r=>r.ok?r.json():{sources:[]}), + fetch("/api/offers").then(r=>r.ok?r.json():[]) + ]); + setFlyers(f); setSources(st.sources||[]); setOffers(o); + }catch{} + } + useEffect(()=>{ + Promise.all([fetch("/api/settings").then(r=>r.json()),fetch("/api/stores").then(r=>r.json())]) + .then(([a,b])=>{setS(a);setStores(b);setMsg("Application connectée")}) + .catch(()=>setMsg("API non disponible")); + loadFlyers(); + },[]); + async function save(){ + const r=await fetch("/api/settings",{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify(s)}); + setMsg(r.ok?"Paramètres enregistrés":"Erreur d'enregistrement"); + } + async function generateSuggestions(){ + setGenerating(true); setPlanMsg("Analyse des promotions et préparation des idées de soupers…"); + try{ + const r=await fetch("/api/meal-suggestions"); + if(!r.ok) throw new Error("Impossible de générer les suggestions"); + const data=await r.json(); + setSuggestions(data.suggestions||[]); + setSelectedIds((data.suggestions||[]).slice(0,Number(s.dinners_per_week)||7).map((x:Suggestion)=>x.id)); + setPlanMsg(`${(data.suggestions||[]).length} idées analysées selon les promotions disponibles.`); + }catch(e:any){setPlanMsg(e.message||"Erreur lors de la génération");} + finally{setGenerating(false);} + } + async function saveMealPlan(){ + if(!selectedIds.length){setPlanMsg("Sélectionne au moins un souper.");return;} + setSavingPlan(true);setPlanMsg("Enregistrement du menu…"); + try{ + const r=await fetch("/api/meal-plan",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({week_start:weekStart,recipe_ids:selectedIds})}); + const data=await r.json(); + if(!r.ok) throw new Error(data.detail||"Impossible d'enregistrer le menu"); + const [meals,list]=await Promise.all([ + fetch(`/api/meal-plan?week_start=${weekStart}`).then(x=>x.json()), + fetch(`/api/shopping-list?week_start=${weekStart}`).then(x=>x.json()) + ]); + setSavedMeals(meals);setShoppingList(list);setShowShopping(true); + setPlanMsg(`Menu enregistré : ${data.meals_saved} soupers planifiés.`); + }catch(e:any){setPlanMsg(e.message||"Erreur d'enregistrement");} + finally{setSavingPlan(false);} + } + function toggleRecipe(id:number){ + setSelectedIds(current=>current.includes(id)?current.filter(x=>x!==id):current.length>=Number(s.dinners_per_week)?current:[...current,id]); + } + async function checkFlyers(){ + setImporting(true); setImportMsg("Vérification des circulaires en cours…"); + try{ + const r=await fetch("/api/flyer-import/run",{method:"POST"}); + const data=await r.json(); + if(!r.ok) throw new Error(data.detail||"Impossible de lancer l'importation"); + setImportMsg("Vérification lancée. Actualise la page dans quelques instants pour voir le résultat."); + window.setTimeout(loadFlyers,15000); + }catch(e:any){setImportMsg(e.message||"Erreur");} + finally{setImporting(false);} + } + const dateLabel=(v:string|null)=>v?new Date(v).toLocaleString("fr-CA"):"—"; + return
TON ASSISTANT CULINAIRE

Bonjour ! 👋

Préparons une semaine de soupers savoureux sans dépasser ton budget.

CETTE SEMAINE

Les bons repas
commencent ici.

Des menus personnalisés selon les rabais de tes épiceries préférées.

Configurer mon budget →
🥗
Budget hebdomadaire

{Number(s.weekly_budget).toFixed(2)} $

Soupers à planifier

{s.dinners_per_week}

Personnes

{s.household_size}

TES COMMERCES

Épiceries suivies

{stores.map(x=>
✓ {x.name}{x.address&&

{x.address}

}
)}
+
RABAIS DE TES MAGASINS

Circulaires IGA et Metro

+
+

Importation automatique quotidienne à 6 h (heure de Montréal) des circulaires régionales Flipp pour J7R 6S4. Les articles avec nom et prix sont enregistrés dans ta base locale.

+ + {importMsg&&

{importMsg}

} +
+
{sources.map(x=>
+ {x.store_name}

{x.address}

+ Dernière vérification : {dateLabel(x.last_checked_at)} + Circulaires actives : {x.flyer_count}Promotions disponibles : {x.offer_count} + {x.last_error&&

{x.last_error}

} +
)}
+
{flyers.map(f=>
+ {f.thumbnail_url&&} + {f.store_name}

{f.title}

+

{f.offer_count} promotions · Valide du {f.valid_from||"—"} au {f.valid_to||"—"}

+ Circulaire régionale · Code postal {f.postal_code||"J7R6S4"} + Importée : {dateLabel(f.downloaded_at)} + Source Flipp ↗ +
)} + {flyers.length===0&&
Aucune circulaire régionale importée pour le moment. Utilise le bouton pour lancer une vérification.
} +
+

Promotions en cours

+

Prix des circulaires régionales; ils peuvent varier selon la succursale et la disponibilité.

+
{offers.slice(0,60).map(o=>
+ {o.image_url?:
🛒
} +
{o.store_name}{o.item_name} + {o.brand&&{o.brand}}{o.price_text||`${Number(o.price).toFixed(2)} $`} + Jusqu'au {o.valid_to||"—"}
+
)}
+ {offers.length===0&&
Les promotions apparaîtront ici après la première importation Flipp.
} +
+
PERSONNALISATION

Ton budget et ton foyer

MealPlanner · Mieux manger et économiser.
+} diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts new file mode 100755 index 0000000..6080add --- /dev/null +++ b/frontend/next-env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/frontend/next.config.ts b/frontend/next.config.ts new file mode 100755 index 0000000..974c5de --- /dev/null +++ b/frontend/next.config.ts @@ -0,0 +1,12 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + async rewrites() { + return [{ + source: "/api/:path*", + destination: "http://api:8000/api/:path*" + }]; + } +}; + +export default nextConfig; diff --git a/frontend/package.json b/frontend/package.json new file mode 100755 index 0000000..6b187de --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,21 @@ +{ + "name": "mealplanner-frontend", + "version": "0.2.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start" + }, + "dependencies": { + "next": "15.2.4", + "react": "19.0.0", + "react-dom": "19.0.0" + }, + "devDependencies": { + "@types/node": "22.13.10", + "@types/react": "19.0.10", + "@types/react-dom": "19.0.4", + "typescript": "5.8.2" + } +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100755 index 0000000..472876b --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [{ "name": "next" }] + }, + "include": ["next-env.d.ts", ".next/types/**/*.ts", "**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"] +} diff --git a/importer/Dockerfile b/importer/Dockerfile new file mode 100755 index 0000000..1d945d7 --- /dev/null +++ b/importer/Dockerfile @@ -0,0 +1,8 @@ +FROM python:3.12-slim +ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 TZ=America/Toronto +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY importer.py . +EXPOSE 8001 +CMD ["uvicorn", "importer:app", "--host", "0.0.0.0", "--port", "8001"] diff --git a/importer/importer.py b/importer/importer.py new file mode 100755 index 0000000..c6b0b59 --- /dev/null +++ b/importer/importer.py @@ -0,0 +1,200 @@ +import os, re, json, random, hashlib, logging, threading +from datetime import datetime, date +from decimal import Decimal, InvalidOperation +from zoneinfo import ZoneInfo +import psycopg +from psycopg.rows import dict_row +import requests +from fastapi import FastAPI, BackgroundTasks +from apscheduler.schedulers.background import BackgroundScheduler + +logging.basicConfig(level=os.getenv('LOG_LEVEL', 'INFO')) +log = logging.getLogger('mealplanner-importer') +DB = dict(host=os.getenv('DB_HOST','db'), port=int(os.getenv('DB_PORT','5432')), dbname=os.getenv('DB_NAME','mealplanner'), user=os.getenv('DB_USER','mealplanner'), password=os.environ['DB_PASSWORD'], connect_timeout=8) +POSTAL_CODE = re.sub(r'\s+','',os.getenv('FLIPP_POSTAL_CODE','J7R6S4')).upper() +LOCALE = os.getenv('FLIPP_LOCALE','fr') +FLIPP_FLYERS_URL = 'https://flyers-ng.flippback.com/api/flipp/data' +FLIPP_ITEMS_URL = 'https://flyers-ng.flippback.com/api/flipp/flyers/{}/flyer_items' +FLIPP_HOME = 'https://www.flipp.com/' +app = FastAPI(title='MealPlanner Flipp Importer') +scheduler = BackgroundScheduler(timezone=ZoneInfo('America/Toronto')) +session = requests.Session() +session.headers.update({'User-Agent':'Mozilla/5.0 (compatible; MealPlanner-personal/1.0)','Accept':'application/json'}) +run_lock = threading.Lock() + +def db(): + return psycopg.connect(**DB, row_factory=dict_row) + +def ensure_tables(): + # Tables Flipp distinctes : les anciennes tables PDF et les autres données restent intactes. + with db() as conn: + conn.execute("CREATE TABLE IF NOT EXISTS flipp_flyers (id SERIAL PRIMARY KEY, store_id INTEGER NOT NULL REFERENCES stores(id) ON DELETE CASCADE, external_flyer_id BIGINT NOT NULL, title TEXT NOT NULL, merchant TEXT NOT NULL, postal_code TEXT NOT NULL, valid_from DATE, valid_to DATE, thumbnail_url TEXT, categories JSONB, imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE(store_id, external_flyer_id))") + conn.execute("CREATE TABLE IF NOT EXISTS flipp_offers (id SERIAL PRIMARY KEY, flyer_id INTEGER NOT NULL REFERENCES flipp_flyers(id) ON DELETE CASCADE, store_id INTEGER NOT NULL REFERENCES stores(id) ON DELETE CASCADE, external_item_id TEXT, item_name TEXT NOT NULL, brand TEXT, price NUMERIC(10,2) NOT NULL, price_text TEXT, valid_from DATE, valid_to DATE, image_url TEXT, offer_key TEXT NOT NULL UNIQUE, imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW())") + conn.execute("ALTER TABLE flipp_offers ADD COLUMN IF NOT EXISTS price_unit TEXT NOT NULL DEFAULT 'unknown'") + conn.execute("ALTER TABLE flipp_offers ADD COLUMN IF NOT EXISTS product_cut TEXT") + conn.execute('CREATE INDEX IF NOT EXISTS flipp_offers_valid_to_idx ON flipp_offers(valid_to)') + conn.execute('CREATE INDEX IF NOT EXISTS flipp_flyers_valid_to_idx ON flipp_flyers(valid_to)') + conn.execute("CREATE TABLE IF NOT EXISTS flyer_sources (id SERIAL PRIMARY KEY, store_id INTEGER NOT NULL REFERENCES stores(id) ON DELETE CASCADE, source_url TEXT NOT NULL, active BOOLEAN NOT NULL DEFAULT TRUE, last_checked_at TIMESTAMPTZ, last_success_at TIMESTAMPTZ, last_error TEXT, UNIQUE(store_id, source_url))") + conn.execute("CREATE TABLE IF NOT EXISTS flyer_import_runs (id SERIAL PRIMARY KEY, store_id INTEGER REFERENCES stores(id) ON DELETE SET NULL, source_url TEXT NOT NULL, started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), finished_at TIMESTAMPTZ, status TEXT NOT NULL DEFAULT 'running', message TEXT)") + +def sid(): + return ''.join(str(random.randint(0,9)) for _ in range(16)) + +def parsedate(value): + if not value: return None + try: return datetime.fromisoformat(str(value).replace('Z','+00:00')).date() + except (ValueError,TypeError): + try: return date.fromisoformat(str(value)[:10]) + except (ValueError,TypeError): return None + +def parsedprice(value): + if value is None or str(value).strip()=='': return None + try: + if isinstance(value,(int,float,Decimal)): return Decimal(str(value)) + nums=re.findall(r'\d+(?:[.,]\d{1,2})?',str(value)) + return Decimal(nums[-1].replace(',','.')) if nums else None + except InvalidOperation: return None + + +def pricebasis(item, price_text): + """Interprète les unités explicites de Flipp; ne devine pas le prix au poids.""" + candidates = [ + item.get('unit'), item.get('price_unit'), item.get('measurement'), + item.get('size'), item.get('description'), item.get('display_name'), + price_text + ] + raw = ' '.join(str(x) for x in candidates if x is not None).lower() + raw = raw.replace(' ', '').replace(',', '.') + if any(x in raw for x in ['/lb', 'perlb', 'lb.', '/livre', 'parlivre', 'pound', 'lb']): + return 'lb' + if any(x in raw for x in ['/kg', 'perkg', '/kilo', 'parkg', 'kg']): + return 'kg' + if any(x in raw for x in ['/100g', 'per100g', '/100 g']): + return '100g' + if any(x in raw for x in ['/each', 'each', 'chacun', '/unité', '/unite']): + return 'each' + # Si Flipp n'indique pas l'unité, on conserve « unité » et on ne calcule + # jamais un prix au poids supposé. + return 'unknown' + +def product_cut(name): + n = str(name or '').lower() + if any(x in n for x in ['thigh', 'cuisse', 'haut de cuisse', 'pilons', 'drumstick']): + return 'cuisse' + if any(x in n for x in ['breast', 'poitrine', 'suprême', 'supreme']): + return 'poitrine' + if any(x in n for x in ['whole chicken', 'poulet entier']): + return 'entier' + return None + +def getstores(): + with db() as conn: + rows=conn.execute("SELECT id,name FROM stores WHERE name IN ('IGA','Metro') AND enabled=TRUE").fetchall() + return {x['name']:x['id'] for x in rows} + +def sourceid(store_id): + with db() as conn: + row=conn.execute('SELECT id FROM flyer_sources WHERE store_id=%s AND source_url=%s',(store_id,FLIPP_HOME)).fetchone() + if row: return row['id'] + return conn.execute('INSERT INTO flyer_sources(store_id,source_url) VALUES(%s,%s) RETURNING id',(store_id,FLIPP_HOME)).fetchone()['id'] + +def fetchflyers(): + session_id=sid() + r=session.get(FLIPP_FLYERS_URL,params={'locale':LOCALE,'postal_code':POSTAL_CODE,'sid':session_id},timeout=30) + r.raise_for_status() + data=r.json(); flyers=data.get('flyers',[]) if isinstance(data,dict) else [] + if not isinstance(flyers,list): raise RuntimeError('Réponse Flipp invalide.') + return session_id,flyers + +def storefor(flyer): + merchant=str(flyer.get('merchant','')).strip().casefold() + return 'IGA' if merchant=='iga' else ('Metro' if merchant=='metro' else None) + +def isgrocery(flyer): + cats=flyer.get('categories',[]) + if isinstance(cats,str): cats=[x.strip() for x in cats.split(',')] + return 'Groceries' in cats + +def saveflyer(store_id,flyer): + cats=flyer.get('categories',[]) + if isinstance(cats,str): cats=[x.strip() for x in cats.split(',')] + title=str(flyer.get('name') or f"Circulaire {flyer.get('merchant','')}").strip() + thumb=flyer.get('thumbnail_url') or flyer.get('premium_thumbnail_url') or flyer.get('mobile_thumbnail_url') + with db() as conn: + conn.execute("INSERT INTO flipp_flyers (store_id,external_flyer_id,title,merchant,postal_code,valid_from,valid_to,thumbnail_url,categories) VALUES(%s,%s,%s,%s,%s,%s,%s,%s,%s::jsonb) ON CONFLICT(store_id,external_flyer_id) DO UPDATE SET title=EXCLUDED.title,valid_from=EXCLUDED.valid_from,valid_to=EXCLUDED.valid_to,thumbnail_url=EXCLUDED.thumbnail_url,categories=EXCLUDED.categories",(store_id,int(flyer['id']),title,flyer.get('merchant'),POSTAL_CODE,parsedate(flyer.get('valid_from')),parsedate(flyer.get('valid_to')),thumb,json.dumps(cats,ensure_ascii=False))) + return conn.execute('SELECT id FROM flipp_flyers WHERE store_id=%s AND external_flyer_id=%s',(store_id,int(flyer['id']))).fetchone()['id'] + +def saveitems(store_id,flyer_id,flyer,items): + kept=skipped=0 + with db() as conn: + for item in items: + if not isinstance(item,dict): skipped+=1; continue + name=' '.join(str(item.get('name') or '').split()).strip() + price_text=str(item.get('price') or '').strip(); price=parsedprice(item.get('price')) + if not name or not price_text or price is None: skipped+=1; continue + itemid=item.get('id') or item.get('item_id') + start=parsedate(item.get('valid_from')) or parsedate(flyer.get('valid_from')) + end=parsedate(item.get('valid_to')) or parsedate(flyer.get('valid_to')) + brand=item.get('brand') or item.get('manufacturer') + unit=pricebasis(item, price_text) + cut=product_cut(name) + img=item.get('clean_image_url') or item.get('image_url') or item.get('thumbnail_url') or item.get('image') + if not isinstance(img,str): img=None + identity='|'.join([str(flyer.get('id')),str(itemid or ''),name.casefold(),price_text,str(start or '')]) + key=hashlib.sha256(identity.encode('utf-8')).hexdigest() + conn.execute("INSERT INTO flipp_offers (flyer_id,store_id,external_item_id,item_name,brand,price,price_text,valid_from,valid_to,image_url,offer_key,price_unit,product_cut) VALUES(%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) ON CONFLICT(offer_key) DO UPDATE SET price=EXCLUDED.price,price_text=EXCLUDED.price_text,item_name=EXCLUDED.item_name,brand=EXCLUDED.brand,valid_from=EXCLUDED.valid_from,valid_to=EXCLUDED.valid_to,image_url=EXCLUDED.image_url,flyer_id=EXCLUDED.flyer_id,price_unit=EXCLUDED.price_unit,product_cut=EXCLUDED.product_cut",(flyer_id,store_id,str(itemid) if itemid is not None else None,name,brand,price,price_text,start,end,img,key,unit,cut)) + kept+=1 + return kept,skipped + +def run_imports(): + if not run_lock.acquire(blocking=False): log.info('Import Flipp déjà en cours; exécution ignorée.'); return + try: + ensure_tables(); stores=getstores() + if not stores: raise RuntimeError('Magasins IGA et Metro absents ou désactivés.') + session_id,allflyers=fetchflyers(); selected={'IGA':[],'Metro':[]} + for f in allflyers: + target=storefor(f) + if target and isgrocery(f) and f.get('id'): selected[target].append(f) + for name in ('IGA','Metro'): + if name not in stores: log.warning('%s absent ou désactivé.',name); continue + store_id=stores[name]; src=sourceid(store_id) + with db() as conn: + run_id=conn.execute("INSERT INTO flyer_import_runs(store_id,source_url,status,message) VALUES(%s,%s,'running','Importation Flipp en cours') RETURNING id",(store_id,FLIPP_HOME)).fetchone()['id'] + conn.execute('UPDATE flyer_sources SET last_checked_at=NOW(),last_error=NULL WHERE id=%s',(src,)) + total=skipped=processed=0; errors=[] + for flyer in selected[name]: + try: + record=saveflyer(store_id,flyer) + r=session.get(FLIPP_ITEMS_URL.format(flyer['id']),params={'locale':LOCALE,'sid':session_id},timeout=30) + r.raise_for_status(); items=r.json() + if not isinstance(items,list): raise RuntimeError('Format articles inattendu.') + good,bad=saveitems(store_id,record,flyer,items); total+=good; skipped+=bad; processed+=1 + log.info('%s — %s (ID %s): %s offres conservées, %s ignorées.',name,flyer.get('name'),flyer.get('id'),good,bad) + except Exception as exc: + errors.append(f"{flyer.get('name') or flyer.get('id')}: {exc}"); log.exception('Erreur circulaire %s pour %s',flyer.get('id'),name) + msg=f'{processed}/{len(selected[name])} circulaires traitées; {total} offres conservées; {skipped} entrées sans nom/prix ignorées.' + if errors: msg+=' Erreurs: '+' | '.join(errors[:3]) + with db() as conn: + conn.execute('UPDATE flyer_sources SET last_success_at=NOW(),last_error=%s WHERE id=%s',('; '.join(errors)[:1000] if errors else None,src)) + conn.execute('UPDATE flyer_import_runs SET finished_at=NOW(),status=%s,message=%s WHERE id=%s',('imported' if processed else 'error',msg,run_id)) + log.info('%s — %s',name,msg) + except Exception: log.exception('Importation Flipp échouée') + finally: run_lock.release() + +@app.on_event('startup') +def startup(): + ensure_tables() + scheduler.add_job(run_imports,'cron',hour=6,minute=0,id='daily-flipp',replace_existing=True) + scheduler.start() + if os.getenv('RUN_ON_START','true').lower()=='true': scheduler.add_job(run_imports,'date',run_date=datetime.now(ZoneInfo('America/Toronto')),id='initial-flipp',replace_existing=True) + +@app.on_event('shutdown') +def shutdown(): scheduler.shutdown(wait=False) + +@app.get('/health') +def health(): return {'status':'ok','service':'flipp-importer','postal_code':POSTAL_CODE} + +@app.post('/run') +def run_now(background_tasks:BackgroundTasks): + background_tasks.add_task(run_imports) + return {'status':'queued','message':f'Importation régionale IGA et Metro pour {POSTAL_CODE} lancée.'} diff --git a/importer/requirements.txt b/importer/requirements.txt new file mode 100755 index 0000000..9f32257 --- /dev/null +++ b/importer/requirements.txt @@ -0,0 +1,5 @@ +fastapi==0.115.12 +uvicorn[standard]==0.34.0 +psycopg[binary]==3.2.6 +requests==2.32.3 +APScheduler==3.11.0