diff --git a/AGENTS.md b/AGENTS.md index db61aa1..10e4e8e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,8 +22,9 @@ Flask web app for tracking household meal attendance. Users register into househ | `static/style.css` | All CSS (no framework) | | `tests/` | Pytest suite (47 tests): `conftest.py` sets up in-memory DB per test | | `Dockerfile` | Python 3.13-alpine. **Must list every `.py` file in the COPY line.** | -| `docker-compose.yml` | Single service `meal-tracker` on external `caddy` network, volume `meal-data` at `/data` | +| `docker-compose.yml` | Two services: `meal-tracker` (web) and `meal-cron` (sends reminders). Both on external `caddy` network, shared `meal-data` volume at `/data`. | | `watch-for-updates.sh` | Polls git remote, rebuilds on change. Uses `docker compose` with env vars from `.env`. | +| `send_reminders_cron.py` | Standalone script run every 60s by the `meal-cron` container. Checks per-household local time against 10:00/16:00 triggers. | | `doc/index.md` | Full project documentation | | `README.md` | Symlink → `doc/index.md` | diff --git a/Dockerfile b/Dockerfile index 7f6b846..1d0e43f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,7 @@ COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt # Copy application -COPY app.py models.py auth.py meals.py i18n.py ntfy.py ./ +COPY app.py models.py auth.py meals.py i18n.py ntfy.py send_reminders_cron.py ./ COPY templates/ templates/ COPY static/ static/ diff --git a/docker-compose.yml b/docker-compose.yml index ef356f6..7122402 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,6 +14,20 @@ services: networks: - caddy + meal-cron: + build: + context: . + dockerfile: Dockerfile + command: ["sh", "-c", "while true; do python3 /app/send_reminders_cron.py; sleep 60; done"] + volumes: + - meal-data:/data + environment: + - SECRET_KEY=${SECRET_KEY:-change-me-in-production} + - APP_BASE_URL=${APP_BASE_URL:-http://meal-tracker:5000} + restart: unless-stopped + networks: + - caddy + volumes: meal-data: diff --git a/i18n.py b/i18n.py index 009727a..3f0b4ac 100644 --- a/i18n.py +++ b/i18n.py @@ -366,6 +366,28 @@ TRANSLATIONS: dict[str, dict[str, str]] = { "fr": "Échec de l'envoi du test. Vérifiez votre URL ntfy et votre jeton.", "de": "Testbenachrichtigung fehlgeschlagen. Überprüfen Sie Ihre ntfy-URL und Ihr Token.", }, + + # ── Timezone ── + "Household Timezone": { + "fr": "Fuseau horaire du foyer", + "de": "Zeitzone des Haushalts", + }, + "Used to send reminders at 10:00 (lunch) and 16:00 (dinner) local time.": { + "fr": "Utilisé pour envoyer les rappels à 10h00 (déjeuner) et 16h00 (dîner) heure locale.", + "de": "Wird verwendet, um Erinnerungen um 10:00 (Mittagessen) und 16:00 (Abendessen) Ortszeit zu senden.", + }, + "Only admins can change the timezone.": { + "fr": "Seuls les administrateurs peuvent changer le fuseau horaire.", + "de": "Nur Administratoren können die Zeitzone ändern.", + }, + "Please select a timezone.": { + "fr": "Veuillez sélectionner un fuseau horaire.", + "de": "Bitte wählen Sie eine Zeitzone aus.", + }, + "Timezone updated.": { + "fr": "Fuseau horaire mis à jour.", + "de": "Zeitzone aktualisiert.", + }, } diff --git a/meals.py b/meals.py index 7dfdfa6..0b69c51 100644 --- a/meals.py +++ b/meals.py @@ -25,6 +25,8 @@ from models import ( set_user_ntfy, get_user_ntfy, get_user_by_callback_token, + set_household_timezone, + get_household_by_id, ) from i18n import t from ntfy import send_test_notification @@ -73,6 +75,7 @@ def dashboard(): data = get_dashboard_data(today, household_id) ntfy = get_user_ntfy(session["user_id"]) + household = get_household_by_id(household_id) return render_template( "dashboard.html", dashboard=data, @@ -83,6 +86,7 @@ def dashboard(): current_user_id=session["user_id"], is_admin=session["is_admin"], ntfy=ntfy, + household=household, ) @@ -132,6 +136,7 @@ def history(): data = get_dashboard_data(target_date, session["household_id"]) ntfy = get_user_ntfy(session["user_id"]) + household = get_household_by_id(session["household_id"]) return render_template( "dashboard.html", dashboard=data, @@ -143,6 +148,7 @@ def history(): is_admin=session["is_admin"], history_mode=True, ntfy=ntfy, + household=household, ) @@ -221,6 +227,24 @@ def admin_delete_household(): return redirect(url_for("auth.login")) +@meals_bp.route("/admin/timezone", methods=["POST"]) +@login_required +def admin_set_timezone(): + """Admin sets the household timezone.""" + if not session["is_admin"]: + flash(t("Only admins can change the timezone."), "error") + return redirect(url_for("meals.dashboard")) + + timezone = request.form.get("timezone", "").strip() + if not timezone: + flash(t("Please select a timezone."), "error") + return redirect(url_for("meals.dashboard")) + + set_household_timezone(session["household_id"], timezone) + flash(t("Timezone updated."), "success") + return redirect(url_for("meals.dashboard")) + + # ── Ntfy integration ───────────────────────────────────────────────────────── @meals_bp.route("/ntfy-settings", methods=["POST"]) diff --git a/models.py b/models.py index e69fbbc..aaaf838 100644 --- a/models.py +++ b/models.py @@ -76,6 +76,15 @@ def init_db() -> None: if "callback_token" not in cols: db.execute("ALTER TABLE users ADD COLUMN callback_token TEXT DEFAULT ''") + # Migration: add timezone and reminder tracking to households + hh_cols = [r["name"] for r in db.execute("PRAGMA table_info(households)").fetchall()] + if "timezone" not in hh_cols: + db.execute("ALTER TABLE households ADD COLUMN timezone TEXT DEFAULT 'UTC'") + if "last_lunch_reminder" not in hh_cols: + db.execute("ALTER TABLE households ADD COLUMN last_lunch_reminder TEXT DEFAULT ''") + if "last_dinner_reminder" not in hh_cols: + db.execute("ALTER TABLE households ADD COLUMN last_dinner_reminder TEXT DEFAULT ''") + # ── Households ──────────────────────────────────────────────────────────────── @@ -130,6 +139,21 @@ def delete_household(household_id: int) -> None: db.execute("DELETE FROM households WHERE id = ?", (household_id,)) +def set_household_timezone(household_id: int, timezone: str) -> None: + """Update the timezone for a household.""" + with get_db() as db: + db.execute("UPDATE households SET timezone = ? WHERE id = ?", + (timezone, household_id)) + + +def update_reminder_sent(household_id: int, meal_type: str, date_str: str) -> None: + """Mark a reminder as sent for the given meal and date.""" + col = "last_lunch_reminder" if meal_type == "lunch" else "last_dinner_reminder" + with get_db() as db: + db.execute(f"UPDATE households SET {col} = ? WHERE id = ?", + (date_str, household_id)) + + # ── Users ───────────────────────────────────────────────────────────────────── def get_user_by_id(user_id: int) -> dict | None: diff --git a/send_reminders_cron.py b/send_reminders_cron.py new file mode 100644 index 0000000..b159b96 --- /dev/null +++ b/send_reminders_cron.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +"""Run by the cron container every ~60s to send meal reminders. + +For each household, checks the current local time. If it's 10:00–10:04, +sends lunch reminders; if 16:00–16:04, sends dinner reminders. A +per-household sent-date guard prevents duplicate sends. +""" + +import os +import sys +from datetime import datetime +from zoneinfo import ZoneInfo, available_timezones + +# Ensure we can import from the app directory +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from models import get_all_households, get_users_with_ntfy, update_reminder_sent, init_db +from ntfy import send_meal_reminder + +APP_BASE_URL = os.environ.get("APP_BASE_URL", "http://meal-tracker:5000") + +# Reminder schedule (local time) +REMINDERS = { + "lunch": 10, # 10:00 + "dinner": 16, # 16:00 +} + + +def _resolve_timezone(tz_str: str) -> ZoneInfo: + """Return a ZoneInfo for *tz_str*, falling back to UTC on error.""" + try: + return ZoneInfo(tz_str) + except Exception: + return ZoneInfo("UTC") + + +def main() -> None: + init_db() # ensure tables exist (first run before web container) + now_utc = datetime.now(ZoneInfo("UTC")) + sent = 0 + + for hh in get_all_households(): + tz = _resolve_timezone(hh.get("timezone", "UTC")) + local_now = now_utc.astimezone(tz) + today_str = local_now.strftime("%Y-%m-%d") + hour = local_now.hour + minute = local_now.minute + + for meal_type, trigger_hour in REMINDERS.items(): + if hour != trigger_hour or minute >= 5: + continue + + last_col = f"last_{meal_type}_reminder" + if hh.get(last_col) == today_str: + continue # already sent today + + users = get_users_with_ntfy(hh["id"]) + if not users: + continue + + for user in users: + ok = send_meal_reminder( + ntfy_url=user["ntfy_url"], + ntfy_token=user["ntfy_token"], + callback_token=user["callback_token"], + meal_type=meal_type, + for_date=local_now.date(), + app_base_url=APP_BASE_URL, + ) + if ok: + print(f"[{local_now:%Y-%m-%d %H:%M}] sent {meal_type} → {user['username']}") + sent += 1 + + update_reminder_sent(hh["id"], meal_type, today_str) + + if sent: + print(f"Sent {sent} reminder(s)") + + +if __name__ == "__main__": + main() diff --git a/templates/dashboard.html b/templates/dashboard.html index 26959be..d6afcc6 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -113,6 +113,24 @@ + {% if is_admin %} +
+

🕐 {{ t('Household Timezone') }}

+

{{ t('Used to send reminders at 10:00 (lunch) and 16:00 (dinner) local time.') }}

+ + +
+ {% endif %} + {% if is_admin %}