first commit

This commit is contained in:
2026-04-13 08:32:28 +02:00
commit 1074a91487
72 changed files with 4078 additions and 0 deletions

14
.dockerignore Normal file
View File

@@ -0,0 +1,14 @@
__pycache__/
*.pyc
*.pyo
*.pyd
.Python
.env
.venv/
venv/
dist/
build/
.git/
data/
instance/
node_modules/

11
.env.example Normal file
View File

@@ -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

19
.gitignore vendored Normal file
View File

@@ -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

24
CloudronManifest.json Normal file
View File

@@ -0,0 +1,24 @@
{
"id": "io.putzliga.app",
"title": "Putzliga",
"author": "hnzio <mail@example.com>",
"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
}

16
Dockerfile Normal file
View File

@@ -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"]

285
README.md Normal file
View File

@@ -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

9
app.py Normal file
View File

@@ -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)

72
app/__init__.py Normal file
View File

@@ -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

71
app/cli.py Normal file
View File

@@ -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}")

12
app/extensions.py Normal file
View File

@@ -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()

99
app/forms.py Normal file
View File

@@ -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.")

167
app/models.py Normal file
View File

@@ -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"<User {self.email}>"
@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)

2
app/routes/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
from . import auth, main, scoreboard, settings, tasks

53
app/routes/auth.py Normal file
View File

@@ -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"))

45
app/routes/main.py Normal file
View File

@@ -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/<path:filename>")
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 ""

43
app/routes/scoreboard.py Normal file
View File

@@ -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),
)

106
app/routes/settings.py Normal file
View File

@@ -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/<int:badge_id>", 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})

174
app/routes/tasks.py Normal file
View File

@@ -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/<int:task_id>/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/<int:task_id>/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,
)

53
app/services/badges.py Normal file
View File

@@ -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

68
app/services/dates.py Normal file
View File

@@ -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

123
app/services/monthly.py Normal file
View File

@@ -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()
)

View File

@@ -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

126
app/services/tasks.py Normal file
View File

@@ -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

916
app/static/css/style.css Normal file
View File

@@ -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));
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 576"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M240.4 40.2C243.1 17.4 257.8 0 288 0C318.2 0 332.9 17.4 335.6 40.2C376.9 48 409.9 66.3 434 93.1C466.3 129.1 480 177.5 480 227.9L480 265.2C546.5 299 576 362.5 576 431.9L576 479.9L384 479.9C384 503.9 377.5 528.2 360.8 546.8C343.7 565.9 318.6 575.9 288 575.9C257.4 575.9 232.4 565.9 215.2 546.8C198.5 528.3 192 504 192 480L0 480L0 432C0 362.6 29.5 299.2 96 265.3L96 228C96 177.5 109.7 129.2 142 93.2C166.1 66.4 199.1 48 240.4 40.3zM213.4 121.6C198.6 146.4 192 183 192 228L192 296.8L177.1 302.9C121.4 325.7 96 373.2 96 432L480 432C480 373.2 454.6 325.7 398.9 302.9L384 296.8L384 228C384 183 377.4 146.4 362.5 121.6C348.9 98.9 327 84 288 84C249 84 227.1 98.9 213.5 121.6zM271.9 518.6C275.8 525.2 280.4 528 287.9 528C295.4 528 300 525.2 303.9 518.6C308.6 510.7 311.9 497.6 311.9 480L263.9 480C263.9 497.6 267.2 510.7 271.9 518.6z"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 576"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M192 0L192 48L384 48L384 0L432 0L432 48L576 48L576 432C576 485 533 528 480 528L96 528C43 528 0 485 0 432L0 48L144 48L144 0L192 0zM96 96L96 144L480 144L480 96L96 96zM96 192L96 432C96 458.5 117.5 480 144 480L432 480C458.5 480 480 458.5 480 432L480 192L96 192zM288 312C262.8 312 252 295.9 252 276C252 256.1 262.8 240 288 240C313.2 240 324 256.1 324 276C324 295.9 313.2 312 288 312zM432 276C432 295.9 421.2 312 396 312C370.8 312 360 295.9 360 276C360 256.1 370.8 240 396 240C421.2 240 432 256.1 432 276zM180 312C154.8 312 144 295.9 144 276C144 256.1 154.8 240 180 240C205.2 240 216 256.1 216 276C216 295.9 205.2 312 180 312zM324 396C324 415.9 313.2 432 288 432C262.8 432 252 415.9 252 396C252 376.1 262.8 360 288 360C313.2 360 324 376.1 324 396zM396 432C370.8 432 360 415.9 360 396C360 376.1 370.8 360 396 360C421.2 360 432 376.1 432 396C432 415.9 421.2 432 396 432zM216 396C216 415.9 205.2 432 180 432C154.8 432 144 415.9 144 396C144 376.1 154.8 360 180 360C205.2 360 216 376.1 216 396z"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 576"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M408 528L408 24L552 24L552 456C552 509 509 552 456 552L408 552L408 528zM504 408L504 72L456 72L456 456C482.5 456 504 434.5 504 408zM216 528L216 144L360 144L360 552L216 552L216 528zM312 456L312 192L264 192L264 456L312 456zM168 528L168 552L120 552C67 552 24 509 24 456L24 264L168 264L168 528zM120 456L120 312L72 312L72 408C72 434.5 93.5 456 120 456z"/></svg>

After

Width:  |  Height:  |  Size: 614 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 576"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M215.5 352.1L488 102.3L521.9 136.3L233.9 448.3L215.6 468.1L198 447.6L54 279.6L87.6 245.5L215.5 352.1z"/></svg>

After

Width:  |  Height:  |  Size: 369 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 576"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M248.4 0L229.7 0L226 14.7C215.5 44.8 201.4 54.7 189.5 57.6C175.1 61.1 154.7 56.9 128.9 42.4L118.9 36.4C117.2 38.1 94.1 61.2 49.6 105.7L36.3 119L42.3 128.9C56.8 154.7 61 175.1 57.5 189.5C54.6 201.4 44.7 215.5 14.6 226L0 229.7L0 346.3L14.7 350C44.8 360.5 54.7 374.6 57.6 386.5C61.1 400.9 56.9 421.3 42.4 447.1L36.4 457C38.1 458.7 61.2 481.8 105.7 526.3L119 539.6L128.9 533.6C154.6 519.1 175.1 514.9 189.5 518.4C201.4 521.3 215.5 531.2 226 561.3L229.7 576L346.4 576L350.1 561.3C360.6 531.2 374.7 521.3 386.6 518.4C401 514.9 421.4 519.1 447.1 533.6L457.1 539.6L539.6 457.1L533.6 447.2C519.1 421.5 514.9 401 518.4 386.6C521.3 374.7 531.2 360.6 561.3 350.1L576 346.4L576 229.7L561.3 226C531.2 215.5 521.3 201.4 518.4 189.5C514.9 175.1 519.1 154.7 533.6 128.9L539.6 119C537.9 117.3 514.8 94.2 470.3 49.7L457 36.4L447 42.4C421.3 56.9 400.8 61.1 386.4 57.6C374.5 54.7 360.4 44.8 349.9 14.7L346.3 0L248.4 0zM251.3 91.4C257.1 79.9 262.1 65.4 266.9 48L309.1 48C313.9 65.3 318.9 79.9 324.7 91.4C332.5 106.8 343.9 121.1 362.8 125.7C379.8 129.8 396.7 124.3 411.2 117.7L458.5 165L458 166.2C451.4 181.1 445.9 198.5 450.8 215.7C456 234.3 470.8 245.1 485.9 252.3C497.2 257.7 511.4 262.3 528.1 266.9L528.1 309C510.7 313.8 496.1 318.8 484.7 324.5C469.3 332.2 454.9 343.6 450.3 362.5C446.1 379.6 451.8 396.6 458.4 411.1L412.3 457.2C411 456.5 409.7 455.9 408.5 455.2C394.6 448.2 376.9 440.7 359.2 445.3C339.5 450.4 329.5 467.4 323.4 482.2C318.5 494 313.9 509.4 309.1 527.9L267.2 527.9C262.4 509.4 257.9 494 252.9 482.2C246.7 467.4 236.8 450.5 217.1 445.3C199.4 440.7 181.7 448.2 167.8 455.2C166.6 455.8 165.3 456.5 164 457.2L118.9 412.1C119.6 410.8 120.3 409.4 121 408.1C128 394.2 135.6 376.5 131 358.8C125.9 339 108.8 329.1 94 323C82.2 318.1 66.7 313.6 48.2 308.7L48.2 266.8C66.7 262 82.2 257.5 94 252.5C108.7 246.4 125.8 236.5 131 216.7C135.6 199 128.1 181.3 121 167.4C120.3 166.1 119.7 164.8 118.9 163.4L165 117.3C179.5 123.9 196.4 129.4 213.4 125.3C232.2 120.7 243.7 106.4 251.5 91zM304 249.4C308.7 257.3 312 270.4 312 288C312 305.6 308.7 318.7 304 326.6C300.1 333.2 295.5 336 288 336C280.5 336 275.9 333.2 272 326.6C267.3 318.7 264 305.6 264 288C264 270.4 267.3 257.3 272 249.4C275.9 242.8 280.5 240 288 240C295.5 240 300.1 242.8 304 249.4zM288 192C257.4 192 232.4 202 215.2 221.1C198.5 239.7 192 264.1 192 288C192 311.9 198.5 336.3 215.2 354.9C232.4 374 257.4 384 288 384C318.6 384 343.6 374 360.8 354.9C377.5 336.3 384 312 384 288C384 264 377.5 239.7 360.8 221.1C343.6 202 318.6 192 288 192z"/></svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 576"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M301.8 16.3L288 6.7L274.2 16.3L34.2 184.3L24 191.5L24 456C24 509 67 552 120 552L456 552C509 552 552 509 552 456L552 48L456 48L456 124.3L301.8 16.3zM72 297.9L288 81.9L504 297.9L504 456C504 482.5 482.5 504 456 504L408 504L408 360C408 320 393.2 289.2 369.3 268.6C346 248.6 316.1 240 288 240C259.9 240 229.9 248.6 206.7 268.6C182.8 289.2 168 320 168 360L168 504L120 504C93.5 504 72 482.5 72 456L72 297.9zM240 504L240 360C240 329.9 247.2 312.1 255.5 302.2C263.4 292.8 274.2 288 288 288C301.8 288 312.6 292.8 320.5 302.2C328.8 312.1 336 330 336 360L336 504L240 504z"/></svg>

After

Width:  |  Height:  |  Size: 827 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 576"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M552 120L552 145.9L526.2 143.9L214.2 119.9L214.2 72L526.2 48L552 46L552 119.9zM96 144C62.4 144 48 122.5 48 96C48 69.5 62.4 48 96 48C129.6 48 144 69.5 144 96C144 122.5 129.6 144 96 144zM96 336C62.4 336 48 314.5 48 288C48 261.5 62.4 240 96 240C129.6 240 144 261.5 144 288C144 314.5 129.6 336 96 336zM144 480C144 506.5 129.6 528 96 528C62.4 528 48 506.5 48 480C48 453.5 62.4 432 96 432C129.6 432 144 453.5 144 480zM552 337.9L526.2 335.9L214.2 311.9L214.2 264L526.2 240L552 238L552 337.8zM552 504L552 529.9L526.2 527.9L214.2 503.9L214.2 456L526.2 432L552 430L552 503.9z"/></svg>

After

Width:  |  Height:  |  Size: 833 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 576"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M506.1 264.1L327.2 248.8L311.9 69.9L264.1 70.2L250.9 250.8L70.3 264L70 311.9L248.9 327.2L264.2 506.1L312 505.8L325.2 325.2L505.8 312L506.1 264.1z"/></svg>

After

Width:  |  Height:  |  Size: 413 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 576"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M96 0L480 0C533 0 576 43 576 96L576 108L576 108C574.7 165.7 541.6 218 489.9 243.9L465.4 256.1C458.5 281 449 304.1 436.6 324.3C409.1 369.2 367.7 399.7 312 406.5L312 431.9L324.2 431.9C362.6 431.9 392.9 440.8 415 459.2C437.2 477.7 447.8 502.9 452.4 528.9C454.3 539.9 457.1 555.6 460.7 575.9L115.5 575.9C119.1 555.6 121.9 539.9 123.8 528.9C128.4 502.9 139 477.7 161.2 459.2C183.3 440.8 213.6 431.9 252 431.9L264.2 431.9L264.2 406.5C208.6 399.7 167.1 369.1 139.6 324.3C127.2 304 117.7 281 110.8 256.1L86.2 243.9C34.4 218 1.3 165.7 0 108L0 108L0 96C0 43 43 0 96 0zM384 72L384 48L192 48L192 138.9C192 205 200.9 262.6 218.5 302.6C235.9 342.3 259.3 360.1 288 360.1C316.7 360.1 340.1 342.3 357.5 302.6C375.1 262.6 384 205.1 384 138.9L384 72zM528 104.5C528 86.5 513.4 72 495.5 72L480 72L480 137.6C480 157.6 479 177.2 476.8 196.3C508.4 176.8 528 142.1 528 104.5zM80.5 72C62.5 72 48 86.6 48 104.5C48 142.1 67.6 176.8 99.2 196.3C97 177.2 96 157.6 96 137.6L96 72L80.5 72zM257.9 480C233.5 480 220.1 486.6 212 494.7C204.7 502 199.3 512.8 196.1 528L380 528C376.8 512.8 371.4 502 364.1 494.7C356 486.6 342.6 480 318.2 480L257.9 480z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 576"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M288 72C308.9 72 325.9 78.9 338 92.5C350.3 106.3 360 129.9 360 168C360 206.1 350.3 229.7 338 243.5C326 257 309 264 288.1 264L288 264C267.1 264 250.1 257.1 238 243.5C225.7 229.7 216 206.1 216 168C216 129.9 225.7 106.3 238 92.5C250.1 78.9 267.1 72 288 72zM187.1 277.4C187.7 277.9 188.2 278.4 188.8 278.9C163.7 287.1 140.6 298.8 120.6 314.2C75.4 349.1 48 401.6 48 469.4L48 552L528 552L528 469.4C528 401.5 500.6 349.1 455.4 314.2C435.4 298.8 412.3 287.1 387.2 278.9C387.8 278.4 388.3 277.9 388.9 277.4C416.7 252.5 432 215.4 432 168C432 120.6 416.6 83.6 388.9 58.6C361.6 34.1 325.2 24 288 24C250.8 24 214.4 34.1 187.1 58.6C159.4 83.6 144 120.6 144 168C144 215.4 159.4 252.4 187.1 277.4zM185 346.4C210.1 324.5 246 312.1 287.7 312.1L288.3 312.1C330 312.2 365.9 324.5 391 346.4C415.7 368 432 400.5 432 445.5L432 504.1L144 504.1L144 445.5C144 400.5 160.2 368 185 346.4z"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 576"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M200.5 134.2C208.8 144.1 216 162 216 192C216 222 208.8 239.9 200.5 249.8C192.6 259.2 181.8 264 168 264C154.2 264 143.4 259.2 135.5 249.8C127.2 239.9 120 222 120 192C120 162 127.2 144.1 135.5 134.2C143.4 124.8 154.2 120 168 120C181.8 120 192.6 124.8 200.5 134.2zM253.4 281.7C275.9 260.7 288 230.2 288 192C288 153 275.3 122 251.9 100.9C228.9 80.3 198.6 72 168 72C137.4 72 107.1 80.3 84.1 100.9C60.7 122 48 153 48 192C48 230.1 60.1 260.7 82.6 281.7C71.2 287.1 60.5 293.8 50.8 301.9C18.6 328.8 0 369 0 420.5L0 504L576 504L576 426.5C576 382.2 558.1 346.6 528.4 322.6C519.8 315.7 510.5 309.8 500.4 305.1C518.4 286.2 528 260 528 228.1C528 193.3 516.7 165.3 495.4 146.2C474.6 127.5 447.2 120.1 420 120.1C392.8 120.1 365.4 127.5 344.6 146.2C323.4 165.3 312 193.3 312 228.1C312 260 321.5 286.3 339.6 305.1C329.6 309.9 320.2 315.7 311.6 322.6C310.3 323.7 309 324.8 307.7 325.9C301.2 317 293.7 309 285.2 302C275.5 293.9 264.8 287.2 253.4 281.8zM504 456L336 456L336 426.4C336 394.5 346.2 372.4 360.5 358.3C374.9 344.1 395.5 336 420 336C444.5 336 465.1 344.1 479.5 358.3C493.8 372.4 504 394.5 504 426.4L504 455.9zM264 456L72 456L72 402.5C72 371.4 83 349.5 99.1 335.2C115.6 320.5 139.5 312 168 312C196.5 312 220.4 320.6 236.9 335.2C253 349.5 264 371.4 264 402.5L264 456zM443.6 178.9C450 186.9 456 201.9 456 228C456 254.1 450 269.1 443.6 277.1C437.8 284.5 430 288 420 288C410 288 402.2 284.5 396.4 277.1C390 269.1 384 254.1 384 228C384 201.9 390 186.9 396.4 178.9C402.2 171.5 410 168 420 168C430 168 437.8 171.5 443.6 178.9z"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<defs>
<linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#ffcf9c"/>
<stop offset="100%" stop-color="#ff8ba7"/>
</linearGradient>
</defs>
<rect width="128" height="128" rx="44" fill="url(#g)"/>
<circle cx="64" cy="50" r="24" fill="#fff" fill-opacity="0.92"/>
<path d="M26 106c7-20 21-31 38-31s31 11 38 31" fill="#fff" fill-opacity="0.92"/>
<text x="64" y="117" text-anchor="middle" font-family="Inter, sans-serif" font-size="28" fill="#b4236c">A</text>
</svg>

After

Width:  |  Height:  |  Size: 594 B

View File

@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<defs>
<linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#93c5fd"/>
<stop offset="100%" stop-color="#34d399"/>
</linearGradient>
</defs>
<rect width="128" height="128" rx="44" fill="url(#g)"/>
<circle cx="64" cy="50" r="24" fill="#fff" fill-opacity="0.92"/>
<path d="M26 106c7-20 21-31 38-31s31 11 38 31" fill="#fff" fill-opacity="0.92"/>
<text x="64" y="117" text-anchor="middle" font-family="Inter, sans-serif" font-size="28" fill="#0c6e6f">B</text>
</svg>

After

Width:  |  Height:  |  Size: 594 B

View File

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<defs>
<linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#dbeafe"/>
<stop offset="100%" stop-color="#bfdbfe"/>
</linearGradient>
</defs>
<rect width="128" height="128" rx="44" fill="url(#g)"/>
<circle cx="64" cy="46" r="22" fill="#fff" fill-opacity="0.94"/>
<path d="M30 104c6-18 19-28 34-28s28 10 34 28" fill="#fff" fill-opacity="0.94"/>
</svg>

After

Width:  |  Height:  |  Size: 479 B

View File

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#2563eb"/>
<stop offset="100%" stop-color="#34d399"/>
</linearGradient>
</defs>
<rect width="64" height="64" rx="18" fill="url(#g)"/>
<path d="M18 32.5 32 21l14 11.5V46H18z" fill="#fff"/>
<path d="m42 16 2.5 5 5.5.8-4 3.8.9 5.4-4.9-2.8-4.9 2.8.9-5.4-4-3.8 5.5-.8z" fill="#f59e0b"/>
</svg>

After

Width:  |  Height:  |  Size: 477 B

View File

@@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-labelledby="title desc">
<title id="title">Putzliga Logo</title>
<desc id="desc">Ein rundes Signet mit Haus, Schild und Stern für Putzliga.</desc>
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#2563eb"/>
<stop offset="100%" stop-color="#34d399"/>
</linearGradient>
<linearGradient id="card" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.96"/>
<stop offset="100%" stop-color="#eef6ff" stop-opacity="0.92"/>
</linearGradient>
</defs>
<rect x="16" y="16" width="224" height="224" rx="56" fill="url(#bg)"/>
<circle cx="128" cy="128" r="82" fill="url(#card)"/>
<path d="M82 127.5 128 88l46 39.5v43.5a8 8 0 0 1-8 8H90a8 8 0 0 1-8-8z" fill="#2563eb"/>
<path d="M108 177v-34a20 20 0 0 1 40 0v34" fill="#9fd0ff"/>
<path d="M128 58l59 50.5" stroke="#ffffff" stroke-width="13" stroke-linecap="round"/>
<path d="m164 74 7 15 16 2-12 11 3 16-14-8-14 8 3-16-12-11 16-2z" fill="#f59e0b"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

104
app/static/js/app.js Normal file
View File

@@ -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));
});
}
})();

25
app/static/manifest.json Normal file
View File

@@ -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"
}
]
}

View File

@@ -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);
}
})
);
});

View File

@@ -0,0 +1,48 @@
{% extends "base.html" %}
{% block title %}Login · Putzliga{% endblock %}
{% block content %}
<section class="auth-layout">
<div class="hero-card hero-card--brand">
<p class="eyebrow">Leichtgewichtige Haushalts-App</p>
<h2>Putzliga bringt Punkte, Rhythmus und ein bisschen Liga-Stimmung in den Alltag.</h2>
<p>Mehrere Nutzer, wiederkehrende Aufgaben, Monats-Highscore, Archiv, Kalender und PWA-Pushs in einer bewusst schlanken Flask-App.</p>
<div class="hero-stats">
<div>
<strong>Mobile first</strong>
<span>Bottom Navigation wie in einer iPhone-App</span>
</div>
<div>
<strong>Fair verteilt</strong>
<span>Punkte landen bei der Person, die wirklich erledigt hat</span>
</div>
<div>
<strong>Erweiterbar</strong>
<span>SQLite, Flask, Jinja und saubere Services statt Overengineering</span>
</div>
</div>
</div>
<section class="panel auth-panel">
<p class="eyebrow">Einloggen</p>
<h2>Willkommen zurück</h2>
<form method="post" class="form-grid">
{{ form.hidden_tag() }}
<div class="field">
{{ form.email.label }}
{{ form.email(placeholder="anna@putzliga.local") }}
{% for error in form.email.errors %}<small class="error">{{ error }}</small>{% endfor %}
</div>
<div class="field">
{{ form.password.label }}
{{ form.password(placeholder="Passwort") }}
{% for error in form.password.errors %}<small class="error">{{ error }}</small>{% endfor %}
</div>
<label class="checkbox">{{ form.remember_me() }} <span>Angemeldet bleiben</span></label>
{{ form.submit(class_="button button--wide") }}
</form>
<p class="inline-note">Demo-Logins nach dem Seeden: `anna@putzliga.local` / `putzliga123` und `ben@putzliga.local` / `putzliga123`.</p>
<p class="inline-note">Noch kein Konto? <a href="{{ url_for('auth.register') }}">Neu registrieren</a></p>
</section>
</section>
{% endblock %}

View File

@@ -0,0 +1,42 @@
{% extends "base.html" %}
{% block title %}Registrieren · Putzliga{% endblock %}
{% block content %}
<section class="auth-layout">
<div class="hero-card hero-card--brand">
<p class="eyebrow">Gemeinsam sauberer</p>
<h2>Erstelle dein Konto und steig direkt in die Liga ein.</h2>
<p>Nach dem Login landest du sofort bei „Meine Aufgaben“ und kannst Aufgaben anlegen, verteilen und Punkte sammeln.</p>
</div>
<section class="panel auth-panel">
<p class="eyebrow">Registrieren</p>
<h2>Neues Konto</h2>
<form method="post" class="form-grid">
{{ form.hidden_tag() }}
<div class="field">
{{ form.name.label }}
{{ form.name(placeholder="Dein Name") }}
{% for error in form.name.errors %}<small class="error">{{ error }}</small>{% endfor %}
</div>
<div class="field">
{{ form.email.label }}
{{ form.email(placeholder="mail@example.com") }}
{% for error in form.email.errors %}<small class="error">{{ error }}</small>{% endfor %}
</div>
<div class="field">
{{ form.password.label }}
{{ form.password(placeholder="Mindestens 6 Zeichen") }}
{% for error in form.password.errors %}<small class="error">{{ error }}</small>{% endfor %}
</div>
<div class="field">
{{ form.password_confirm.label }}
{{ form.password_confirm(placeholder="Passwort wiederholen") }}
{% for error in form.password_confirm.errors %}<small class="error">{{ error }}</small>{% endfor %}
</div>
{{ form.submit(class_="button button--wide") }}
</form>
<p class="inline-note">Schon dabei? <a href="{{ url_for('auth.login') }}">Zum Login</a></p>
</section>
</section>
{% endblock %}

121
app/templates/base.html Normal file
View File

@@ -0,0 +1,121 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#f5f7ff">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="{{ app_name }}">
<meta name="description" content="Putzliga macht Haushaltsaufgaben leichter, motivierender und fair verteilt.">
<title>{% block title %}{{ app_name }}{% endblock %}</title>
<link rel="manifest" href="{{ url_for('main.manifest') }}">
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='images/favicon.svg') }}">
<link rel="apple-touch-icon" href="{{ url_for('static', filename='images/apple-touch-icon.png') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body data-push-key="{{ config['VAPID_PUBLIC_KEY'] if current_user.is_authenticated else '' }}">
{% from "partials/macros.html" import nav_icon %}
<div class="app-shell {% if not current_user.is_authenticated %}app-shell--auth{% endif %}">
{% if current_user.is_authenticated %}
<aside class="sidebar">
<a class="brand" href="{{ url_for('tasks.my_tasks') }}">
<img src="{{ url_for('static', filename='images/logo.svg') }}" alt="Putzliga Logo" class="brand__logo">
<div>
<strong>Putzliga</strong>
<span>Haushalt mit Punktestand</span>
</div>
</a>
<nav class="sidebar-nav" aria-label="Hauptnavigation">
{% for endpoint, label, icon in nav_items %}
<a href="{{ url_for(endpoint) }}" class="nav-link {% if request.endpoint == endpoint %}is-active{% endif %}">
{{ nav_icon(icon) }}
<span>{{ label }}</span>
</a>
{% endfor %}
</nav>
<section class="sidebar-card">
<div class="sidebar-card__row">
<img class="avatar avatar--lg" src="{% if current_user.avatar_path and current_user.avatar_path.startswith('avatars/') %}{{ url_for('main.uploads', filename=current_user.avatar_path) }}{% else %}{{ url_for('static', filename=current_user.display_avatar) }}{% endif %}" alt="Avatar von {{ current_user.name }}">
<div>
<strong>{{ current_user.name }}</strong>
<p>{{ current_user.email }}</p>
</div>
</div>
<a class="text-link" href="{{ url_for('auth.logout') }}">Abmelden</a>
</section>
</aside>
{% endif %}
<div class="page-shell">
<header class="topbar">
{% if current_user.is_authenticated %}
<div>
<p class="eyebrow">Spielerisch sauber bleiben</p>
<h1>{% block page_title %}{{ app_name }}{% endblock %}</h1>
</div>
<a class="topbar-user" href="{{ url_for('settings.index') }}">
<span>{{ current_user.name }}</span>
<img class="avatar" src="{% if current_user.avatar_path and current_user.avatar_path.startswith('avatars/') %}{{ url_for('main.uploads', filename=current_user.avatar_path) }}{% else %}{{ url_for('static', filename=current_user.display_avatar) }}{% endif %}" alt="">
</a>
{% else %}
<a class="brand brand--public" href="{{ url_for('auth.login') }}">
<img src="{{ url_for('static', filename='images/logo.svg') }}" alt="Putzliga Logo" class="brand__logo">
<div>
<strong>Putzliga</strong>
<span>Haushaltsaufgaben mit Liga-Gefühl</span>
</div>
</a>
{% endif %}
</header>
<main class="content">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flash-stack">
{% for category, message in messages %}
<div class="flash flash--{{ category }}">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</main>
</div>
</div>
{% if current_user.is_authenticated %}
<nav class="bottom-nav" aria-label="Mobile Navigation">
{% for endpoint, label, icon in nav_items %}
<a href="{{ url_for(endpoint) }}" class="bottom-nav__item {% if request.endpoint == endpoint %}is-active{% endif %}">
{{ nav_icon(icon) }}
<span>{{ label }}</span>
</a>
{% endfor %}
</nav>
<dialog class="complete-dialog" id="completeDialog">
<form method="dialog" class="complete-dialog__surface">
<p class="eyebrow">Punkte fair verbuchen</p>
<h2>Wer hat diese Aufgabe erledigt?</h2>
<p id="completeDialogText">Bitte wähle aus, wem die Punkte gutgeschrieben werden sollen.</p>
<div class="choice-grid">
<button type="button" class="button button--secondary" data-complete-choice="assigned">Zugewiesene Person</button>
<button type="button" class="button" data-complete-choice="me">Ich</button>
</div>
<button type="button" class="button button--ghost" id="completeDialogClose">Abbrechen</button>
</form>
</dialog>
<form method="post" class="sr-only" id="completeDialogForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="completed_for" value="me" id="completeDialogChoice">
</form>
{% endif %}
<script src="{{ url_for('static', filename='js/app.js') }}" defer></script>
</body>
</html>

View File

@@ -0,0 +1,92 @@
{% macro nav_icon(name) -%}
<span class="nav-icon">{{ icon_svg(name)|safe }}</span>
{%- endmacro %}
{% macro status_badge(task) -%}
<span class="status-badge status-badge--{{ task.status }}">{{ task.status_label }}</span>
{%- endmacro %}
{% macro avatar(user, size='') -%}
{% if user %}
{% if user.avatar_path and user.avatar_path.startswith('avatars/') %}
<img class="avatar {{ size }}" src="{{ url_for('main.uploads', filename=user.avatar_path) }}" alt="Avatar von {{ user.name }}">
{% else %}
<img class="avatar {{ size }}" src="{{ url_for('static', filename=user.display_avatar) }}" alt="Avatar von {{ user.name }}">
{% endif %}
{% endif %}
{%- endmacro %}
{% macro task_card(task, current_user, compact=false) -%}
<article class="task-card {% if compact %}task-card--compact{% endif %}">
<div class="task-card__top">
<div>
<div class="chip-row">
{{ status_badge(task) }}
<span class="point-pill">{{ task.points_awarded }} Punkte</span>
</div>
<h3>{{ task.title }}</h3>
</div>
<a class="icon-button" href="{{ url_for('tasks.edit', task_id=task.id) }}" aria-label="Aufgabe bearbeiten">
{{ nav_icon('gear') }}
</a>
</div>
{% if task.description %}
<p class="muted">{{ task.description }}</p>
{% endif %}
<dl class="task-meta">
<div>
<dt>Fällig</dt>
<dd>{{ task.due_date|date_de }}</dd>
</div>
<div>
<dt>Zuständig</dt>
<dd>{{ task.assigned_user.name if task.assigned_user else 'Unverteilt' }}</dd>
</div>
<div>
<dt>Rhythmus</dt>
<dd>{{ task.task_template.recurrence_label }}</dd>
</div>
{% if task.completed_at %}
<div>
<dt>Erledigt von</dt>
<dd>{{ task.completed_by_user.name if task.completed_by_user else '—' }}</dd>
</div>
{% endif %}
</dl>
<div class="task-card__footer">
<div class="task-assignee">
{{ avatar(task.assigned_user) }}
<span>{{ task.assigned_user.name if task.assigned_user else 'Ohne Person' }}</span>
</div>
{% if not task.completed_at %}
{% if task.assigned_user_id and task.assigned_user_id != current_user.id %}
<button
type="button"
class="button"
data-complete-action="{{ url_for('tasks.complete', task_id=task.id) }}"
data-complete-title="{{ task.title }}"
data-complete-assigned="{{ task.assigned_user.name if task.assigned_user else 'Zugewiesene Person' }}"
>
{{ nav_icon('check') }}
<span>Erledigen</span>
</button>
{% else %}
<form method="post" action="{{ url_for('tasks.complete', task_id=task.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="completed_for" value="me">
<button type="submit" class="button">
{{ nav_icon('check') }}
<span>Erledigen</span>
</button>
</form>
{% endif %}
{% else %}
<span class="done-hint">Am {{ task.completed_at|datetime_de }}</span>
{% endif %}
</div>
</article>
{%- endmacro %}

View File

@@ -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 %}
<section class="hero-grid">
<article class="hero-card">
<p class="eyebrow">Aktueller Monat</p>
<h2>{{ current_label }}</h2>
<p>Aufgabenpunkte und Badge-Boni zählen nur im aktuellen Monat. Zum Monatswechsel landet alles im Archiv und die Liga startet wieder bei null.</p>
</article>
<article class="panel highlight-panel">
<p class="eyebrow">Archiv</p>
<form method="get" class="inline-form">
<select name="archive">
{% for year, month in archive_options %}
{% set archive_value = year ~ '-' ~ '%02d'|format(month) %}
<option value="{{ archive_value }}" {% if selected_archive == archive_value %}selected{% endif %}>{{ month|month_name }} {{ year }}</option>
{% endfor %}
</select>
<button type="submit" class="button button--secondary">Anzeigen</button>
</form>
</article>
</section>
<section class="scoreboard">
{% for row in current_rows %}
<article class="score-row {% if row.rank == 1 %}score-row--leader{% endif %}">
<div class="score-row__head">
<div class="score-row__person">
<span class="rank-badge">#{{ row.rank }}</span>
{{ avatar(row.user) }}
<div>
<strong>{{ row.user.name }}</strong>
<p>{{ row.completed_tasks_count }} erledigte Aufgaben</p>
</div>
</div>
<div class="score-row__points">
<strong>{{ row.total_points }}</strong>
<span>Punkte</span>
</div>
</div>
<div class="score-bar">
<span style="width: {{ 0 if max_points == 0 else (row.total_points / max_points * 100) }}%"></span>
</div>
<div class="score-row__meta">
<span>Basis: {{ row.base_points }}</span>
<span>Badges: +{{ row.bonus_points }}</span>
</div>
{% if row.badges %}
<div class="badge-cloud">
{% for badge in row.badges %}
<span class="reward-chip">{{ badge.definition.name }} +{{ badge.bonus_points }}</span>
{% endfor %}
</div>
{% endif %}
</article>
{% else %}
<div class="empty-state">Noch keine Punkte in diesem Monat.</div>
{% endfor %}
</section>
<section class="panel">
<p class="eyebrow">Monatsarchiv</p>
<h2>{{ archive_label or 'Noch kein Archiv' }}</h2>
<div class="archive-list">
{% for row in archived_rows %}
<article class="archive-row">
<div class="archive-row__left">
<span class="rank-badge">#{{ row.rank }}</span>
{{ avatar(row.user) }}
<strong>{{ row.user.name }}</strong>
</div>
<div class="archive-row__right">
<span>{{ row.total_points }} Punkte</span>
<small>{{ row.completed_tasks_count }} Aufgaben</small>
</div>
</article>
{% else %}
<div class="empty-state">Sobald ein Monat archiviert wurde, taucht er hier auf.</div>
{% endfor %}
</div>
</section>
{% endblock %}

View File

@@ -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 %}
<section class="two-column">
<article class="panel">
<p class="eyebrow">Profil & Benachrichtigungen</p>
<h2>Persönliche Einstellungen</h2>
<form method="post" enctype="multipart/form-data" class="form-grid">
{{ form.hidden_tag() }}
<div class="field">
{{ form.name.label }}
{{ form.name() }}
{% for error in form.name.errors %}<small class="error">{{ error }}</small>{% endfor %}
</div>
<div class="field">
{{ form.email.label }}
{{ form.email() }}
{% for error in form.email.errors %}<small class="error">{{ error }}</small>{% endfor %}
</div>
<div class="field">
{{ form.password.label }}
{{ form.password(placeholder="Leer lassen, wenn nichts geändert werden soll") }}
{% for error in form.password.errors %}<small class="error">{{ error }}</small>{% endfor %}
</div>
<div class="field">
{{ form.avatar.label }}
{{ form.avatar() }}
{% for error in form.avatar.errors %}<small class="error">{{ error }}</small>{% endfor %}
</div>
<label class="checkbox">
{{ form.notification_task_due_enabled() }}
<span>Push für heute oder morgen fällige Aufgaben</span>
</label>
<label class="checkbox">
{{ form.notification_monthly_winner_enabled() }}
<span>Push zum Monatssieger am 1. um 09:00 Uhr</span>
</label>
{{ form.submit(class_='button') }}
</form>
</article>
<article class="panel">
<p class="eyebrow">Push & App-Install</p>
<h2>Web-Push vorbereiten</h2>
<p class="muted">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.</p>
<div class="push-box">
<div class="push-box__state {% if push_ready %}is-ready{% else %}is-disabled{% endif %}">
{{ nav_icon('bell') }}
<div>
<strong>{% if push_ready %}VAPID konfiguriert{% else %}VAPID fehlt{% endif %}</strong>
<p>{% if push_ready %}Push kann im Browser aktiviert werden.{% else %}Bitte zuerst Public/Private Key in der Umgebung setzen.{% endif %}</p>
</div>
</div>
<button
type="button"
class="button button--wide"
id="pushToggle"
{% if not push_ready %}disabled{% endif %}
data-subscribed="{{ '1' if has_subscription else '0' }}"
>
{% if has_subscription %}Push deaktivieren{% else %}Push aktivieren{% endif %}
</button>
<p class="inline-note" id="pushHint">
Auf iPhone/iPad bitte zuerst in Safari zum Home-Bildschirm hinzufügen und als Web-App öffnen.
</p>
</div>
</article>
</section>
<section class="panel">
<p class="eyebrow">Gamification</p>
<h2>Badge-Regeln pflegen</h2>
<div class="badge-settings">
{% for badge in badges %}
<form method="post" action="{{ url_for('settings.update_badge', badge_id=badge.id) }}" class="badge-setting-card">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div>
<strong>{{ badge.name }}</strong>
<p class="muted">{{ badge.description }}</p>
</div>
<div class="field field--compact">
<label>Schwelle</label>
<input type="number" name="threshold" min="1" value="{{ badge.threshold }}">
</div>
<div class="field field--compact">
<label>Bonus</label>
<input type="number" name="bonus_points" min="0" value="{{ badge.bonus_points }}">
</div>
<label class="checkbox checkbox--compact">
<input type="checkbox" name="active" {% if badge.active %}checked{% endif %}>
<span>Aktiv</span>
</label>
<button type="submit" class="button button--secondary">Badge speichern</button>
</form>
{% endfor %}
</div>
</section>
{% endblock %}

View File

@@ -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 %}
<section class="panel">
<form method="get" class="filter-bar">
<div class="field field--compact">
<label for="status">Status</label>
<select name="status" id="status">
<option value="all" {% if filters.status == 'all' %}selected{% endif %}>Alle</option>
<option value="open" {% if filters.status == 'open' %}selected{% endif %}>Offen</option>
<option value="soon" {% if filters.status == 'soon' %}selected{% endif %}>Bald fällig</option>
<option value="overdue" {% if filters.status == 'overdue' %}selected{% endif %}>Überfällig</option>
<option value="completed" {% if filters.status == 'completed' %}selected{% endif %}>Erledigt</option>
</select>
</div>
<div class="field field--compact">
<label for="user_id">Nutzer</label>
<select name="user_id" id="user_id">
<option value="">Alle</option>
{% for user in users %}
<option value="{{ user.id }}" {% if filters.user_id == user.id %}selected{% endif %}>{{ user.name }}</option>
{% endfor %}
</select>
</div>
<div class="field field--compact">
<label for="sort">Sortierung</label>
<select name="sort" id="sort">
<option value="due" {% if filters.sort == 'due' %}selected{% endif %}>Fälligkeit</option>
<option value="points" {% if filters.sort == 'points' %}selected{% endif %}>Punkte</option>
<option value="user" {% if filters.sort == 'user' %}selected{% endif %}>Nutzer</option>
</select>
</div>
<label class="checkbox checkbox--compact">
<input type="checkbox" name="mine" value="1" {% if filters.mine == '1' %}checked{% endif %}>
<span>Nur meine</span>
</label>
<button type="submit" class="button">Filter anwenden</button>
</form>
</section>
<section class="task-grid">
{% for task in tasks %}
{{ task_card(task, current_user) }}
{% else %}
<div class="empty-state">Für diese Filter gibt es gerade keine Aufgaben.</div>
{% endfor %}
</section>
{% endblock %}

View File

@@ -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 %}
<section class="panel panel--toolbar">
<div>
<p class="eyebrow">Monatsansicht</p>
<h2>{{ current_label }}</h2>
</div>
<div class="toolbar-actions">
<a class="button button--secondary" href="{{ url_for('tasks.calendar_view', year=current_year if current_month > 1 else current_year - 1, month=current_month - 1 if current_month > 1 else 12, view=view) }}">Zurück</a>
<a class="button button--secondary" href="{{ url_for('tasks.calendar_view', year=current_year if current_month < 12 else current_year + 1, month=current_month + 1 if current_month < 12 else 1, view=view) }}">Weiter</a>
<div class="segmented">
<a href="{{ url_for('tasks.calendar_view', year=current_year, month=current_month, view='calendar') }}" class="{% if view == 'calendar' %}is-active{% endif %}">Kalender</a>
<a href="{{ url_for('tasks.calendar_view', year=current_year, month=current_month, view='list') }}" class="{% if view == 'list' %}is-active{% endif %}">Liste</a>
</div>
</div>
</section>
{% if view == 'calendar' %}
<section class="calendar-grid">
<div class="calendar-grid__weekdays">
{% for label in ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] %}
<span>{{ label }}</span>
{% endfor %}
</div>
{% for week in month_calendar %}
{% for day in week %}
<article class="calendar-day {% if day == 0 %}calendar-day--empty{% endif %}">
{% if day != 0 %}
<strong>{{ day }}</strong>
<div class="calendar-day__tasks">
{% for task in tasks_by_day.get(day, []) %}
<a href="{{ url_for('tasks.edit', task_id=task.id) }}" class="calendar-task calendar-task--{{ task.status }}">
<span>{{ task.title }}</span>
<small>{{ task.points_awarded }} P</small>
</a>
{% endfor %}
</div>
{% endif %}
</article>
{% endfor %}
{% endfor %}
</section>
{% else %}
<section class="stack">
{% for task in tasks %}
<article class="panel list-row">
<div>
<strong>{{ task.title }}</strong>
<p class="muted">{{ task.description or 'Ohne Zusatzbeschreibung' }}</p>
</div>
<div class="list-row__meta">
{{ status_badge(task) }}
<span>{{ task.due_date|date_de }}</span>
<a href="{{ url_for('tasks.edit', task_id=task.id) }}" class="text-link">Bearbeiten</a>
</div>
</article>
{% else %}
<div class="empty-state">In diesem Monat sind noch keine Aufgaben hinterlegt.</div>
{% endfor %}
</section>
{% endif %}
{% endblock %}

View File

@@ -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 %}
<section class="hero-grid">
<article class="hero-card">
<p class="eyebrow">Heute im Fokus</p>
<h2>{{ current_user.name }}, deine Liga für den Haushalt läuft.</h2>
<p>Erledige offene Aufgaben, sammle Punkte und halte deinen Monatslauf stabil.</p>
<div class="progress-card">
<div class="progress-card__top">
<span>Erledigungsquote</span>
<strong>{{ completion_ratio }}%</strong>
</div>
<div class="progress"><span style="width: {{ completion_ratio }}%"></span></div>
</div>
</article>
<article class="panel highlight-panel">
<p class="eyebrow">Schnellzugriff</p>
<a class="button button--wide" href="{{ url_for('tasks.create') }}">
{{ nav_icon('plus') }}
<span>Neue Aufgabe anlegen</span>
</a>
<a class="button button--secondary button--wide" href="{{ url_for('scoreboard.index') }}">
{{ nav_icon('trophy') }}
<span>Zum aktuellen Highscore</span>
</a>
</article>
</section>
<section class="stack">
<div class="section-heading">
<h2>Überfällig</h2>
<span class="section-heading__count">{{ sections.overdue|length }}</span>
</div>
<div class="task-grid">
{% for task in sections.overdue %}
{{ task_card(task, current_user) }}
{% else %}
<div class="empty-state">Nichts überfällig. Genau so darf es bleiben.</div>
{% endfor %}
</div>
</section>
<section class="stack">
<div class="section-heading">
<h2>Bald fällig</h2>
<span class="section-heading__count">{{ sections.soon|length }}</span>
</div>
<div class="task-grid">
{% for task in sections.soon %}
{{ task_card(task, current_user) }}
{% else %}
<div class="empty-state">Gerade nichts, was in den nächsten Tagen drängt.</div>
{% endfor %}
</div>
</section>
<section class="stack">
<div class="section-heading">
<h2>Offen</h2>
<span class="section-heading__count">{{ sections.open|length }}</span>
</div>
<div class="task-grid">
{% for task in sections.open %}
{{ task_card(task, current_user) }}
{% else %}
<div class="empty-state">Alles leer. Zeit für eine kleine Siegerrunde.</div>
{% endfor %}
</div>
</section>
<section class="stack">
<div class="section-heading">
<h2>Erledigt</h2>
<span class="section-heading__count">{{ sections.completed|length }}</span>
</div>
<div class="task-grid">
{% for task in sections.completed %}
{{ task_card(task, current_user, compact=true) }}
{% else %}
<div class="empty-state">Noch keine erledigten Aufgaben in deiner Liste.</div>
{% endfor %}
</div>
</section>
{% endblock %}

View File

@@ -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 %}
<section class="panel form-panel">
<p class="eyebrow">{% if mode == 'edit' %}Bestehende Aufgabe anpassen{% else %}Neue Aufgabe und Vorlage{% endif %}</p>
<h2>{% if mode == 'edit' %}Änderungen für {{ task.title }}{% else %}Neue Aufgabe anlegen{% endif %}</h2>
<form method="post" class="form-grid form-grid--two">
{{ form.hidden_tag() }}
<div class="field field--full">
{{ form.title.label }}
{{ form.title(placeholder="Zum Beispiel: Küche wischen") }}
{% for error in form.title.errors %}<small class="error">{{ error }}</small>{% endfor %}
</div>
<div class="field field--full">
{{ form.description.label }}
{{ form.description(rows="4", placeholder="Optional: kurze Hinweise zur Aufgabe") }}
{% for error in form.description.errors %}<small class="error">{{ error }}</small>{% endfor %}
</div>
<div class="field">
{{ form.default_points.label }}
{{ form.default_points() }}
{% for error in form.default_points.errors %}<small class="error">{{ error }}</small>{% endfor %}
</div>
<div class="field">
{{ form.assigned_user_id.label }}
{{ form.assigned_user_id() }}
{% for error in form.assigned_user_id.errors %}<small class="error">{{ error }}</small>{% endfor %}
</div>
<div class="field">
{{ form.due_date.label }}
{{ form.due_date() }}
{% for error in form.due_date.errors %}<small class="error">{{ error }}</small>{% endfor %}
</div>
<div class="field">
{{ form.recurrence_interval_unit.label }}
{{ form.recurrence_interval_unit() }}
</div>
<div class="field">
{{ form.recurrence_interval_value.label }}
{{ form.recurrence_interval_value() }}
{% for error in form.recurrence_interval_value.errors %}<small class="error">{{ error }}</small>{% endfor %}
</div>
<label class="checkbox">
{{ form.active() }}
<span>Vorlage bleibt aktiv und erzeugt bei Wiederholung weitere Aufgaben</span>
</label>
<div class="form-actions field--full">
{{ form.submit(class_='button') }}
<a class="button button--ghost" href="{{ url_for('tasks.all_tasks') }}">Abbrechen</a>
</div>
</form>
</section>
{% endblock %}

32
config.py Normal file
View File

@@ -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"

BIN
icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

8
requirements.txt Normal file
View File

@@ -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

103
scripts/generate_assets.py Normal file
View File

@@ -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()

View File

@@ -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)

182
seed.py Normal file
View File

@@ -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()

21
start.sh Executable file
View File

@@ -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"

4
wsgi.py Normal file
View File

@@ -0,0 +1,4 @@
from app import create_app
app = create_app()