commit 1074a914870bc5da9d956650fc644b70e01714b0 Author: Florian Heinz Date: Mon Apr 13 08:32:28 2026 +0200 first commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c14dadf --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python +.env +.venv/ +venv/ +dist/ +build/ +.git/ +data/ +instance/ +node_modules/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..769af62 --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +SECRET_KEY=change-me +PORT=8000 +DATA_DIR=./data +DATABASE_PATH=./data/putzliga.db +UPLOAD_FOLDER=./data/uploads +APP_BASE_URL=http://localhost:8000 +APP_TIMEZONE=Europe/Berlin +VAPID_PUBLIC_KEY= +VAPID_PRIVATE_KEY= +VAPID_CLAIMS_SUBJECT=mailto:admin@example.com +GUNICORN_WORKERS=2 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..825e7f4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +.venv/ +venv/ +__pycache__/ +*.pyc +*.pyo +*.pyd +.pytest_cache/ +.mypy_cache/ +.DS_Store + +data/ +instance/ +*.sqlite +*.sqlite3 + +.env +.env.local +.env.push.local +.cloudron-push.env diff --git a/CloudronManifest.json b/CloudronManifest.json new file mode 100644 index 0000000..908b3d1 --- /dev/null +++ b/CloudronManifest.json @@ -0,0 +1,24 @@ +{ + "id": "io.putzliga.app", + "title": "Putzliga", + "author": "hnzio ", + "description": "Spielerische Haushalts-App mit Aufgaben, Punkten, Monats-Highscore, Kalender und PWA-Push.", + "tagline": "Haushalt mit Liga-Gefühl", + "version": "1.0.0", + "manifestVersion": 2, + "healthCheckPath": "/healthz", + "httpPort": 8000, + "addons": { + "localstorage": {} + }, + "contactEmail": "admin@example.com", + "icon": "file://icon.png", + "tags": [ + "household", + "tasks", + "pwa", + "productivity", + "flask" + ], + "memoryLimit": 268435456 +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ce33f49 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.13-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . +RUN chmod +x /app/start.sh + +EXPOSE 8000 + +CMD ["./start.sh"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..c039b8a --- /dev/null +++ b/README.md @@ -0,0 +1,285 @@ +# Putzliga + +Putzliga ist eine moderne, leichte Haushaltsaufgaben-Web-App mit spielerischem Charakter. Version 1 setzt auf Flask, SQLite, Jinja-Templates, responsives CSS, minimales JavaScript und eine saubere PWA-/Web-Push-Basis. Die App ist für mehrere Nutzer ausgelegt, läuft lokal sehr unkompliziert und ist so vorbereitet, dass sie auf Cloudron direkt als Container-App betrieben werden kann. + +## Features + +- Mehrere Nutzer mit Login, Registrierung und Profil-/Avatar-Einstellungen +- Trennung zwischen `TaskTemplate` und `TaskInstance` +- Aufgaben anlegen, bearbeiten, zuweisen und erledigen +- Wiederholungen für einmalig, alle X Tage, alle X Wochen und alle X Monate +- Saubere Erledigungslogik für fremd zugewiesene Aufgaben mit Auswahl, wer wirklich erledigt hat +- Statuslogik für offen, bald fällig, überfällig und erledigt +- `Meine Aufgaben`, `Alle Aufgaben`, `Aufgabe erstellen`, `Kalender/Liste`, `Highscoreboard`, `Optionen` +- Monats-Highscore mit Badge-Boni und Balkendarstellung +- Monatsarchiv über `MonthlyScoreSnapshot` +- PWA mit `manifest.json`, Service Worker, App-Icons und iOS-freundlicher Installationsbasis +- Echte Web-Push-Architektur mit gespeicherten `PushSubscription`s +- CLI-Kommandos für Archivierung und serverseitig triggerbare Benachrichtigungen +- Cloudron-/Container-tauglicher Start mit `start.sh`, `Dockerfile` und `CloudronManifest.json` + +## Projektstruktur + +```text +app/ + routes/ + services/ + static/ + css/ + fonts/ + icons/ + images/ + js/ + manifest.json + service-worker.js + templates/ + auth/ + partials/ + scoreboard/ + settings/ + tasks/ +app.py +config.py +seed.py +start.sh +Dockerfile +CloudronManifest.json +requirements.txt +.env.example +scripts/ +data/ +``` + +## Lokale Daten vs. Cloudron-Inhalte + +Die App ist jetzt so vorbereitet, dass lokale Entwicklungsdaten nicht versehentlich mit nach Cloudron wandern: + +- `data/` ist in `.gitignore` ausgeschlossen und wird nicht committed +- `data/` ist zusätzlich in `.dockerignore` ausgeschlossen und landet nicht im Docker-Build-Kontext +- Uploads liegen standardmäßig ebenfalls unter `data/uploads` und bleiben damit lokal bzw. im persistenten Cloudron-Storage +- Beim ersten Cloudron-Start wird keine lokale Entwicklungsdatenbank ins Image kopiert + +Damit kannst du lokal mit Seed-Daten entwickeln und online unabhängig davon echte Inhalte pflegen. + +## Lokales Setup + +### 1. Abhängigkeiten installieren + +```bash +python3 -m venv .venv +. .venv/bin/activate +pip install -r requirements.txt +``` + +### 2. Umgebungsvariablen setzen + +```bash +cp .env.example .env +``` + +Wichtige Variablen: + +- `SECRET_KEY`: Flask Secret Key +- `DATA_DIR`: Persistentes Datenverzeichnis +- `DATABASE_PATH`: SQLite-Datei +- `UPLOAD_FOLDER`: Upload-Verzeichnis für Avatare +- `APP_BASE_URL`: Vollständige Basis-URL der App, wichtig für Push-Links +- `APP_TIMEZONE`: Standardmäßig `Europe/Berlin` +- `VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY`: Web-Push-Schlüssel +- `VAPID_CLAIMS_SUBJECT`: Kontaktadresse für VAPID + +### 3. App-Icons erzeugen + +Die Raster-Icons liegen als generierte Dateien im Projekt. Falls du sie neu erzeugen willst: + +```bash +python scripts/generate_assets.py +``` + +### 4. Datenbank und Seed-Daten anlegen + +```bash +flask --app app.py init-db +python seed.py +``` + +Demo-Logins: + +- `anna@putzliga.local` / `putzliga123` +- `ben@putzliga.local` / `putzliga123` + +### 5. Entwicklungsserver starten + +```bash +flask --app app.py run --debug +``` + +oder produktionsnah: + +```bash +./start.sh +``` + +## Seed-Daten + +Die Seed-Datei erzeugt: + +- 2 Beispielnutzer +- wiederkehrende und einmalige Vorlagen +- offene, bald fällige, überfällige und erledigte Aufgaben +- Punkte im aktuellen Monat +- erledigte Aufgaben aus dem Vormonat, damit das Archiv direkt sichtbar ist +- Standard-Badges für Frühstarter, Serien und Monatsmenge + +## Datenmodell + +Umgesetzt sind die Kernmodelle: + +- `User` +- `TaskTemplate` +- `TaskInstance` +- `MonthlyScoreSnapshot` +- `PushSubscription` +- `NotificationLog` + +Zusätzlich für Version 1: + +- `BadgeDefinition` für pflegbare Badge-Regeln in den Optionen + +Wichtig: `TaskTemplate` beschreibt die wiederverwendbare Vorlage, `TaskInstance` die konkrete Aufgabe mit Fälligkeit, Status und tatsächlicher Erledigung. + +## Monatsarchivierung + +Putzliga speichert keine monatlichen Punktetotale als Live-Zähler. Stattdessen wird der Monatsstand aus erledigten `TaskInstance`s berechnet. Dadurch startet jeder neue Monat automatisch bei 0, weil nur Aufgaben des aktuellen Monats zählen. + +Die Archivierung funktioniert so: + +- Vor Requests wird geprüft, ob bis zum Vormonat Archive fehlen +- Fehlende Monate werden als `MonthlyScoreSnapshot` erzeugt +- Archivwerte enthalten erledigte Aufgaben und Badge-Boni des jeweiligen Monats +- Frühere Monate bleiben dauerhaft sichtbar + +Zusätzlicher CLI-Trigger: + +```bash +flask --app app.py archive-months +``` + +## Push-Benachrichtigungen + +Putzliga nutzt echte Web-Push-Benachrichtigungen mit Service Worker und VAPID. + +### Architektur + +- Browser registriert Service Worker +- Subscription wird pro Nutzer in `PushSubscription` gespeichert +- Server versendet über `pywebpush` +- Versand ist getrennt in Prüf- und Ausführungslogik +- Logs werden in `NotificationLog` dedupliziert + +### Verfügbare Trigger + +```bash +flask --app app.py notify-due +flask --app app.py notify-monthly-winner +``` + +`notify-due`: + +- prüft offene Aufgaben, die heute oder morgen fällig sind +- berücksichtigt die Nutzeroption `notification_task_due_enabled` + +`notify-monthly-winner`: + +- sendet am 1. des Monats ab 09:00 Uhr +- verweist auf das Scoreboard/Archiv des letzten Monats +- berücksichtigt `notification_monthly_winner_enabled` + +### Produktiver Betrieb + +Auf Cloudron oder einem anderen Server solltest du diese Kommandos regelmäßig per Cronjob oder Task ausführen, zum Beispiel: + +```bash +flask --app /app/app.py notify-due +flask --app /app/app.py notify-monthly-winner +``` + +### iPhone-/iOS-Hinweis + +Web-Push auf iPhone/iPad funktioniert nur in neueren iOS-/iPadOS-Versionen, wenn die Web-App über Safari zum Home-Bildschirm hinzugefügt wurde. Innerhalb eines normalen Safari-Tabs stehen Push-Berechtigungen nicht zuverlässig zur Verfügung. + +## PWA + +Enthalten sind: + +- `app/static/manifest.json` +- `app/static/service-worker.js` +- `app/static/images/pwa-icon-192.png` +- `app/static/images/pwa-icon-512.png` +- `app/static/images/apple-touch-icon.png` +- `app/static/images/pwa-badge.png` + +Der Service Worker cached die App-Shell und Assets pragmatisch für eine stabile Basis. Für Version 1 ist das bewusst schlank gehalten. + +## Branding und Assets aus `heinz.marketing` + +Aus `../heinz.marketing` wurden bewusst nur verwertbare Grundlagen übernommen: + +- `Inter` und `Space Grotesk` aus `css/fonts/` +- ausgewählte lokale SVG-Icons aus `css/fontawesome-pro-plus-7.0.0-web/svgs-full/chisel-regular/` + +Diese Assets wurden nicht unverändert als fertiges Branding übernommen. Putzliga nutzt darauf aufbauend: + +- eine eigene helle, iOS-nahe Farbwelt +- ein neues App-Logo (`app/static/images/logo.svg`) +- ein eigenes Favicon (`app/static/images/favicon.svg`) +- eigene generierte PWA-Raster-Icons (`scripts/generate_assets.py`) + +## Cloudron + +Cloudron-Dateien im Projekt: + +- `Dockerfile` +- `start.sh` +- `CloudronManifest.json` + +Die Manifest- und Docker-Struktur orientiert sich an der aktuellen Cloudron-Dokumentation für Docker-/Container-Apps: +https://docs.cloudron.io/docker/ + +### Wichtige Punkte für Cloudron + +- App hört auf `PORT` und standardmäßig auf `8000` +- `DATA_DIR` und `UPLOAD_FOLDER` sollten im persistenten Storage liegen +- SQLite-Datei liegt standardmäßig unter `/app/data/putzliga.db` +- `start.sh` initialisiert die DB und startet Gunicorn +- `APP_BASE_URL` kann auf Cloudron über `CLOUDRON_APP_ORIGIN` gesetzt oder daraus abgeleitet werden +- Lokale Testdaten aus `data/` werden weder committed noch in das Docker-Image gepackt + +### Beispielstart in Cloudron-/Container-Umgebungen + +```bash +./start.sh +``` + +## Hilfsskripte + +VAPID-Schlüssel generieren: + +```bash +python scripts/generate_vapid_keys.py +``` + +Der ausgegebene `VAPID_PRIVATE_KEY` ist bereits `.env`-freundlich mit escaped Newlines formatiert. `config.py` wandelt `\\n` beim Start automatisch in echte Zeilenumbrüche zurück. + +Icons neu generieren: + +```bash +python scripts/generate_assets.py +``` + +## Hinweise für spätere Erweiterungen + +- Maluslogik für verspätete Erledigungen kann an `compute_monthly_scores()` und `TaskInstance` ergänzt werden +- echte Admin-/Rollenrechte können ergänzt werden, aktuell dürfen bewusst alle Nutzer Aufgaben pflegen +- Scheduler kann auf Cloudron später als separater Task sauber ausgelagert werden +- Badge-Awards könnten in einer eigenen Tabelle historisiert werden, falls spätere Regeln rückwirkungsfrei versioniert werden sollen diff --git a/app.py b/app.py new file mode 100644 index 0000000..1cfe611 --- /dev/null +++ b/app.py @@ -0,0 +1,9 @@ +from app import create_app + + +app = create_app() + + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=app.config["PORT"], debug=True) + diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..cca59f1 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +from flask import Flask + +from config import Config + +from .cli import register_cli, seed_badges +from .extensions import csrf, db, login_manager +from .routes import auth, main, scoreboard, settings, tasks +from .routes.main import load_icon_svg +from .services.dates import MONTH_NAMES, local_now +from .services.monthly import archive_months_missing_up_to_previous + + +def create_app(config_class: type[Config] = Config) -> Flask: + app = Flask(__name__, static_folder="static", template_folder="templates") + app.config.from_object(config_class) + + app.config["DATA_DIR"].mkdir(parents=True, exist_ok=True) + app.config["UPLOAD_FOLDER"].mkdir(parents=True, exist_ok=True) + + db.init_app(app) + login_manager.init_app(app) + csrf.init_app(app) + + with app.app_context(): + db.create_all() + seed_badges() + + register_cli(app) + + app.register_blueprint(main.bp) + app.register_blueprint(auth.bp) + app.register_blueprint(tasks.bp) + app.register_blueprint(scoreboard.bp) + app.register_blueprint(settings.bp) + + app.jinja_env.globals["icon_svg"] = lambda name: load_icon_svg(name, app.static_folder) + + @app.before_request + def ensure_archives(): + archive_months_missing_up_to_previous() + + @app.context_processor + def inject_globals(): + return { + "app_name": app.config["APP_NAME"], + "nav_items": [ + ("tasks.my_tasks", "Meine Aufgaben", "house"), + ("tasks.all_tasks", "Alle", "list"), + ("tasks.create", "Neu", "plus"), + ("tasks.calendar_view", "Kalender", "calendar"), + ("scoreboard.index", "Highscore", "trophy"), + ("settings.index", "Optionen", "gear"), + ], + "icon_svg": lambda name: load_icon_svg(name, app.static_folder), + "now_local": local_now(), + } + + @app.template_filter("date_de") + def date_de(value): + return value.strftime("%d.%m.%Y") if value else "—" + + @app.template_filter("datetime_de") + def datetime_de(value): + return value.strftime("%d.%m.%Y, %H:%M") if value else "—" + + @app.template_filter("month_name") + def month_name(value): + return MONTH_NAMES[value] + + return app diff --git a/app/cli.py b/app/cli.py new file mode 100644 index 0000000..b6b4897 --- /dev/null +++ b/app/cli.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import click + +from .extensions import db +from .models import BadgeDefinition +from .services.monthly import archive_months_missing_up_to_previous +from .services.notifications import send_due_notifications, send_monthly_winner_notifications + + +DEFAULT_BADGES = [ + { + "key": "early_bird", + "name": "Frühstarter", + "description": "Erledige 3 Aufgaben vor ihrem Fälligkeitsdatum.", + "icon_name": "bell", + "trigger_type": "early_finisher_count", + "threshold": 3, + "bonus_points": 10, + }, + { + "key": "on_time_streak", + "name": "Sauberer Lauf", + "description": "Erledige Aufgaben an 3 Tagen in Folge.", + "icon_name": "check", + "trigger_type": "streak_days", + "threshold": 3, + "bonus_points": 15, + }, + { + "key": "task_sprinter", + "name": "Putz-Sprinter", + "description": "Schließe 8 Aufgaben in einem Monat ab.", + "icon_name": "trophy", + "trigger_type": "monthly_task_count", + "threshold": 8, + "bonus_points": 20, + }, +] + + +def seed_badges() -> None: + for payload in DEFAULT_BADGES: + badge = BadgeDefinition.query.filter_by(key=payload["key"]).first() + if not badge: + db.session.add(BadgeDefinition(**payload)) + db.session.commit() + + +def register_cli(app) -> None: + @app.cli.command("init-db") + def init_db_command(): + db.create_all() + seed_badges() + click.echo("Datenbank und Standard-Badges sind bereit.") + + @app.cli.command("archive-months") + def archive_months_command(): + archive_months_missing_up_to_previous() + click.echo("Monatsarchiv wurde geprüft.") + + @app.cli.command("notify-due") + def notify_due_command(): + result = send_due_notifications() + click.echo(f"Due-Push: sent={result.sent} skipped={result.skipped} failed={result.failed}") + + @app.cli.command("notify-monthly-winner") + def notify_monthly_winner_command(): + result = send_monthly_winner_notifications() + click.echo(f"Winner-Push: sent={result.sent} skipped={result.skipped} failed={result.failed}") + diff --git a/app/extensions.py b/app/extensions.py new file mode 100644 index 0000000..251c0c6 --- /dev/null +++ b/app/extensions.py @@ -0,0 +1,12 @@ +from flask_login import LoginManager +from flask_sqlalchemy import SQLAlchemy +from flask_wtf import CSRFProtect + + +db = SQLAlchemy() +login_manager = LoginManager() +login_manager.login_view = "auth.login" +login_manager.login_message = "Bitte melde dich an, um Putzliga zu nutzen." +login_manager.login_message_category = "info" +csrf = CSRFProtect() + diff --git a/app/forms.py b/app/forms.py new file mode 100644 index 0000000..396bef1 --- /dev/null +++ b/app/forms.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +from flask_wtf import FlaskForm +from flask_wtf.file import FileAllowed, FileField +from wtforms import ( + BooleanField, + DateField, + EmailField, + IntegerField, + PasswordField, + SelectField, + StringField, + SubmitField, + TextAreaField, +) +from wtforms.validators import DataRequired, Email, EqualTo, Length, NumberRange, Optional, ValidationError + +from .models import User + + +class LoginForm(FlaskForm): + email = EmailField("E-Mail", validators=[DataRequired(), Email(), Length(max=255)]) + password = PasswordField("Passwort", validators=[DataRequired(), Length(min=6, max=128)]) + remember_me = BooleanField("Angemeldet bleiben") + submit = SubmitField("Einloggen") + + +class RegisterForm(FlaskForm): + name = StringField("Name", validators=[DataRequired(), Length(min=2, max=120)]) + email = EmailField("E-Mail", validators=[DataRequired(), Email(), Length(max=255)]) + password = PasswordField("Passwort", validators=[DataRequired(), Length(min=6, max=128)]) + password_confirm = PasswordField( + "Passwort wiederholen", + validators=[DataRequired(), EqualTo("password", message="Die Passwörter stimmen nicht überein.")], + ) + submit = SubmitField("Konto erstellen") + + def validate_email(self, field) -> None: + if User.query.filter_by(email=field.data.lower().strip()).first(): + raise ValidationError("Diese E-Mail-Adresse ist bereits vergeben.") + + +class TaskForm(FlaskForm): + title = StringField("Titel", validators=[DataRequired(), Length(min=2, max=160)]) + description = TextAreaField("Beschreibung", validators=[Optional(), Length(max=2000)]) + default_points = IntegerField("Punkte", validators=[DataRequired(), NumberRange(min=1, max=500)], default=10) + assigned_user_id = SelectField("Zugewiesen an", coerce=int, validators=[DataRequired()]) + due_date = DateField("Fälligkeitsdatum", format="%Y-%m-%d", validators=[DataRequired()]) + recurrence_interval_value = IntegerField( + "Intervallwert", + validators=[Optional(), NumberRange(min=1, max=365)], + default=1, + ) + recurrence_interval_unit = SelectField( + "Wiederholung", + choices=[ + ("none", "Einmalig"), + ("days", "Alle X Tage"), + ("weeks", "Alle X Wochen"), + ("months", "Alle X Monate"), + ], + validators=[DataRequired()], + ) + active = BooleanField("Vorlage aktiv", default=True) + submit = SubmitField("Speichern") + + def validate(self, extra_validators=None): + valid = super().validate(extra_validators=extra_validators) + if not valid: + return False + if self.recurrence_interval_unit.data != "none" and not self.recurrence_interval_value.data: + self.recurrence_interval_value.errors.append("Bitte gib einen Intervallwert an.") + return False + return True + + +class SettingsProfileForm(FlaskForm): + name = StringField("Name", validators=[DataRequired(), Length(min=2, max=120)]) + email = EmailField("E-Mail", validators=[DataRequired(), Email(), Length(max=255)]) + password = PasswordField("Neues Passwort", validators=[Optional(), Length(min=6, max=128)]) + avatar = FileField( + "Avatar", + validators=[Optional(), FileAllowed(["png", "jpg", "jpeg", "gif", "webp", "svg"], "Bitte ein Bild hochladen.")], + ) + notification_task_due_enabled = BooleanField("Push bei bald fälligen Aufgaben") + notification_monthly_winner_enabled = BooleanField("Push zum Monatssieger") + submit = SubmitField("Einstellungen speichern") + + def __init__(self, original_email: str | None = None, *args, **kwargs): + super().__init__(*args, **kwargs) + self.original_email = original_email + + def validate_email(self, field) -> None: + value = field.data.lower().strip() + if value == (self.original_email or "").lower().strip(): + return + if User.query.filter_by(email=value).first(): + raise ValidationError("Diese E-Mail-Adresse wird bereits verwendet.") + diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..53bbd97 --- /dev/null +++ b/app/models.py @@ -0,0 +1,167 @@ +from __future__ import annotations + +from datetime import UTC, date, datetime, timedelta + +from flask_login import UserMixin +from werkzeug.security import check_password_hash, generate_password_hash + +from .extensions import db, login_manager + + +def utcnow() -> datetime: + return datetime.now(UTC).replace(tzinfo=None) + + +class TimestampMixin: + created_at = db.Column(db.DateTime, nullable=False, default=utcnow) + updated_at = db.Column(db.DateTime, nullable=False, default=utcnow, onupdate=utcnow) + + +class User(UserMixin, TimestampMixin, db.Model): + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(120), nullable=False) + email = db.Column(db.String(255), nullable=False, unique=True, index=True) + password_hash = db.Column(db.String(255), nullable=False) + avatar_path = db.Column(db.String(255), nullable=True) + notification_task_due_enabled = db.Column(db.Boolean, nullable=False, default=True) + notification_monthly_winner_enabled = db.Column(db.Boolean, nullable=False, default=True) + + assigned_task_templates = db.relationship( + "TaskTemplate", + foreign_keys="TaskTemplate.default_assigned_user_id", + backref="default_assigned_user", + lazy=True, + ) + assigned_tasks = db.relationship( + "TaskInstance", + foreign_keys="TaskInstance.assigned_user_id", + backref="assigned_user", + lazy=True, + ) + completed_tasks = db.relationship( + "TaskInstance", + foreign_keys="TaskInstance.completed_by_user_id", + backref="completed_by_user", + lazy=True, + ) + subscriptions = db.relationship("PushSubscription", backref="user", lazy=True, cascade="all, delete-orphan") + + def set_password(self, password: str) -> None: + self.password_hash = generate_password_hash(password) + + def check_password(self, password: str) -> bool: + return check_password_hash(self.password_hash, password) + + @property + def display_avatar(self) -> str: + return self.avatar_path or "images/avatars/default.svg" + + def __repr__(self) -> str: + return f"" + + +@login_manager.user_loader +def load_user(user_id: str) -> User | None: + return db.session.get(User, int(user_id)) + + +class TaskTemplate(TimestampMixin, db.Model): + id = db.Column(db.Integer, primary_key=True) + title = db.Column(db.String(160), nullable=False) + description = db.Column(db.Text, nullable=True) + default_points = db.Column(db.Integer, nullable=False, default=10) + default_assigned_user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True) + recurrence_interval_value = db.Column(db.Integer, nullable=True) + recurrence_interval_unit = db.Column(db.String(20), nullable=False, default="none") + active = db.Column(db.Boolean, nullable=False, default=True) + + instances = db.relationship("TaskInstance", backref="task_template", lazy=True, cascade="all, delete-orphan") + + @property + def recurrence_label(self) -> str: + if self.recurrence_interval_unit == "none" or not self.recurrence_interval_value: + return "Einmalig" + return f"Alle {self.recurrence_interval_value} {self.recurrence_interval_unit}" + + +class TaskInstance(TimestampMixin, db.Model): + id = db.Column(db.Integer, primary_key=True) + task_template_id = db.Column(db.Integer, db.ForeignKey("task_template.id"), nullable=False, index=True) + title = db.Column(db.String(160), nullable=False) + description = db.Column(db.Text, nullable=True) + assigned_user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True, index=True) + due_date = db.Column(db.Date, nullable=False, index=True) + status = db.Column(db.String(20), nullable=False, default="open", index=True) + completed_at = db.Column(db.DateTime, nullable=True, index=True) + completed_by_user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True, index=True) + points_awarded = db.Column(db.Integer, nullable=False, default=10) + + @property + def is_completed(self) -> bool: + return self.completed_at is not None + + def compute_status(self, reference_date: date | None = None) -> str: + reference_date = reference_date or date.today() + if self.completed_at: + return "completed" + if self.due_date < reference_date: + return "overdue" + if self.due_date <= reference_date + timedelta(days=2): + return "soon" + return "open" + + @property + def status_label(self) -> str: + labels = { + "open": "Offen", + "soon": "Bald fällig", + "overdue": "Überfällig", + "completed": "Erledigt", + } + return labels.get(self.status, "Offen") + + +class MonthlyScoreSnapshot(db.Model): + id = db.Column(db.Integer, primary_key=True) + year = db.Column(db.Integer, nullable=False, index=True) + month = db.Column(db.Integer, nullable=False, index=True) + user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False, index=True) + total_points = db.Column(db.Integer, nullable=False, default=0) + completed_tasks_count = db.Column(db.Integer, nullable=False, default=0) + rank = db.Column(db.Integer, nullable=False, default=1) + created_at = db.Column(db.DateTime, nullable=False, default=utcnow) + + user = db.relationship("User", backref="monthly_snapshots") + + __table_args__ = (db.UniqueConstraint("year", "month", "user_id", name="uq_snapshot_month_user"),) + + +class PushSubscription(TimestampMixin, db.Model): + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False, index=True) + endpoint = db.Column(db.Text, nullable=False, unique=True) + p256dh = db.Column(db.Text, nullable=False) + auth = db.Column(db.Text, nullable=False) + + +class NotificationLog(db.Model): + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False, index=True) + type = db.Column(db.String(80), nullable=False, index=True) + payload = db.Column(db.Text, nullable=False) + sent_at = db.Column(db.DateTime, nullable=False, default=utcnow, index=True) + + user = db.relationship("User", backref="notification_logs") + + +class BadgeDefinition(TimestampMixin, db.Model): + id = db.Column(db.Integer, primary_key=True) + key = db.Column(db.String(80), nullable=False, unique=True, index=True) + name = db.Column(db.String(120), nullable=False) + description = db.Column(db.String(255), nullable=False) + icon_name = db.Column(db.String(80), nullable=False, default="sparkles") + trigger_type = db.Column(db.String(80), nullable=False) + threshold = db.Column(db.Integer, nullable=False, default=1) + bonus_points = db.Column(db.Integer, nullable=False, default=0) + active = db.Column(db.Boolean, nullable=False, default=True) + diff --git a/app/routes/__init__.py b/app/routes/__init__.py new file mode 100644 index 0000000..4a9ccd9 --- /dev/null +++ b/app/routes/__init__.py @@ -0,0 +1,2 @@ +from . import auth, main, scoreboard, settings, tasks + diff --git a/app/routes/auth.py b/app/routes/auth.py new file mode 100644 index 0000000..d4786ed --- /dev/null +++ b/app/routes/auth.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from flask import Blueprint, flash, redirect, render_template, url_for +from flask_login import current_user, login_required, login_user, logout_user + +from ..extensions import db +from ..forms import LoginForm, RegisterForm +from ..models import User + + +bp = Blueprint("auth", __name__) + + +@bp.route("/login", methods=["GET", "POST"]) +def login(): + if current_user.is_authenticated: + return redirect(url_for("tasks.my_tasks")) + + form = LoginForm() + if form.validate_on_submit(): + user = User.query.filter_by(email=form.email.data.lower().strip()).first() + if user and user.check_password(form.password.data): + login_user(user, remember=form.remember_me.data) + flash(f"Willkommen zurück, {user.name}.", "success") + return redirect(url_for("tasks.my_tasks")) + flash("Die Kombination aus E-Mail und Passwort passt leider nicht.", "error") + return render_template("auth/login.html", form=form) + + +@bp.route("/register", methods=["GET", "POST"]) +def register(): + if current_user.is_authenticated: + return redirect(url_for("tasks.my_tasks")) + + form = RegisterForm() + if form.validate_on_submit(): + user = User(name=form.name.data.strip(), email=form.email.data.lower().strip()) + user.set_password(form.password.data) + db.session.add(user) + db.session.commit() + login_user(user) + flash("Dein Konto ist bereit. Willkommen in der Putzliga.", "success") + return redirect(url_for("tasks.my_tasks")) + return render_template("auth/register.html", form=form) + + +@bp.route("/logout") +@login_required +def logout(): + logout_user() + flash("Du bist jetzt abgemeldet.", "info") + return redirect(url_for("auth.login")) + diff --git a/app/routes/main.py b/app/routes/main.py new file mode 100644 index 0000000..87a7101 --- /dev/null +++ b/app/routes/main.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from functools import lru_cache +from pathlib import Path + +from flask import Blueprint, current_app, redirect, send_from_directory, url_for +from flask_login import current_user + + +bp = Blueprint("main", __name__) + + +@bp.route("/") +def index(): + if current_user.is_authenticated: + return redirect(url_for("tasks.my_tasks")) + return redirect(url_for("auth.login")) + + +@bp.route("/healthz") +def healthz(): + return {"status": "ok"}, 200 + + +@bp.route("/manifest.json") +def manifest(): + return send_from_directory(current_app.static_folder, "manifest.json", mimetype="application/manifest+json") + + +@bp.route("/service-worker.js") +def service_worker(): + response = send_from_directory(current_app.static_folder, "service-worker.js", mimetype="application/javascript") + response.headers["Service-Worker-Allowed"] = "/" + return response + + +@bp.route("/uploads/") +def uploads(filename: str): + return send_from_directory(current_app.config["UPLOAD_FOLDER"], filename) + + +@lru_cache(maxsize=64) +def load_icon_svg(name: str, static_folder: str) -> str: + path = Path(static_folder) / "icons" / f"{name}.svg" + return path.read_text(encoding="utf-8") if path.exists() else "" diff --git a/app/routes/scoreboard.py b/app/routes/scoreboard.py new file mode 100644 index 0000000..409defd --- /dev/null +++ b/app/routes/scoreboard.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from flask import Blueprint, render_template, request +from flask_login import login_required + +from ..services.dates import local_now, month_label +from ..services.monthly import archive_months_missing_up_to_previous, compute_monthly_scores, get_archived_months, get_snapshot_rows + + +bp = Blueprint("scoreboard", __name__, url_prefix="/scoreboard") + + +@bp.route("") +@login_required +def index(): + archive_months_missing_up_to_previous() + now = local_now() + current_rows = compute_monthly_scores(now.year, now.month) + archive_options = get_archived_months(limit=18) + + selected = request.args.get("archive") + selected_archive = selected + selected_year = selected_month = None + archived_rows = [] + if selected: + year_str, month_str = selected.split("-") + selected_year, selected_month = int(year_str), int(month_str) + archived_rows = get_snapshot_rows(selected_year, selected_month) + elif archive_options: + selected_year, selected_month = archive_options[0] + selected_archive = f"{selected_year}-{selected_month:02d}" + archived_rows = get_snapshot_rows(selected_year, selected_month) + + return render_template( + "scoreboard/index.html", + current_rows=current_rows, + current_label=month_label(now.year, now.month), + archive_options=archive_options, + selected_archive=selected_archive, + archived_rows=archived_rows, + archive_label=month_label(selected_year, selected_month) if selected_year and selected_month else None, + max_points=max([row["total_points"] for row in current_rows], default=1), + ) diff --git a/app/routes/settings.py b/app/routes/settings.py new file mode 100644 index 0000000..1228059 --- /dev/null +++ b/app/routes/settings.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +from pathlib import Path +from uuid import uuid4 + +from flask import Blueprint, current_app, flash, jsonify, redirect, render_template, request, url_for +from flask_login import current_user, login_required +from werkzeug.utils import secure_filename + +from ..extensions import csrf, db +from ..forms import SettingsProfileForm +from ..models import BadgeDefinition, PushSubscription +from ..services.notifications import push_enabled + + +bp = Blueprint("settings", __name__, url_prefix="/settings") + + +def _save_avatar(file_storage) -> str: + filename = secure_filename(file_storage.filename or "") + ext = Path(filename).suffix.lower() or ".png" + relative_path = Path("avatars") / f"{uuid4().hex}{ext}" + absolute_path = Path(current_app.config["UPLOAD_FOLDER"]) / relative_path + absolute_path.parent.mkdir(parents=True, exist_ok=True) + file_storage.save(absolute_path) + return relative_path.as_posix() + + +@bp.route("", methods=["GET", "POST"]) +@login_required +def index(): + form = SettingsProfileForm(original_email=current_user.email, obj=current_user) + if form.validate_on_submit(): + current_user.name = form.name.data.strip() + current_user.email = form.email.data.lower().strip() + current_user.notification_task_due_enabled = form.notification_task_due_enabled.data + current_user.notification_monthly_winner_enabled = form.notification_monthly_winner_enabled.data + if form.password.data: + current_user.set_password(form.password.data) + if form.avatar.data: + current_user.avatar_path = _save_avatar(form.avatar.data) + db.session.commit() + flash("Deine Einstellungen wurden gespeichert.", "success") + return redirect(url_for("settings.index")) + + badges = BadgeDefinition.query.order_by(BadgeDefinition.name.asc()).all() + subscriptions = PushSubscription.query.filter_by(user_id=current_user.id).all() + return render_template( + "settings/index.html", + form=form, + badges=badges, + push_ready=push_enabled(), + vapid_public_key=current_app.config["VAPID_PUBLIC_KEY"], + has_subscription=bool(subscriptions), + ) + + +@bp.route("/badges/", methods=["POST"]) +@login_required +def update_badge(badge_id: int): + badge = BadgeDefinition.query.get_or_404(badge_id) + badge.threshold = max(1, request.form.get("threshold", type=int, default=badge.threshold)) + badge.bonus_points = max(0, request.form.get("bonus_points", type=int, default=badge.bonus_points)) + badge.active = request.form.get("active") == "on" + db.session.commit() + flash(f"Badge „{badge.name}“ wurde aktualisiert.", "success") + return redirect(url_for("settings.index")) + + +@bp.route("/push/subscribe", methods=["POST"]) +@login_required +@csrf.exempt +def push_subscribe(): + if not push_enabled(): + return jsonify({"ok": False, "message": "VAPID ist nicht konfiguriert."}), 400 + + data = request.get_json(silent=True) or {} + endpoint = data.get("endpoint") + keys = data.get("keys", {}) + if not endpoint or not keys.get("p256dh") or not keys.get("auth"): + return jsonify({"ok": False, "message": "Subscription unvollständig."}), 400 + + subscription = PushSubscription.query.filter_by(endpoint=endpoint).first() + if not subscription: + subscription = PushSubscription(user_id=current_user.id, endpoint=endpoint, p256dh=keys["p256dh"], auth=keys["auth"]) + db.session.add(subscription) + else: + subscription.user_id = current_user.id + subscription.p256dh = keys["p256dh"] + subscription.auth = keys["auth"] + db.session.commit() + return jsonify({"ok": True}) + + +@bp.route("/push/unsubscribe", methods=["POST"]) +@login_required +@csrf.exempt +def push_unsubscribe(): + data = request.get_json(silent=True) or {} + endpoint = data.get("endpoint") + if endpoint: + subscription = PushSubscription.query.filter_by(endpoint=endpoint, user_id=current_user.id).first() + if subscription: + db.session.delete(subscription) + db.session.commit() + return jsonify({"ok": True}) diff --git a/app/routes/tasks.py b/app/routes/tasks.py new file mode 100644 index 0000000..93be0b2 --- /dev/null +++ b/app/routes/tasks.py @@ -0,0 +1,174 @@ +from __future__ import annotations + +import calendar +from collections import defaultdict +from datetime import date + +from flask import Blueprint, flash, redirect, render_template, request, url_for +from flask_login import current_user, login_required + +from ..forms import TaskForm +from ..models import TaskInstance, User +from ..services.dates import month_label, today_local +from ..services.tasks import complete_task, create_task_template_and_instance, refresh_task_statuses, update_template_and_instance + + +bp = Blueprint("tasks", __name__, url_prefix="") + + +def _user_choices() -> list[tuple[int, str]]: + return [(user.id, user.name) for user in User.query.order_by(User.name.asc()).all()] + + +@bp.route("/my-tasks") +@login_required +def my_tasks(): + tasks = ( + TaskInstance.query.filter_by(assigned_user_id=current_user.id) + .order_by(TaskInstance.completed_at.is_(None).desc(), TaskInstance.due_date.asc()) + .all() + ) + refresh_task_statuses(tasks) + + sections = {"open": [], "soon": [], "overdue": [], "completed": []} + for task in tasks: + sections[task.status].append(task) + + completed_count = len(sections["completed"]) + active_count = len(sections["open"]) + len(sections["soon"]) + len(sections["overdue"]) + completion_ratio = 0 if completed_count + active_count == 0 else round(completed_count / (completed_count + active_count) * 100) + + return render_template( + "tasks/my_tasks.html", + sections=sections, + completion_ratio=completion_ratio, + today=today_local(), + ) + + +@bp.route("/tasks") +@login_required +def all_tasks(): + query = TaskInstance.query + status = request.args.get("status", "all") + mine = request.args.get("mine") + user_filter = request.args.get("user_id", type=int) + sort = request.args.get("sort", "due") + + if mine == "1": + query = query.filter(TaskInstance.assigned_user_id == current_user.id) + elif user_filter: + query = query.filter(TaskInstance.assigned_user_id == user_filter) + + if sort == "points": + query = query.order_by(TaskInstance.points_awarded.desc(), TaskInstance.due_date.asc()) + elif sort == "user": + query = query.order_by(TaskInstance.assigned_user_id.asc(), TaskInstance.due_date.asc()) + else: + query = query.order_by(TaskInstance.completed_at.is_(None).desc(), TaskInstance.due_date.asc()) + + tasks = query.all() + refresh_task_statuses(tasks) + + if status != "all": + status_map = {"completed": "completed", "overdue": "overdue", "open": "open", "soon": "soon"} + selected = status_map.get(status) + if selected: + tasks = [task for task in tasks if task.status == selected] + + return render_template( + "tasks/all_tasks.html", + tasks=tasks, + users=User.query.order_by(User.name.asc()).all(), + filters={"status": status, "mine": mine, "user_id": user_filter, "sort": sort}, + ) + + +@bp.route("/tasks/new", methods=["GET", "POST"]) +@login_required +def create(): + form = TaskForm() + form.assigned_user_id.choices = _user_choices() + if request.method == "GET" and not form.due_date.data: + form.due_date.data = today_local() + + if form.validate_on_submit(): + task = create_task_template_and_instance(form) + flash(f"Aufgabe „{task.title}“ wurde angelegt.", "success") + return redirect(url_for("tasks.my_tasks")) + return render_template("tasks/task_form.html", form=form, mode="create", task=None) + + +@bp.route("/tasks//edit", methods=["GET", "POST"]) +@login_required +def edit(task_id: int): + task = TaskInstance.query.get_or_404(task_id) + form = TaskForm(obj=task.task_template) + form.assigned_user_id.choices = _user_choices() + + if request.method == "GET": + form.title.data = task.title + form.description.data = task.description + form.default_points.data = task.points_awarded + form.assigned_user_id.data = task.assigned_user_id or _user_choices()[0][0] + form.due_date.data = task.due_date + form.recurrence_interval_value.data = task.task_template.recurrence_interval_value or 1 + form.recurrence_interval_unit.data = task.task_template.recurrence_interval_unit + form.active.data = task.task_template.active + + if form.validate_on_submit(): + update_template_and_instance(task, form) + flash("Aufgabe und Vorlage wurden aktualisiert.", "success") + return redirect(url_for("tasks.all_tasks")) + + return render_template("tasks/task_form.html", form=form, mode="edit", task=task) + + +@bp.route("/tasks//complete", methods=["POST"]) +@login_required +def complete(task_id: int): + task = TaskInstance.query.get_or_404(task_id) + choice = request.form.get("completed_for", "me") + if task.is_completed: + flash("Diese Aufgabe ist bereits erledigt.", "info") + return redirect(request.referrer or url_for("tasks.my_tasks")) + + completed_by_id = current_user.id + if task.assigned_user_id and task.assigned_user_id != current_user.id and choice == "assigned": + completed_by_id = task.assigned_user_id + + complete_task(task, completed_by_id) + flash("Punkte verbucht. Gute Arbeit.", "success") + return redirect(request.referrer or url_for("tasks.my_tasks")) + + +@bp.route("/calendar") +@login_required +def calendar_view(): + today = today_local() + year = request.args.get("year", type=int) or today.year + month = request.args.get("month", type=int) or today.month + view = request.args.get("view", "calendar") + + tasks = TaskInstance.query.filter( + TaskInstance.due_date >= date(year, month, 1), + TaskInstance.due_date <= date(year, month, calendar.monthrange(year, month)[1]), + ).order_by(TaskInstance.due_date.asc()).all() + refresh_task_statuses(tasks) + + tasks_by_day: dict[int, list[TaskInstance]] = defaultdict(list) + for task in tasks: + tasks_by_day[task.due_date.day].append(task) + + month_calendar = calendar.Calendar(firstweekday=0).monthdayscalendar(year, month) + return render_template( + "tasks/calendar.html", + current_year=year, + current_month=month, + current_label=month_label(year, month), + month_calendar=month_calendar, + tasks_by_day=tasks_by_day, + view=view, + tasks=tasks, + ) + diff --git a/app/services/badges.py b/app/services/badges.py new file mode 100644 index 0000000..73da661 --- /dev/null +++ b/app/services/badges.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from collections import defaultdict +from datetime import date, timedelta + +from ..models import BadgeDefinition, TaskInstance + + +def _max_day_streak(days: set[date]) -> int: + if not days: + return 0 + streak = 1 + best = 1 + ordered = sorted(days) + for previous, current in zip(ordered, ordered[1:]): + if current == previous + timedelta(days=1): + streak += 1 + else: + streak = 1 + best = max(best, streak) + return best + + +def compute_badge_awards(definitions: list[BadgeDefinition], completed_tasks: list[TaskInstance]) -> list[dict]: + by_type: dict[str, int] = defaultdict(int) + completion_days: set[date] = set() + + for task in completed_tasks: + if not task.completed_at: + continue + completion_day = task.completed_at.date() + completion_days.add(completion_day) + by_type["monthly_task_count"] += 1 + if task.due_date and completion_day < task.due_date: + by_type["early_finisher_count"] += 1 + if task.due_date and completion_day <= task.due_date: + by_type["on_time_count"] += 1 + + by_type["streak_days"] = _max_day_streak(completion_days) + + awards = [] + for definition in definitions: + metric_value = by_type.get(definition.trigger_type, 0) + if definition.active and metric_value >= definition.threshold: + awards.append( + { + "definition": definition, + "metric_value": metric_value, + "bonus_points": definition.bonus_points, + } + ) + return awards + diff --git a/app/services/dates.py b/app/services/dates.py new file mode 100644 index 0000000..6f55c20 --- /dev/null +++ b/app/services/dates.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import calendar +from datetime import UTC, date, datetime +from zoneinfo import ZoneInfo + +from flask import current_app + + +MONTH_NAMES = [ + "", + "Januar", + "Februar", + "März", + "April", + "Mai", + "Juni", + "Juli", + "August", + "September", + "Oktober", + "November", + "Dezember", +] + + +def get_timezone() -> ZoneInfo: + return ZoneInfo(current_app.config["APP_TIMEZONE"]) + + +def local_now() -> datetime: + return datetime.now(UTC).astimezone(get_timezone()) + + +def today_local() -> date: + return local_now().date() + + +def previous_month(year: int, month: int) -> tuple[int, int]: + if month == 1: + return year - 1, 12 + return year, month - 1 + + +def next_month(year: int, month: int) -> tuple[int, int]: + if month == 12: + return year + 1, 1 + return year, month + 1 + + +def month_label(year: int, month: int) -> str: + return f"{MONTH_NAMES[month]} {year}" + + +def add_months(base_date: date, months: int) -> date: + month_index = base_date.month - 1 + months + year = base_date.year + month_index // 12 + month = month_index % 12 + 1 + day = min(base_date.day, calendar.monthrange(year, month)[1]) + return date(year, month, day) + + +def month_bounds(year: int, month: int) -> tuple[datetime, datetime]: + start = datetime(year, month, 1) + next_year, next_month_value = next_month(year, month) + end = datetime(next_year, next_month_value, 1) + return start, end + diff --git a/app/services/monthly.py b/app/services/monthly.py new file mode 100644 index 0000000..c745d38 --- /dev/null +++ b/app/services/monthly.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +from collections import defaultdict +from datetime import datetime + +from sqlalchemy import extract, select + +from ..extensions import db +from ..models import BadgeDefinition, MonthlyScoreSnapshot, TaskInstance, User +from .badges import compute_badge_awards +from .dates import local_now, month_bounds, next_month, previous_month + + +def _build_ranking(rows: list[dict]) -> list[dict]: + rows.sort(key=lambda row: (-row["total_points"], -row["completed_tasks_count"], row["user"].name.lower())) + for index, row in enumerate(rows, start=1): + row["rank"] = index + return rows + + +def compute_monthly_scores(year: int, month: int) -> list[dict]: + start, end = month_bounds(year, month) + users = User.query.order_by(User.name.asc()).all() + badges = BadgeDefinition.query.filter_by(active=True).all() + completed_tasks = TaskInstance.query.filter( + TaskInstance.completed_at.isnot(None), + TaskInstance.completed_at >= start, + TaskInstance.completed_at < end, + ).all() + + tasks_by_user: dict[int, list[TaskInstance]] = defaultdict(list) + for task in completed_tasks: + if task.completed_by_user_id: + tasks_by_user[task.completed_by_user_id].append(task) + + rows = [] + for user in users: + personal_tasks = tasks_by_user.get(user.id, []) + base_points = sum(task.points_awarded for task in personal_tasks) + awards = compute_badge_awards(badges, personal_tasks) + bonus_points = sum(award["bonus_points"] for award in awards) + rows.append( + { + "user": user, + "base_points": base_points, + "bonus_points": bonus_points, + "total_points": base_points + bonus_points, + "completed_tasks_count": len(personal_tasks), + "badges": awards, + } + ) + return _build_ranking(rows) + + +def ensure_monthly_snapshots(reference: datetime | None = None) -> None: + now = reference or local_now().replace(tzinfo=None) + target_year, target_month = previous_month(now.year, now.month) + if MonthlyScoreSnapshot.query.filter_by(year=target_year, month=target_month).count(): + return + + snapshot_rows = compute_monthly_scores(target_year, target_month) + for row in snapshot_rows: + db.session.add( + MonthlyScoreSnapshot( + year=target_year, + month=target_month, + user_id=row["user"].id, + total_points=row["total_points"], + completed_tasks_count=row["completed_tasks_count"], + rank=row["rank"], + ) + ) + db.session.commit() + + +def archive_months_missing_up_to_previous() -> None: + now = local_now() + previous_year, previous_month_value = previous_month(now.year, now.month) + latest = ( + MonthlyScoreSnapshot.query.order_by(MonthlyScoreSnapshot.year.desc(), MonthlyScoreSnapshot.month.desc()).first() + ) + + if latest: + year, month = next_month(latest.year, latest.month) + else: + year, month = previous_year, previous_month_value + + while (year, month) <= (previous_year, previous_month_value): + if not MonthlyScoreSnapshot.query.filter_by(year=year, month=month).count(): + rows = compute_monthly_scores(year, month) + for row in rows: + db.session.add( + MonthlyScoreSnapshot( + year=year, + month=month, + user_id=row["user"].id, + total_points=row["total_points"], + completed_tasks_count=row["completed_tasks_count"], + rank=row["rank"], + ) + ) + db.session.commit() + year, month = next_month(year, month) + + +def get_archived_months(limit: int = 12) -> list[tuple[int, int]]: + rows = ( + db.session.query(MonthlyScoreSnapshot.year, MonthlyScoreSnapshot.month) + .group_by(MonthlyScoreSnapshot.year, MonthlyScoreSnapshot.month) + .order_by(MonthlyScoreSnapshot.year.desc(), MonthlyScoreSnapshot.month.desc()) + .limit(limit) + .all() + ) + return [(row.year, row.month) for row in rows] + + +def get_snapshot_rows(year: int, month: int) -> list[MonthlyScoreSnapshot]: + return ( + MonthlyScoreSnapshot.query.filter_by(year=year, month=month) + .order_by(MonthlyScoreSnapshot.rank.asc(), MonthlyScoreSnapshot.total_points.desc()) + .all() + ) + diff --git a/app/services/notifications.py b/app/services/notifications.py new file mode 100644 index 0000000..313c0b7 --- /dev/null +++ b/app/services/notifications.py @@ -0,0 +1,172 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass +from datetime import timedelta +from urllib.parse import urljoin + +from flask import current_app +from pywebpush import WebPushException, webpush + +from ..extensions import db +from ..models import NotificationLog, PushSubscription, TaskInstance, User +from .monthly import archive_months_missing_up_to_previous, get_snapshot_rows +from .dates import local_now, previous_month + + +@dataclass +class NotificationResult: + sent: int = 0 + skipped: int = 0 + failed: int = 0 + + +def push_enabled() -> bool: + return bool(current_app.config["VAPID_PUBLIC_KEY"] and current_app.config["VAPID_PRIVATE_KEY"]) + + +def _absolute_url(path: str) -> str: + base = current_app.config["APP_BASE_URL"].rstrip("/") + "/" + return urljoin(base, path.lstrip("/")) + + +def _notification_exists(user_id: int, notification_type: str, payload: dict) -> bool: + payload_value = json.dumps(payload, sort_keys=True) + return ( + NotificationLog.query.filter_by(user_id=user_id, type=notification_type, payload=payload_value).first() + is not None + ) + + +def _log_notification(user_id: int, notification_type: str, payload: dict) -> None: + db.session.add( + NotificationLog(user_id=user_id, type=notification_type, payload=json.dumps(payload, sort_keys=True)) + ) + + +def _send_subscription(subscription: PushSubscription, payload: dict) -> bool: + try: + webpush( + subscription_info={ + "endpoint": subscription.endpoint, + "keys": {"p256dh": subscription.p256dh, "auth": subscription.auth}, + }, + data=json.dumps(payload), + vapid_private_key=current_app.config["VAPID_PRIVATE_KEY"], + vapid_claims={"sub": current_app.config["VAPID_CLAIMS_SUBJECT"]}, + ) + return True + except WebPushException: + return False + + +def send_due_notifications() -> NotificationResult: + result = NotificationResult() + if not push_enabled(): + result.skipped += 1 + return result + + today = local_now().date() + relevant_tasks = TaskInstance.query.filter( + TaskInstance.completed_at.is_(None), + TaskInstance.assigned_user_id.isnot(None), + TaskInstance.due_date <= today + timedelta(days=1), + ).all() + + for task in relevant_tasks: + user = task.assigned_user + if not user or not user.notification_task_due_enabled: + result.skipped += 1 + continue + + payload_marker = {"task_instance_id": task.id, "due_date": task.due_date.isoformat()} + if _notification_exists(user.id, "task_due", payload_marker): + result.skipped += 1 + continue + + subscriptions = PushSubscription.query.filter_by(user_id=user.id).all() + if not subscriptions: + result.skipped += 1 + continue + + body = "Heute ist ein guter Tag für Punkte." if task.due_date <= today else "Morgen wird's fällig." + payload = { + "title": f"Putzliga erinnert: {task.title}", + "body": body, + "icon": _absolute_url("/static/images/pwa-icon-192.png"), + "badge": _absolute_url("/static/images/pwa-badge.png"), + "url": _absolute_url("/my-tasks"), + "tag": f"task-{task.id}", + } + + sent_any = False + for subscription in subscriptions: + if _send_subscription(subscription, payload): + sent_any = True + result.sent += 1 + else: + result.failed += 1 + + if sent_any: + _log_notification(user.id, "task_due", payload_marker) + db.session.commit() + return result + + +def send_monthly_winner_notifications() -> NotificationResult: + result = NotificationResult() + if not push_enabled(): + result.skipped += 1 + return result + + now = local_now() + if not (now.day == 1 and now.hour >= 9): + result.skipped += 1 + return result + + archive_months_missing_up_to_previous() + target_year, target_month = previous_month(now.year, now.month) + rows = get_snapshot_rows(target_year, target_month) + if not rows: + result.skipped += 1 + return result + + winners = [row.user.name for row in rows if row.rank == 1] + winner_text = ", ".join(winners) + users = User.query.order_by(User.name.asc()).all() + marker = {"year": target_year, "month": target_month} + + for user in users: + if not user.notification_monthly_winner_enabled: + result.skipped += 1 + continue + if _notification_exists(user.id, "monthly_winner", marker): + result.skipped += 1 + continue + + subscriptions = PushSubscription.query.filter_by(user_id=user.id).all() + if not subscriptions: + result.skipped += 1 + continue + + payload = { + "title": "Der Haushalts-Champion des letzten Monats steht fest", + "body": f"{winner_text} führt den letzten Monat an. Schau ins Scoreboard.", + "icon": _absolute_url("/static/images/pwa-icon-192.png"), + "badge": _absolute_url("/static/images/pwa-badge.png"), + "url": _absolute_url(f"/scoreboard?archive={target_year}-{target_month:02d}"), + "tag": f"winner-{target_year}-{target_month}", + } + sent_any = False + for subscription in subscriptions: + if _send_subscription(subscription, payload): + sent_any = True + result.sent += 1 + else: + result.failed += 1 + + if sent_any: + _log_notification(user.id, "monthly_winner", marker) + db.session.commit() + + return result diff --git a/app/services/tasks.py b/app/services/tasks.py new file mode 100644 index 0000000..1315969 --- /dev/null +++ b/app/services/tasks.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +from datetime import date, datetime, timedelta + +from sqlalchemy import select + +from ..extensions import db +from ..models import TaskInstance, TaskTemplate +from .dates import add_months, today_local + + +def refresh_task_status(task: TaskInstance, reference_date: date | None = None) -> bool: + status = task.compute_status(reference_date or today_local()) + if task.status != status: + task.status = status + return True + return False + + +def refresh_task_statuses(tasks: list[TaskInstance]) -> None: + dirty = any(refresh_task_status(task) for task in tasks) + if dirty: + db.session.commit() + + +def create_task_template_and_instance(form) -> TaskInstance: + template = TaskTemplate( + title=form.title.data.strip(), + description=(form.description.data or "").strip(), + default_points=form.default_points.data, + default_assigned_user_id=form.assigned_user_id.data, + recurrence_interval_value=form.recurrence_interval_value.data if form.recurrence_interval_unit.data != "none" else None, + recurrence_interval_unit=form.recurrence_interval_unit.data, + active=form.active.data, + ) + db.session.add(template) + db.session.flush() + + task = TaskInstance( + task_template_id=template.id, + title=template.title, + description=template.description, + assigned_user_id=template.default_assigned_user_id, + due_date=form.due_date.data, + points_awarded=template.default_points, + status="open", + ) + refresh_task_status(task, form.due_date.data) + db.session.add(task) + db.session.commit() + return task + + +def update_template_and_instance(task: TaskInstance, form) -> TaskInstance: + template = task.task_template + template.title = form.title.data.strip() + template.description = (form.description.data or "").strip() + template.default_points = form.default_points.data + template.default_assigned_user_id = form.assigned_user_id.data + template.recurrence_interval_unit = form.recurrence_interval_unit.data + template.recurrence_interval_value = ( + form.recurrence_interval_value.data if form.recurrence_interval_unit.data != "none" else None + ) + template.active = form.active.data + + task.title = template.title + task.description = template.description + task.assigned_user_id = template.default_assigned_user_id + task.points_awarded = template.default_points + task.due_date = form.due_date.data + refresh_task_status(task, form.due_date.data) + db.session.commit() + return task + + +def _next_due_date(task: TaskInstance) -> date | None: + template = task.task_template + value = template.recurrence_interval_value + if template.recurrence_interval_unit == "none" or not value: + return None + if template.recurrence_interval_unit == "days": + return task.due_date + timedelta(days=value) + if template.recurrence_interval_unit == "weeks": + return task.due_date + timedelta(weeks=value) + if template.recurrence_interval_unit == "months": + return add_months(task.due_date, value) + return None + + +def ensure_next_recurring_task(task: TaskInstance) -> TaskInstance | None: + next_due = _next_due_date(task) + if not next_due or not task.task_template.active: + return None + + existing = db.session.scalar( + select(TaskInstance).where( + TaskInstance.task_template_id == task.task_template_id, + TaskInstance.due_date == next_due, + ) + ) + if existing: + return existing + + next_task = TaskInstance( + task_template_id=task.task_template_id, + title=task.task_template.title, + description=task.task_template.description, + assigned_user_id=task.task_template.default_assigned_user_id, + due_date=next_due, + points_awarded=task.task_template.default_points, + status="open", + ) + refresh_task_status(next_task, today_local()) + db.session.add(next_task) + return next_task + + +def complete_task(task: TaskInstance, completed_by_user_id: int) -> TaskInstance: + if not task.completed_at: + task.completed_at = datetime.utcnow() + task.completed_by_user_id = completed_by_user_id + task.status = "completed" + ensure_next_recurring_task(task) + db.session.commit() + return task + diff --git a/app/static/css/style.css b/app/static/css/style.css new file mode 100644 index 0000000..6af86af --- /dev/null +++ b/app/static/css/style.css @@ -0,0 +1,916 @@ +@font-face { + font-family: "InterLocal"; + src: url("../fonts/Inter_24pt-Regular.ttf") format("truetype"); + font-weight: 400; + font-display: swap; +} + +@font-face { + font-family: "InterLocal"; + src: url("../fonts/Inter_24pt-Medium.ttf") format("truetype"); + font-weight: 500; + font-display: swap; +} + +@font-face { + font-family: "InterLocal"; + src: url("../fonts/Inter_24pt-SemiBold.ttf") format("truetype"); + font-weight: 600; + font-display: swap; +} + +@font-face { + font-family: "InterLocal"; + src: url("../fonts/Inter_24pt-Bold.ttf") format("truetype"); + font-weight: 700; + font-display: swap; +} + +@font-face { + font-family: "SpaceGroteskLocal"; + src: url("../fonts/SpaceGrotesk-Regular.ttf") format("truetype"); + font-weight: 400; + font-display: swap; +} + +@font-face { + font-family: "SpaceGroteskLocal"; + src: url("../fonts/SpaceGrotesk-SemiBold.ttf") format("truetype"); + font-weight: 600; + font-display: swap; +} + +@font-face { + font-family: "SpaceGroteskLocal"; + src: url("../fonts/SpaceGrotesk-Bold.ttf") format("truetype"); + font-weight: 700; + font-display: swap; +} + +:root { + --bg: #eef3ff; + --bg-accent: #d8e6ff; + --surface: rgba(255, 255, 255, 0.85); + --surface-strong: #ffffff; + --surface-soft: rgba(244, 248, 255, 0.95); + --text: #223049; + --muted: #64748b; + --border: rgba(132, 152, 190, 0.24); + --primary: #2563eb; + --primary-strong: #1745c1; + --secondary: #edf4ff; + --success: #059669; + --warning: #f59e0b; + --danger: #e11d48; + --shadow: 0 24px 60px rgba(52, 79, 131, 0.16); + --radius-lg: 28px; + --radius-md: 22px; + --radius-sm: 16px; + --font-body: "InterLocal", system-ui, sans-serif; + --font-heading: "SpaceGroteskLocal", "InterLocal", sans-serif; + --safe-bottom: max(24px, env(safe-area-inset-bottom)); +} + +* { + box-sizing: border-box; +} + +html { + min-height: 100%; +} + +body { + margin: 0; + min-height: 100vh; + font-family: var(--font-body); + color: var(--text); + background: + radial-gradient(circle at top left, rgba(181, 210, 255, 0.85), transparent 32%), + radial-gradient(circle at top right, rgba(255, 221, 196, 0.48), transparent 32%), + linear-gradient(180deg, #f8fbff 0%, #eef3ff 42%, #edf2ff 100%); +} + +a { + color: inherit; + text-decoration: none; +} + +img { + max-width: 100%; + display: block; +} + +button, +input, +select, +textarea { + font: inherit; +} + +h1, +h2, +h3 { + margin: 0; + font-family: var(--font-heading); + line-height: 1.05; +} + +p { + margin: 0; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + border: 0; +} + +.app-shell { + display: block; +} + +.page-shell { + min-height: 100vh; + padding: 24px 18px calc(100px + var(--safe-bottom)); +} + +.topbar { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 16px; + margin-bottom: 24px; +} + +.topbar h1 { + font-size: clamp(1.9rem, 4vw, 2.9rem); +} + +.topbar-user { + display: none; +} + +.eyebrow { + margin-bottom: 8px; + font-size: 0.84rem; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--primary); +} + +.content { + display: grid; + gap: 24px; +} + +.panel, +.hero-card, +.task-card, +.score-row, +.sidebar-card, +.calendar-day, +.archive-row, +.badge-setting-card, +.list-row { + border: 1px solid var(--border); + background: var(--surface); + backdrop-filter: blur(18px); + box-shadow: var(--shadow); + border-radius: var(--radius-lg); +} + +.panel, +.hero-card, +.score-row, +.list-row { + padding: 22px; +} + +.hero-grid, +.two-column { + display: grid; + gap: 18px; +} + +.hero-card { + background: + linear-gradient(135deg, rgba(255, 255, 255, 0.96), rgba(245, 249, 255, 0.86)), + linear-gradient(135deg, rgba(37, 99, 235, 0.06), rgba(5, 150, 105, 0.03)); +} + +.hero-card h2 { + margin-bottom: 12px; + font-size: clamp(1.8rem, 4vw, 2.7rem); +} + +.hero-card p { + color: var(--muted); + line-height: 1.55; +} + +.hero-card--brand { + padding: 28px; +} + +.hero-stats { + margin-top: 22px; + display: grid; + gap: 14px; +} + +.hero-stats div { + padding: 16px; + border-radius: var(--radius-md); + background: rgba(255, 255, 255, 0.7); + border: 1px solid rgba(150, 173, 214, 0.18); +} + +.hero-stats strong { + display: block; + margin-bottom: 4px; +} + +.brand { + display: inline-flex; + align-items: center; + gap: 14px; +} + +.brand strong { + display: block; + font-size: 1.1rem; +} + +.brand span { + color: var(--muted); + font-size: 0.95rem; +} + +.brand__logo { + width: 48px; + height: 48px; +} + +.flash-stack { + display: grid; + gap: 10px; +} + +.flash { + padding: 14px 16px; + border-radius: 16px; + border: 1px solid var(--border); + background: rgba(255, 255, 255, 0.94); +} + +.flash--success { + border-color: rgba(5, 150, 105, 0.2); + color: #065f46; +} + +.flash--error { + border-color: rgba(225, 29, 72, 0.2); + color: #9f1239; +} + +.flash--info { + color: var(--primary-strong); +} + +.progress-card { + margin-top: 22px; + padding: 18px; + border-radius: var(--radius-md); + background: rgba(255, 255, 255, 0.74); + border: 1px solid rgba(132, 152, 190, 0.2); +} + +.progress-card__top, +.section-heading, +.task-card__top, +.score-row__head, +.score-row__meta, +.task-card__footer, +.sidebar-card__row, +.toolbar-actions, +.archive-row, +.list-row, +.push-box__state, +.form-actions, +.field-inline { + display: flex; + justify-content: space-between; + align-items: center; + gap: 14px; +} + +.progress { + margin-top: 12px; + height: 14px; + border-radius: 999px; + background: rgba(162, 182, 218, 0.24); + overflow: hidden; +} + +.progress span, +.score-bar span { + display: block; + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, #2563eb, #34d399); +} + +.stack, +.task-grid, +.scoreboard, +.archive-list, +.badge-settings { + display: grid; + gap: 16px; +} + +.section-heading h2, +.panel h2 { + font-size: 1.5rem; +} + +.section-heading__count, +.point-pill, +.reward-chip, +.rank-badge { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 8px 12px; + border-radius: 999px; + font-weight: 700; + font-size: 0.88rem; +} + +.section-heading__count, +.point-pill { + background: var(--secondary); + color: var(--primary-strong); +} + +.empty-state { + padding: 28px; + border: 1px dashed rgba(132, 152, 190, 0.44); + border-radius: var(--radius-md); + color: var(--muted); + text-align: center; + background: rgba(255, 255, 255, 0.58); +} + +.task-card { + display: grid; + gap: 16px; + padding: 20px; +} + +.task-card h3 { + font-size: 1.35rem; +} + +.task-card--compact { + opacity: 0.94; +} + +.chip-row, +.badge-cloud { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.status-badge { + display: inline-flex; + align-items: center; + padding: 8px 12px; + border-radius: 999px; + font-weight: 700; + font-size: 0.83rem; +} + +.status-badge--open { + background: #e8f0ff; + color: #1d4ed8; +} + +.status-badge--soon { + background: #fff3d6; + color: #b45309; +} + +.status-badge--overdue { + background: #ffe3ea; + color: #be123c; +} + +.status-badge--completed { + background: #ddfbf1; + color: #047857; +} + +.task-meta { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.task-meta dt { + margin-bottom: 6px; + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--muted); +} + +.task-meta dd { + margin: 0; + font-weight: 600; +} + +.task-assignee { + display: inline-flex; + align-items: center; + gap: 10px; + color: var(--muted); +} + +.avatar { + width: 34px; + height: 34px; + border-radius: 50%; + object-fit: cover; + border: 2px solid rgba(255, 255, 255, 0.8); + box-shadow: 0 10px 24px rgba(78, 100, 141, 0.14); +} + +.avatar--lg { + width: 52px; + height: 52px; +} + +.done-hint, +.muted, +.inline-note, +.sidebar-card p, +.score-row__meta, +.archive-row__right small, +.calendar-task small { + color: var(--muted); +} + +.button, +.button--ghost, +.button--secondary, +.icon-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 10px; + min-height: 48px; + padding: 0 18px; + border: 0; + border-radius: 16px; + cursor: pointer; + transition: transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease; +} + +.button { + color: #fff; + background: linear-gradient(135deg, var(--primary), #4f8cff); + box-shadow: 0 14px 30px rgba(37, 99, 235, 0.24); +} + +.button:hover, +.button--secondary:hover, +.button--ghost:hover, +.icon-button:hover { + transform: translateY(-1px); +} + +.button--secondary { + background: var(--secondary); + color: var(--primary-strong); +} + +.button--ghost { + background: transparent; + border: 1px solid rgba(132, 152, 190, 0.3); + color: var(--text); +} + +.button--wide { + width: 100%; +} + +.icon-button { + width: 48px; + padding: 0; + background: rgba(255, 255, 255, 0.74); + border: 1px solid rgba(132, 152, 190, 0.24); +} + +.form-grid { + display: grid; + gap: 16px; +} + +.form-grid--two { + grid-template-columns: 1fr; +} + +.field { + display: grid; + gap: 8px; +} + +.field--full { + grid-column: 1 / -1; +} + +.field label, +.checkbox span { + font-weight: 600; +} + +.field input, +.field select, +.field textarea { + width: 100%; + padding: 14px 16px; + border: 1px solid rgba(132, 152, 190, 0.3); + border-radius: 16px; + background: rgba(255, 255, 255, 0.86); + color: var(--text); +} + +.field--compact input, +.field--compact select { + min-width: 120px; +} + +.checkbox { + display: inline-flex; + align-items: center; + gap: 10px; +} + +.checkbox input { + width: 18px; + height: 18px; +} + +.checkbox--compact { + min-height: 48px; +} + +.error { + color: var(--danger); +} + +.filter-bar, +.inline-form { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 14px; +} + +.panel--toolbar { + display: flex; + justify-content: space-between; + align-items: center; + gap: 18px; +} + +.segmented { + display: inline-flex; + padding: 4px; + border-radius: 18px; + background: rgba(255, 255, 255, 0.74); +} + +.segmented a { + padding: 10px 16px; + border-radius: 14px; + color: var(--muted); +} + +.segmented .is-active { + background: #fff; + color: var(--text); +} + +.calendar-grid { + display: grid; + gap: 12px; +} + +.calendar-grid__weekdays { + display: none; +} + +.calendar-day { + min-height: 132px; + padding: 14px; +} + +.calendar-day strong { + display: inline-flex; + margin-bottom: 10px; +} + +.calendar-day--empty { + display: none; +} + +.calendar-day__tasks { + display: grid; + gap: 8px; +} + +.calendar-task { + display: grid; + gap: 4px; + padding: 10px 12px; + border-radius: 14px; + background: rgba(255, 255, 255, 0.72); +} + +.calendar-task--open { + border-left: 4px solid #2563eb; +} + +.calendar-task--soon { + border-left: 4px solid #f59e0b; +} + +.calendar-task--overdue { + border-left: 4px solid #e11d48; +} + +.calendar-task--completed { + border-left: 4px solid #059669; +} + +.score-row { + display: grid; + gap: 16px; +} + +.score-row--leader { + background: + linear-gradient(135deg, rgba(255, 255, 255, 0.98), rgba(246, 255, 249, 0.92)), + linear-gradient(135deg, rgba(52, 211, 153, 0.08), rgba(37, 99, 235, 0.08)); +} + +.score-row__person, +.archive-row__left { + display: inline-flex; + align-items: center; + gap: 12px; +} + +.score-row__points strong { + display: block; + font-size: clamp(1.8rem, 4vw, 2.8rem); + text-align: right; +} + +.score-bar { + height: 14px; + border-radius: 999px; + background: rgba(162, 182, 218, 0.22); + overflow: hidden; +} + +.reward-chip, +.rank-badge { + background: rgba(37, 99, 235, 0.1); + color: var(--primary-strong); +} + +.badge-setting-card { + display: grid; + gap: 12px; + padding: 18px; +} + +.push-box { + display: grid; + gap: 18px; +} + +.push-box__state { + align-items: flex-start; + padding: 16px; + border-radius: 18px; + background: rgba(255, 255, 255, 0.66); +} + +.push-box__state.is-disabled { + border: 1px dashed rgba(225, 29, 72, 0.26); +} + +.push-box__state.is-ready { + border: 1px solid rgba(5, 150, 105, 0.18); +} + +.auth-layout { + display: grid; + gap: 18px; +} + +.auth-panel { + max-width: 520px; +} + +.sidebar { + display: none; +} + +.bottom-nav { + position: fixed; + left: 14px; + right: 14px; + bottom: calc(10px + env(safe-area-inset-bottom)); + display: grid; + grid-template-columns: repeat(6, 1fr); + gap: 8px; + padding: 10px; + border-radius: 24px; + background: rgba(255, 255, 255, 0.88); + backdrop-filter: blur(16px); + box-shadow: 0 24px 44px rgba(58, 82, 128, 0.2); + border: 1px solid rgba(132, 152, 190, 0.22); + z-index: 40; +} + +.bottom-nav__item, +.nav-link { + display: grid; + justify-items: center; + gap: 6px; + padding: 10px 6px; + color: var(--muted); + border-radius: 16px; + text-align: center; + font-size: 0.73rem; + font-weight: 700; +} + +.bottom-nav__item.is-active, +.nav-link.is-active { + color: var(--primary-strong); + background: rgba(37, 99, 235, 0.1); +} + +.nav-icon, +.nav-icon svg { + width: 20px; + height: 20px; + display: inline-block; +} + +.complete-dialog { + border: 0; + padding: 0; + background: transparent; +} + +.complete-dialog::backdrop { + background: rgba(18, 31, 56, 0.36); + backdrop-filter: blur(6px); +} + +.complete-dialog__surface { + width: min(460px, calc(100vw - 24px)); + padding: 24px; + border-radius: 28px; + background: #fff; + box-shadow: var(--shadow); + display: grid; + gap: 18px; +} + +.choice-grid { + display: grid; + gap: 12px; +} + +.text-link { + color: var(--primary-strong); + font-weight: 700; +} + +@media (min-width: 760px) { + .page-shell { + padding: 28px 28px 32px; + } + + .hero-grid, + .two-column, + .auth-layout { + grid-template-columns: minmax(0, 1.5fr) minmax(280px, 0.95fr); + } + + .task-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .form-grid--two, + .badge-settings { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .calendar-grid { + grid-template-columns: repeat(7, minmax(0, 1fr)); + } + + .calendar-grid__weekdays { + display: grid; + grid-column: 1 / -1; + grid-template-columns: repeat(7, minmax(0, 1fr)); + gap: 12px; + color: var(--muted); + font-weight: 700; + padding: 0 4px; + } + + .calendar-day--empty { + display: block; + opacity: 0; + box-shadow: none; + background: transparent; + border: 0; + } +} + +@media (min-width: 1100px) { + .app-shell { + display: grid; + grid-template-columns: 290px minmax(0, 1fr); + } + + .sidebar { + position: sticky; + top: 0; + display: flex; + flex-direction: column; + gap: 24px; + height: 100vh; + padding: 28px 20px; + border-right: 1px solid rgba(132, 152, 190, 0.2); + background: rgba(248, 251, 255, 0.72); + backdrop-filter: blur(12px); + } + + .sidebar-nav { + display: grid; + gap: 8px; + } + + .nav-link { + grid-template-columns: auto 1fr; + justify-items: start; + text-align: left; + font-size: 0.94rem; + padding: 12px 14px; + } + + .page-shell { + padding: 36px 40px 48px; + } + + .topbar-user { + display: inline-flex; + align-items: center; + gap: 10px; + padding: 8px 10px 8px 18px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.85); + border: 1px solid rgba(132, 152, 190, 0.2); + } + + .bottom-nav { + display: none; + } + + .task-grid, + .scoreboard { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .form-grid--two { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} diff --git a/app/static/fonts/Inter_24pt-Bold.ttf b/app/static/fonts/Inter_24pt-Bold.ttf new file mode 100644 index 0000000..e974d96 Binary files /dev/null and b/app/static/fonts/Inter_24pt-Bold.ttf differ diff --git a/app/static/fonts/Inter_24pt-Medium.ttf b/app/static/fonts/Inter_24pt-Medium.ttf new file mode 100644 index 0000000..5c88739 Binary files /dev/null and b/app/static/fonts/Inter_24pt-Medium.ttf differ diff --git a/app/static/fonts/Inter_24pt-Regular.ttf b/app/static/fonts/Inter_24pt-Regular.ttf new file mode 100644 index 0000000..6b088a7 Binary files /dev/null and b/app/static/fonts/Inter_24pt-Regular.ttf differ diff --git a/app/static/fonts/Inter_24pt-SemiBold.ttf b/app/static/fonts/Inter_24pt-SemiBold.ttf new file mode 100644 index 0000000..ceb8576 Binary files /dev/null and b/app/static/fonts/Inter_24pt-SemiBold.ttf differ diff --git a/app/static/fonts/SpaceGrotesk-Bold.ttf b/app/static/fonts/SpaceGrotesk-Bold.ttf new file mode 100644 index 0000000..8a8611a Binary files /dev/null and b/app/static/fonts/SpaceGrotesk-Bold.ttf differ diff --git a/app/static/fonts/SpaceGrotesk-Regular.ttf b/app/static/fonts/SpaceGrotesk-Regular.ttf new file mode 100644 index 0000000..8215f81 Binary files /dev/null and b/app/static/fonts/SpaceGrotesk-Regular.ttf differ diff --git a/app/static/fonts/SpaceGrotesk-SemiBold.ttf b/app/static/fonts/SpaceGrotesk-SemiBold.ttf new file mode 100644 index 0000000..e05b967 Binary files /dev/null and b/app/static/fonts/SpaceGrotesk-SemiBold.ttf differ diff --git a/app/static/icons/bell.svg b/app/static/icons/bell.svg new file mode 100644 index 0000000..1af6d8c --- /dev/null +++ b/app/static/icons/bell.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/static/icons/calendar.svg b/app/static/icons/calendar.svg new file mode 100644 index 0000000..d1f1551 --- /dev/null +++ b/app/static/icons/calendar.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/static/icons/chart-bar.svg b/app/static/icons/chart-bar.svg new file mode 100644 index 0000000..2ca9792 --- /dev/null +++ b/app/static/icons/chart-bar.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/static/icons/check.svg b/app/static/icons/check.svg new file mode 100644 index 0000000..7609328 --- /dev/null +++ b/app/static/icons/check.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/static/icons/gear.svg b/app/static/icons/gear.svg new file mode 100644 index 0000000..60e3416 --- /dev/null +++ b/app/static/icons/gear.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/static/icons/house.svg b/app/static/icons/house.svg new file mode 100644 index 0000000..9a417a2 --- /dev/null +++ b/app/static/icons/house.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/static/icons/list.svg b/app/static/icons/list.svg new file mode 100644 index 0000000..086143f --- /dev/null +++ b/app/static/icons/list.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/static/icons/plus.svg b/app/static/icons/plus.svg new file mode 100644 index 0000000..271c1bc --- /dev/null +++ b/app/static/icons/plus.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/static/icons/trophy.svg b/app/static/icons/trophy.svg new file mode 100644 index 0000000..7770b14 --- /dev/null +++ b/app/static/icons/trophy.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/static/icons/user.svg b/app/static/icons/user.svg new file mode 100644 index 0000000..86dffc1 --- /dev/null +++ b/app/static/icons/user.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/static/icons/users.svg b/app/static/icons/users.svg new file mode 100644 index 0000000..88bbea2 --- /dev/null +++ b/app/static/icons/users.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/static/images/apple-touch-icon.png b/app/static/images/apple-touch-icon.png new file mode 100644 index 0000000..2fdc9d2 Binary files /dev/null and b/app/static/images/apple-touch-icon.png differ diff --git a/app/static/images/avatars/anna.svg b/app/static/images/avatars/anna.svg new file mode 100644 index 0000000..0a2cdb0 --- /dev/null +++ b/app/static/images/avatars/anna.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + A + diff --git a/app/static/images/avatars/ben.svg b/app/static/images/avatars/ben.svg new file mode 100644 index 0000000..4d51bc8 --- /dev/null +++ b/app/static/images/avatars/ben.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + B + diff --git a/app/static/images/avatars/default.svg b/app/static/images/avatars/default.svg new file mode 100644 index 0000000..fb48a1c --- /dev/null +++ b/app/static/images/avatars/default.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/app/static/images/favicon.svg b/app/static/images/favicon.svg new file mode 100644 index 0000000..7ce8f12 --- /dev/null +++ b/app/static/images/favicon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/app/static/images/logo.svg b/app/static/images/logo.svg new file mode 100644 index 0000000..0ff4dd6 --- /dev/null +++ b/app/static/images/logo.svg @@ -0,0 +1,20 @@ + + Putzliga Logo + Ein rundes Signet mit Haus, Schild und Stern für Putzliga. + + + + + + + + + + + + + + + + + diff --git a/app/static/images/pwa-badge.png b/app/static/images/pwa-badge.png new file mode 100644 index 0000000..e2b7ae3 Binary files /dev/null and b/app/static/images/pwa-badge.png differ diff --git a/app/static/images/pwa-icon-192.png b/app/static/images/pwa-icon-192.png new file mode 100644 index 0000000..4a40b0c Binary files /dev/null and b/app/static/images/pwa-icon-192.png differ diff --git a/app/static/images/pwa-icon-512.png b/app/static/images/pwa-icon-512.png new file mode 100644 index 0000000..dc832a8 Binary files /dev/null and b/app/static/images/pwa-icon-512.png differ diff --git a/app/static/js/app.js b/app/static/js/app.js new file mode 100644 index 0000000..49da34a --- /dev/null +++ b/app/static/js/app.js @@ -0,0 +1,104 @@ +(function () { + const dialog = document.getElementById("completeDialog"); + const dialogForm = document.getElementById("completeDialogForm"); + const dialogChoice = document.getElementById("completeDialogChoice"); + const dialogText = document.getElementById("completeDialogText"); + const closeButton = document.getElementById("completeDialogClose"); + + document.querySelectorAll("[data-complete-action]").forEach((button) => { + button.addEventListener("click", () => { + if (!dialog || !dialogForm || !dialogChoice || !dialogText) { + return; + } + dialogForm.action = button.dataset.completeAction; + dialogText.textContent = `Die Aufgabe "${button.dataset.completeTitle}" war ${button.dataset.completeAssigned} zugewiesen. Wer hat sie erledigt?`; + dialog.showModal(); + }); + }); + + document.querySelectorAll("[data-complete-choice]").forEach((button) => { + button.addEventListener("click", () => { + dialogChoice.value = button.dataset.completeChoice || "me"; + dialog.close(); + dialogForm.submit(); + }); + }); + + if (closeButton && dialog) { + closeButton.addEventListener("click", () => dialog.close()); + } + + const pushButton = document.getElementById("pushToggle"); + const pushHint = document.getElementById("pushHint"); + const vapidKey = document.body.dataset.pushKey; + const isIos = /iphone|ipad|ipod/i.test(window.navigator.userAgent); + const isStandalone = + window.matchMedia("(display-mode: standalone)").matches || + window.navigator.standalone === true; + + function urlBase64ToUint8Array(base64String) { + const padding = "=".repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/"); + const raw = atob(base64); + return Uint8Array.from([...raw].map((char) => char.charCodeAt(0))); + } + + async function postJSON(url, payload) { + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + return response.json(); + } + + async function togglePush() { + if (!("serviceWorker" in navigator) || !("PushManager" in window) || !pushButton) { + return; + } + + const registration = await navigator.serviceWorker.register("/service-worker.js"); + const existing = await registration.pushManager.getSubscription(); + + if (existing) { + await postJSON("/settings/push/unsubscribe", { endpoint: existing.endpoint }); + await existing.unsubscribe(); + pushButton.dataset.subscribed = "0"; + pushButton.textContent = "Push aktivieren"; + return; + } + + const permission = await Notification.requestPermission(); + if (permission !== "granted") { + return; + } + + const subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(vapidKey), + }); + await postJSON("/settings/push/subscribe", subscription.toJSON()); + pushButton.dataset.subscribed = "1"; + pushButton.textContent = "Push deaktivieren"; + } + + if ("serviceWorker" in navigator) { + navigator.serviceWorker.register("/service-worker.js").catch(() => {}); + } + + if (pushButton && (!("serviceWorker" in navigator) || !("PushManager" in window))) { + pushButton.disabled = true; + if (pushHint) { + pushHint.textContent = "Dieser Browser unterstützt Web-Push hier aktuell nicht."; + } + } else if (pushButton && isIos && !isStandalone) { + pushButton.disabled = true; + if (pushHint) { + pushHint.textContent = "Auf iPhone/iPad funktioniert Web-Push erst nach „Zum Home-Bildschirm“ und Öffnen als Web-App."; + } + } else if (pushButton && vapidKey) { + pushButton.addEventListener("click", () => { + togglePush().catch((error) => console.error("Push toggle failed", error)); + }); + } +})(); diff --git a/app/static/manifest.json b/app/static/manifest.json new file mode 100644 index 0000000..a0d5786 --- /dev/null +++ b/app/static/manifest.json @@ -0,0 +1,25 @@ +{ + "name": "Putzliga", + "short_name": "Putzliga", + "description": "Spielerische Haushalts-App mit Aufgaben, Punkten und Monats-Highscore.", + "id": "/", + "start_url": "/my-tasks", + "scope": "/", + "display": "standalone", + "background_color": "#eef3ff", + "theme_color": "#f5f7ff", + "lang": "de", + "icons": [ + { + "src": "/static/images/pwa-icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/static/images/pwa-icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] +} diff --git a/app/static/service-worker.js b/app/static/service-worker.js new file mode 100644 index 0000000..e084201 --- /dev/null +++ b/app/static/service-worker.js @@ -0,0 +1,71 @@ +const CACHE_NAME = "putzliga-shell-v1"; +const ASSETS = [ + "/my-tasks", + "/static/css/style.css", + "/static/js/app.js", + "/static/images/logo.svg", + "/static/images/pwa-icon-192.png", + "/static/images/pwa-icon-512.png" +]; + +self.addEventListener("install", (event) => { + event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(ASSETS))); + self.skipWaiting(); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches.keys().then((keys) => + Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))) + ) + ); + self.clients.claim(); +}); + +self.addEventListener("fetch", (event) => { + if (event.request.method !== "GET") return; + event.respondWith( + caches.match(event.request).then((cached) => { + if (cached) return cached; + return fetch(event.request) + .then((response) => { + const clone = response.clone(); + caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone)); + return response; + }) + .catch(() => caches.match("/my-tasks")); + }) + ); +}); + +self.addEventListener("push", (event) => { + const payload = event.data ? event.data.json() : {}; + const title = payload.title || "Putzliga"; + event.waitUntil( + self.registration.showNotification(title, { + body: payload.body || "Es gibt Neuigkeiten in der Putzliga.", + icon: payload.icon || "/static/images/pwa-icon-192.png", + badge: payload.badge || "/static/images/pwa-badge.png", + tag: payload.tag || "putzliga", + data: { url: payload.url || "/my-tasks" } + }) + ); +}); + +self.addEventListener("notificationclick", (event) => { + event.notification.close(); + const targetUrl = event.notification.data?.url || "/my-tasks"; + event.waitUntil( + clients.matchAll({ type: "window", includeUncontrolled: true }).then((clientList) => { + for (const client of clientList) { + if ("focus" in client) { + client.navigate(targetUrl); + return client.focus(); + } + } + if (clients.openWindow) { + return clients.openWindow(targetUrl); + } + }) + ); +}); diff --git a/app/templates/auth/login.html b/app/templates/auth/login.html new file mode 100644 index 0000000..dda1ba3 --- /dev/null +++ b/app/templates/auth/login.html @@ -0,0 +1,48 @@ +{% extends "base.html" %} +{% block title %}Login · Putzliga{% endblock %} +{% block content %} +
+
+

Leichtgewichtige Haushalts-App

+

Putzliga bringt Punkte, Rhythmus und ein bisschen Liga-Stimmung in den Alltag.

+

Mehrere Nutzer, wiederkehrende Aufgaben, Monats-Highscore, Archiv, Kalender und PWA-Pushs in einer bewusst schlanken Flask-App.

+
+
+ Mobile first + Bottom Navigation wie in einer iPhone-App +
+
+ Fair verteilt + Punkte landen bei der Person, die wirklich erledigt hat +
+
+ Erweiterbar + SQLite, Flask, Jinja und saubere Services statt Overengineering +
+
+
+ +
+

Einloggen

+

Willkommen zurück

+
+ {{ form.hidden_tag() }} +
+ {{ form.email.label }} + {{ form.email(placeholder="anna@putzliga.local") }} + {% for error in form.email.errors %}{{ error }}{% endfor %} +
+
+ {{ form.password.label }} + {{ form.password(placeholder="Passwort") }} + {% for error in form.password.errors %}{{ error }}{% endfor %} +
+ + {{ form.submit(class_="button button--wide") }} +
+

Demo-Logins nach dem Seeden: `anna@putzliga.local` / `putzliga123` und `ben@putzliga.local` / `putzliga123`.

+

Noch kein Konto? Neu registrieren

+
+
+{% endblock %} + diff --git a/app/templates/auth/register.html b/app/templates/auth/register.html new file mode 100644 index 0000000..1c9c05b --- /dev/null +++ b/app/templates/auth/register.html @@ -0,0 +1,42 @@ +{% extends "base.html" %} +{% block title %}Registrieren · Putzliga{% endblock %} +{% block content %} +
+
+

Gemeinsam sauberer

+

Erstelle dein Konto und steig direkt in die Liga ein.

+

Nach dem Login landest du sofort bei „Meine Aufgaben“ und kannst Aufgaben anlegen, verteilen und Punkte sammeln.

+
+ +
+

Registrieren

+

Neues Konto

+
+ {{ form.hidden_tag() }} +
+ {{ form.name.label }} + {{ form.name(placeholder="Dein Name") }} + {% for error in form.name.errors %}{{ error }}{% endfor %} +
+
+ {{ form.email.label }} + {{ form.email(placeholder="mail@example.com") }} + {% for error in form.email.errors %}{{ error }}{% endfor %} +
+
+ {{ form.password.label }} + {{ form.password(placeholder="Mindestens 6 Zeichen") }} + {% for error in form.password.errors %}{{ error }}{% endfor %} +
+
+ {{ form.password_confirm.label }} + {{ form.password_confirm(placeholder="Passwort wiederholen") }} + {% for error in form.password_confirm.errors %}{{ error }}{% endfor %} +
+ {{ form.submit(class_="button button--wide") }} +
+

Schon dabei? Zum Login

+
+
+{% endblock %} + diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..5fd9f9f --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,121 @@ + + + + + + + + + + + {% block title %}{{ app_name }}{% endblock %} + + + + + + + {% from "partials/macros.html" import nav_icon %} +
+ {% if current_user.is_authenticated %} + + {% endif %} + +
+
+ {% if current_user.is_authenticated %} +
+

Spielerisch sauber bleiben

+

{% block page_title %}{{ app_name }}{% endblock %}

+
+ + {{ current_user.name }} + + + {% else %} + + +
+ Putzliga + Haushaltsaufgaben mit Liga-Gefühl +
+
+ {% endif %} +
+ +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} + {% endwith %} + + {% block content %}{% endblock %} +
+
+
+ + {% if current_user.is_authenticated %} + + + +
+

Punkte fair verbuchen

+

Wer hat diese Aufgabe erledigt?

+

Bitte wähle aus, wem die Punkte gutgeschrieben werden sollen.

+
+ + +
+ +
+
+ +
+ + +
+ {% endif %} + + + + diff --git a/app/templates/partials/macros.html b/app/templates/partials/macros.html new file mode 100644 index 0000000..bbd3abb --- /dev/null +++ b/app/templates/partials/macros.html @@ -0,0 +1,92 @@ +{% macro nav_icon(name) -%} + {{ icon_svg(name)|safe }} +{%- endmacro %} + +{% macro status_badge(task) -%} + {{ task.status_label }} +{%- endmacro %} + +{% macro avatar(user, size='') -%} + {% if user %} + {% if user.avatar_path and user.avatar_path.startswith('avatars/') %} + Avatar von {{ user.name }} + {% else %} + Avatar von {{ user.name }} + {% endif %} + {% endif %} +{%- endmacro %} + +{% macro task_card(task, current_user, compact=false) -%} +
+
+
+
+ {{ status_badge(task) }} + {{ task.points_awarded }} Punkte +
+

{{ task.title }}

+
+ + {{ nav_icon('gear') }} + +
+ + {% if task.description %} +

{{ task.description }}

+ {% endif %} + +
+
+
Fällig
+
{{ task.due_date|date_de }}
+
+
+
Zuständig
+
{{ task.assigned_user.name if task.assigned_user else 'Unverteilt' }}
+
+
+
Rhythmus
+
{{ task.task_template.recurrence_label }}
+
+ {% if task.completed_at %} +
+
Erledigt von
+
{{ task.completed_by_user.name if task.completed_by_user else '—' }}
+
+ {% endif %} +
+ + +
+{%- endmacro %} diff --git a/app/templates/scoreboard/index.html b/app/templates/scoreboard/index.html new file mode 100644 index 0000000..351d037 --- /dev/null +++ b/app/templates/scoreboard/index.html @@ -0,0 +1,84 @@ +{% extends "base.html" %} +{% from "partials/macros.html" import avatar %} +{% block title %}Highscoreboard · Putzliga{% endblock %} +{% block page_title %}Highscoreboard{% endblock %} +{% block content %} +
+
+

Aktueller Monat

+

{{ current_label }}

+

Aufgabenpunkte und Badge-Boni zählen nur im aktuellen Monat. Zum Monatswechsel landet alles im Archiv und die Liga startet wieder bei null.

+
+
+

Archiv

+
+ + +
+
+
+ +
+ {% for row in current_rows %} +
+
+
+ #{{ row.rank }} + {{ avatar(row.user) }} +
+ {{ row.user.name }} +

{{ row.completed_tasks_count }} erledigte Aufgaben

+
+
+
+ {{ row.total_points }} + Punkte +
+
+
+ +
+
+ Basis: {{ row.base_points }} + Badges: +{{ row.bonus_points }} +
+ {% if row.badges %} +
+ {% for badge in row.badges %} + {{ badge.definition.name }} +{{ badge.bonus_points }} + {% endfor %} +
+ {% endif %} +
+ {% else %} +
Noch keine Punkte in diesem Monat.
+ {% endfor %} +
+ +
+

Monatsarchiv

+

{{ archive_label or 'Noch kein Archiv' }}

+
+ {% for row in archived_rows %} +
+
+ #{{ row.rank }} + {{ avatar(row.user) }} + {{ row.user.name }} +
+
+ {{ row.total_points }} Punkte + {{ row.completed_tasks_count }} Aufgaben +
+
+ {% else %} +
Sobald ein Monat archiviert wurde, taucht er hier auf.
+ {% endfor %} +
+
+{% endblock %} diff --git a/app/templates/settings/index.html b/app/templates/settings/index.html new file mode 100644 index 0000000..eee5611 --- /dev/null +++ b/app/templates/settings/index.html @@ -0,0 +1,100 @@ +{% extends "base.html" %} +{% from "partials/macros.html" import avatar, nav_icon %} +{% block title %}Optionen · Putzliga{% endblock %} +{% block page_title %}Optionen{% endblock %} +{% block content %} +
+
+

Profil & Benachrichtigungen

+

Persönliche Einstellungen

+
+ {{ form.hidden_tag() }} +
+ {{ form.name.label }} + {{ form.name() }} + {% for error in form.name.errors %}{{ error }}{% endfor %} +
+
+ {{ form.email.label }} + {{ form.email() }} + {% for error in form.email.errors %}{{ error }}{% endfor %} +
+
+ {{ form.password.label }} + {{ form.password(placeholder="Leer lassen, wenn nichts geändert werden soll") }} + {% for error in form.password.errors %}{{ error }}{% endfor %} +
+
+ {{ form.avatar.label }} + {{ form.avatar() }} + {% for error in form.avatar.errors %}{{ error }}{% endfor %} +
+ + + {{ form.submit(class_='button') }} +
+
+ +
+

Push & App-Install

+

Web-Push vorbereiten

+

Putzliga nutzt echten Web-Push mit Service Worker und gespeicherten Subscriptions. Auf iPhone funktioniert das nur, wenn die App zum Home-Bildschirm hinzugefügt wurde.

+
+
+ {{ nav_icon('bell') }} +
+ {% if push_ready %}VAPID konfiguriert{% else %}VAPID fehlt{% endif %} +

{% if push_ready %}Push kann im Browser aktiviert werden.{% else %}Bitte zuerst Public/Private Key in der Umgebung setzen.{% endif %}

+
+
+ +

+ Auf iPhone/iPad bitte zuerst in Safari zum Home-Bildschirm hinzufügen und als Web-App öffnen. +

+
+
+
+ +
+

Gamification

+

Badge-Regeln pflegen

+
+ {% for badge in badges %} +
+ +
+ {{ badge.name }} +

{{ badge.description }}

+
+
+ + +
+
+ + +
+ + +
+ {% endfor %} +
+
+{% endblock %} diff --git a/app/templates/tasks/all_tasks.html b/app/templates/tasks/all_tasks.html new file mode 100644 index 0000000..d5fc1cf --- /dev/null +++ b/app/templates/tasks/all_tasks.html @@ -0,0 +1,51 @@ +{% extends "base.html" %} +{% from "partials/macros.html" import task_card %} +{% block title %}Alle Aufgaben · Putzliga{% endblock %} +{% block page_title %}Alle Aufgaben{% endblock %} +{% block content %} +
+
+
+ + +
+
+ + +
+
+ + +
+ + +
+
+ +
+ {% for task in tasks %} + {{ task_card(task, current_user) }} + {% else %} +
Für diese Filter gibt es gerade keine Aufgaben.
+ {% endfor %} +
+{% endblock %} + diff --git a/app/templates/tasks/calendar.html b/app/templates/tasks/calendar.html new file mode 100644 index 0000000..e58abf5 --- /dev/null +++ b/app/templates/tasks/calendar.html @@ -0,0 +1,66 @@ +{% extends "base.html" %} +{% from "partials/macros.html" import status_badge %} +{% block title %}Kalender · Putzliga{% endblock %} +{% block page_title %}Kalender & Liste{% endblock %} +{% block content %} +
+
+

Monatsansicht

+

{{ current_label }}

+
+
+ Zurück + Weiter +
+ Kalender + Liste +
+
+
+ + {% if view == 'calendar' %} +
+
+ {% for label in ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] %} + {{ label }} + {% endfor %} +
+ {% for week in month_calendar %} + {% for day in week %} + + {% endfor %} + {% endfor %} +
+ {% else %} +
+ {% for task in tasks %} +
+
+ {{ task.title }} +

{{ task.description or 'Ohne Zusatzbeschreibung' }}

+
+
+ {{ status_badge(task) }} + {{ task.due_date|date_de }} + Bearbeiten +
+
+ {% else %} +
In diesem Monat sind noch keine Aufgaben hinterlegt.
+ {% endfor %} +
+ {% endif %} +{% endblock %} + diff --git a/app/templates/tasks/my_tasks.html b/app/templates/tasks/my_tasks.html new file mode 100644 index 0000000..7cdc8c5 --- /dev/null +++ b/app/templates/tasks/my_tasks.html @@ -0,0 +1,88 @@ +{% extends "base.html" %} +{% from "partials/macros.html" import task_card, nav_icon %} +{% block title %}Meine Aufgaben · Putzliga{% endblock %} +{% block page_title %}Meine Aufgaben{% endblock %} +{% block content %} +
+
+

Heute im Fokus

+

{{ current_user.name }}, deine Liga für den Haushalt läuft.

+

Erledige offene Aufgaben, sammle Punkte und halte deinen Monatslauf stabil.

+
+
+ Erledigungsquote + {{ completion_ratio }}% +
+
+
+
+ +
+ +
+
+

Überfällig

+ {{ sections.overdue|length }} +
+
+ {% for task in sections.overdue %} + {{ task_card(task, current_user) }} + {% else %} +
Nichts überfällig. Genau so darf es bleiben.
+ {% endfor %} +
+
+ +
+
+

Bald fällig

+ {{ sections.soon|length }} +
+
+ {% for task in sections.soon %} + {{ task_card(task, current_user) }} + {% else %} +
Gerade nichts, was in den nächsten Tagen drängt.
+ {% endfor %} +
+
+ +
+
+

Offen

+ {{ sections.open|length }} +
+
+ {% for task in sections.open %} + {{ task_card(task, current_user) }} + {% else %} +
Alles leer. Zeit für eine kleine Siegerrunde.
+ {% endfor %} +
+
+ +
+
+

Erledigt

+ {{ sections.completed|length }} +
+
+ {% for task in sections.completed %} + {{ task_card(task, current_user, compact=true) }} + {% else %} +
Noch keine erledigten Aufgaben in deiner Liste.
+ {% endfor %} +
+
+{% endblock %} + diff --git a/app/templates/tasks/task_form.html b/app/templates/tasks/task_form.html new file mode 100644 index 0000000..d1194de --- /dev/null +++ b/app/templates/tasks/task_form.html @@ -0,0 +1,64 @@ +{% extends "base.html" %} +{% block title %}{% if mode == 'edit' %}Aufgabe bearbeiten{% else %}Aufgabe erstellen{% endif %} · Putzliga{% endblock %} +{% block page_title %}{% if mode == 'edit' %}Aufgabe bearbeiten{% else %}Aufgabe erstellen{% endif %}{% endblock %} +{% block content %} +
+

{% if mode == 'edit' %}Bestehende Aufgabe anpassen{% else %}Neue Aufgabe und Vorlage{% endif %}

+

{% if mode == 'edit' %}Änderungen für {{ task.title }}{% else %}Neue Aufgabe anlegen{% endif %}

+
+ {{ form.hidden_tag() }} + +
+ {{ form.title.label }} + {{ form.title(placeholder="Zum Beispiel: Küche wischen") }} + {% for error in form.title.errors %}{{ error }}{% endfor %} +
+ +
+ {{ form.description.label }} + {{ form.description(rows="4", placeholder="Optional: kurze Hinweise zur Aufgabe") }} + {% for error in form.description.errors %}{{ error }}{% endfor %} +
+ +
+ {{ form.default_points.label }} + {{ form.default_points() }} + {% for error in form.default_points.errors %}{{ error }}{% endfor %} +
+ +
+ {{ form.assigned_user_id.label }} + {{ form.assigned_user_id() }} + {% for error in form.assigned_user_id.errors %}{{ error }}{% endfor %} +
+ +
+ {{ form.due_date.label }} + {{ form.due_date() }} + {% for error in form.due_date.errors %}{{ error }}{% endfor %} +
+ +
+ {{ form.recurrence_interval_unit.label }} + {{ form.recurrence_interval_unit() }} +
+ +
+ {{ form.recurrence_interval_value.label }} + {{ form.recurrence_interval_value() }} + {% for error in form.recurrence_interval_value.errors %}{{ error }}{% endfor %} +
+ + + +
+ {{ form.submit(class_='button') }} + Abbrechen +
+
+
+{% endblock %} + diff --git a/config.py b/config.py new file mode 100644 index 0000000..9337470 --- /dev/null +++ b/config.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import os +from pathlib import Path + + +BASE_DIR = Path(__file__).resolve().parent + + +class Config: + APP_NAME = "Putzliga" + SECRET_KEY = os.getenv("SECRET_KEY", "dev-secret-change-me") + SQLALCHEMY_TRACK_MODIFICATIONS = False + WTF_CSRF_TIME_LIMIT = None + + DATA_DIR = Path(os.getenv("DATA_DIR", BASE_DIR / "data")).resolve() + SQLITE_PATH = Path(os.getenv("DATABASE_PATH", DATA_DIR / "putzliga.db")).resolve() + SQLALCHEMY_DATABASE_URI = f"sqlite:///{SQLITE_PATH}" + + UPLOAD_FOLDER = Path(os.getenv("UPLOAD_FOLDER", DATA_DIR / "uploads")).resolve() + MAX_CONTENT_LENGTH = 3 * 1024 * 1024 + + APP_BASE_URL = os.getenv("APP_BASE_URL") or os.getenv("CLOUDRON_APP_ORIGIN", "http://localhost:8000") + APP_TIMEZONE = os.getenv("APP_TIMEZONE", "Europe/Berlin") + PORT = int(os.getenv("PORT", "8000")) + + VAPID_PUBLIC_KEY = os.getenv("VAPID_PUBLIC_KEY", "") + VAPID_PRIVATE_KEY = os.getenv("VAPID_PRIVATE_KEY", "").replace("\\n", "\n") + VAPID_CLAIMS_SUBJECT = os.getenv("VAPID_CLAIMS_SUBJECT", "mailto:admin@example.com") + + SESSION_COOKIE_SAMESITE = "Lax" + REMEMBER_COOKIE_SAMESITE = "Lax" diff --git a/icon.png b/icon.png new file mode 100644 index 0000000..93cc85d Binary files /dev/null and b/icon.png differ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..17f8ff3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +Flask==3.1.0 +Flask-Login==0.6.3 +Flask-SQLAlchemy==3.1.1 +Flask-WTF==1.2.2 +email-validator==2.2.0 +gunicorn==23.0.0 +pywebpush==2.0.3 +python-dotenv==1.0.1 diff --git a/scripts/generate_assets.py b/scripts/generate_assets.py new file mode 100644 index 0000000..c1fb85a --- /dev/null +++ b/scripts/generate_assets.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import struct +import zlib +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] / "app" / "static" / "images" + + +def png_chunk(tag: bytes, data: bytes) -> bytes: + return struct.pack("!I", len(data)) + tag + data + struct.pack("!I", zlib.crc32(tag + data) & 0xFFFFFFFF) + + +def save_png(path: Path, width: int, height: int, rgba_rows: list[bytes]) -> None: + raw = b"".join(b"\x00" + row for row in rgba_rows) + header = struct.pack("!IIBBBBB", width, height, 8, 6, 0, 0, 0) + content = b"".join( + [ + b"\x89PNG\r\n\x1a\n", + png_chunk(b"IHDR", header), + png_chunk(b"IDAT", zlib.compress(raw, 9)), + png_chunk(b"IEND", b""), + ] + ) + path.write_bytes(content) + + +def lerp(a: int, b: int, t: float) -> int: + return int(a + (b - a) * t) + + +def rounded_square_icon(size: int) -> list[bytes]: + rows = [] + radius = size * 0.22 + for y in range(size): + row = bytearray() + for x in range(size): + dx = min(x, size - 1 - x) + dy = min(y, size - 1 - y) + in_corner = (dx < radius and dy < radius) + if in_corner: + cx = radius - dx + cy = radius - dy + inside = cx * cx + cy * cy <= radius * radius + else: + inside = True + + if not inside: + row.extend((0, 0, 0, 0)) + continue + + t = (x + y) / (2 * size) + r = lerp(37, 52, t) + g = lerp(99, 211, t) + b = lerp(235, 153, t) + + if (x - size * 0.5) ** 2 + (y - size * 0.48) ** 2 < (size * 0.33) ** 2: + r, g, b = 250, 252, 255 + + roof = abs(x - size * 0.5) + (y - size * 0.40) + if roof < size * 0.19 and y < size * 0.55: + r, g, b = 37, 99, 235 + + if size * 0.33 < x < size * 0.67 and size * 0.52 < y < size * 0.80: + r, g, b = 159, 208, 255 + + star_x = x - size * 0.68 + star_y = y - size * 0.28 + if abs(star_x) + abs(star_y) < size * 0.06 or (abs(star_x) < size * 0.018 or abs(star_y) < size * 0.018): + r, g, b = 245, 158, 11 + + row.extend((r, g, b, 255)) + rows.append(bytes(row)) + return rows + + +def badge_icon(size: int) -> list[bytes]: + rows = [] + radius = size * 0.48 + cx = cy = size / 2 + for y in range(size): + row = bytearray() + for x in range(size): + if (x - cx) ** 2 + (y - cy) ** 2 > radius ** 2: + row.extend((0, 0, 0, 0)) + continue + row.extend((37, 99, 235, 255)) + rows.append(bytes(row)) + return rows + + +def main() -> None: + ROOT.mkdir(parents=True, exist_ok=True) + save_png(ROOT / "pwa-icon-192.png", 192, 192, rounded_square_icon(192)) + save_png(Path(__file__).resolve().parents[1] / "icon.png", 256, 256, rounded_square_icon(256)) + save_png(ROOT / "pwa-icon-512.png", 512, 512, rounded_square_icon(512)) + save_png(ROOT / "apple-touch-icon.png", 180, 180, rounded_square_icon(180)) + save_png(ROOT / "pwa-badge.png", 96, 96, badge_icon(96)) + + +if __name__ == "__main__": + main() diff --git a/scripts/generate_vapid_keys.py b/scripts/generate_vapid_keys.py new file mode 100644 index 0000000..6ca96e7 --- /dev/null +++ b/scripts/generate_vapid_keys.py @@ -0,0 +1,15 @@ +import base64 + +from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat +from py_vapid import Vapid01 + + +private_key = Vapid01() +private_key.generate_keys() + +public_key = private_key.public_key.public_bytes(Encoding.X962, PublicFormat.UncompressedPoint) +public_key_b64 = base64.urlsafe_b64encode(public_key).rstrip(b"=").decode() +private_key_env = private_key.private_pem().decode().strip().replace("\n", "\\n") + +print("VAPID_PUBLIC_KEY=" + public_key_b64) +print("VAPID_PRIVATE_KEY=" + private_key_env) diff --git a/seed.py b/seed.py new file mode 100644 index 0000000..301a65e --- /dev/null +++ b/seed.py @@ -0,0 +1,182 @@ +from __future__ import annotations + +from datetime import datetime, timedelta + +from app import create_app +from app.cli import seed_badges +from app.extensions import db +from app.models import TaskInstance, TaskTemplate, User +from app.services.monthly import archive_months_missing_up_to_previous + + +def seed_database() -> None: + app = create_app() + with app.app_context(): + db.drop_all() + db.create_all() + seed_badges() + + anna = User( + name="Anna", + email="anna@putzliga.local", + avatar_path="images/avatars/anna.svg", + notification_task_due_enabled=True, + notification_monthly_winner_enabled=True, + ) + anna.set_password("putzliga123") + ben = User( + name="Ben", + email="ben@putzliga.local", + avatar_path="images/avatars/ben.svg", + notification_task_due_enabled=True, + notification_monthly_winner_enabled=False, + ) + ben.set_password("putzliga123") + + db.session.add_all([anna, ben]) + db.session.flush() + + templates = [ + TaskTemplate( + title="Küche wischen", + description="Arbeitsfläche, Herd und Tisch sauber machen.", + default_points=12, + default_assigned_user_id=anna.id, + recurrence_interval_value=3, + recurrence_interval_unit="days", + active=True, + ), + TaskTemplate( + title="Bad putzen", + description="Waschbecken, Spiegel und Dusche reinigen.", + default_points=20, + default_assigned_user_id=ben.id, + recurrence_interval_value=1, + recurrence_interval_unit="weeks", + active=True, + ), + TaskTemplate( + title="Müll runterbringen", + description="Restmüll, Papier und Bio entsorgen.", + default_points=8, + default_assigned_user_id=anna.id, + recurrence_interval_value=1, + recurrence_interval_unit="weeks", + active=True, + ), + TaskTemplate( + title="Fensterbank entstauben", + description="Wohnzimmer und Flur mitnehmen.", + default_points=6, + default_assigned_user_id=ben.id, + recurrence_interval_unit="none", + active=True, + ), + TaskTemplate( + title="Bettwäsche wechseln", + description="Neue Bettwäsche aufziehen.", + default_points=15, + default_assigned_user_id=anna.id, + recurrence_interval_value=1, + recurrence_interval_unit="months", + active=True, + ), + ] + db.session.add_all(templates) + db.session.flush() + + now = datetime.now() + current_month_anchor = now.replace(day=5, hour=10, minute=0, second=0, microsecond=0) + previous_month_anchor = (current_month_anchor.replace(day=1) - timedelta(days=3)).replace(day=10) + + instances = [ + TaskInstance( + task_template_id=templates[0].id, + title=templates[0].title, + description=templates[0].description, + assigned_user_id=anna.id, + due_date=(now + timedelta(days=1)).date(), + status="soon", + points_awarded=12, + ), + TaskInstance( + task_template_id=templates[1].id, + title=templates[1].title, + description=templates[1].description, + assigned_user_id=ben.id, + due_date=(now - timedelta(days=1)).date(), + status="overdue", + points_awarded=20, + ), + TaskInstance( + task_template_id=templates[2].id, + title=templates[2].title, + description=templates[2].description, + assigned_user_id=anna.id, + due_date=(now + timedelta(days=4)).date(), + status="open", + points_awarded=8, + ), + TaskInstance( + task_template_id=templates[3].id, + title=templates[3].title, + description=templates[3].description, + assigned_user_id=ben.id, + due_date=(now + timedelta(days=2)).date(), + status="soon", + points_awarded=6, + ), + TaskInstance( + task_template_id=templates[4].id, + title=templates[4].title, + description=templates[4].description, + assigned_user_id=anna.id, + due_date=(now - timedelta(days=9)).date(), + status="completed", + completed_at=current_month_anchor - timedelta(days=2), + completed_by_user_id=anna.id, + points_awarded=15, + ), + TaskInstance( + task_template_id=templates[1].id, + title=templates[1].title, + description=templates[1].description, + assigned_user_id=ben.id, + due_date=(previous_month_anchor - timedelta(days=1)).date(), + status="completed", + completed_at=previous_month_anchor, + completed_by_user_id=ben.id, + points_awarded=20, + ), + TaskInstance( + task_template_id=templates[0].id, + title=templates[0].title, + description=templates[0].description, + assigned_user_id=anna.id, + due_date=(previous_month_anchor - timedelta(days=2)).date(), + status="completed", + completed_at=previous_month_anchor - timedelta(days=1), + completed_by_user_id=anna.id, + points_awarded=12, + ), + TaskInstance( + task_template_id=templates[2].id, + title=templates[2].title, + description=templates[2].description, + assigned_user_id=anna.id, + due_date=(previous_month_anchor + timedelta(days=2)).date(), + status="completed", + completed_at=previous_month_anchor + timedelta(days=2), + completed_by_user_id=anna.id, + points_awarded=8, + ), + ] + db.session.add_all(instances) + db.session.commit() + + archive_months_missing_up_to_previous() + print("Seed-Daten geschrieben.") + + +if __name__ == "__main__": + seed_database() diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..080e37f --- /dev/null +++ b/start.sh @@ -0,0 +1,21 @@ +#!/bin/sh +set -eu + +export PYTHONUNBUFFERED=1 +APP_ROOT="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" + +export DATA_DIR="${DATA_DIR:-$APP_ROOT/data}" +export DATABASE_PATH="${DATABASE_PATH:-$DATA_DIR/putzliga.db}" +export UPLOAD_FOLDER="${UPLOAD_FOLDER:-$DATA_DIR/uploads}" +export PORT="${PORT:-8000}" + +mkdir -p "$DATA_DIR" "$UPLOAD_FOLDER" + +flask --app app.py init-db + +exec gunicorn \ + --bind "0.0.0.0:${PORT}" \ + --workers "${GUNICORN_WORKERS:-2}" \ + --threads "${GUNICORN_THREADS:-2}" \ + --timeout "${GUNICORN_TIMEOUT:-120}" \ + "wsgi:app" diff --git a/wsgi.py b/wsgi.py new file mode 100644 index 0000000..4d256fe --- /dev/null +++ b/wsgi.py @@ -0,0 +1,4 @@ +from app import create_app + + +app = create_app()