12 Commits

32 changed files with 1660 additions and 300 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
"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": "0.6.0",
"version": "0.6.5",
"manifestVersion": 2,
"healthCheckPath": "/healthz",
"httpPort": 8000,
+43 -6
View File
@@ -7,7 +7,7 @@ Putzliga ist eine moderne, leichte Haushaltsaufgaben-Web-App mit spielerischem C
- Mehrere Nutzer mit Login, Admin-Nutzermanagement und Profil-/Avatar-Einstellungen
- Trennung zwischen `TaskTemplate` und `TaskInstance`
- Aufgaben anlegen, bearbeiten, zuweisen und erledigen
- globale Schnellaufgabe per Plus-Button mit Titel + Aufwand und automatisch passender Punktezahl
- globale Quick-Wins per Plus-Button mit gemeinsamen Vorlagen und freiem „Sonstiges“-Fallback
- 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
@@ -129,11 +129,13 @@ Admins können Nutzer zusätzlich direkt in der App unter `Optionen -> Profil &
Badges werden dauerhaft pro Nutzer gespeichert und automatisch freigeschaltet. Die Badge-Regeln werden für Admins auf einer eigenen Seite unter `Optionen -> Badges` gepflegt.
## Schnellaufgabe
## Quick-Wins
Über den global sichtbaren Plus-Button rechts unten kannst du auf jeder eingeloggten Seite eine Schnellaufgabe anlegen.
Über den global sichtbaren Plus-Button rechts unten kannst du auf jeder eingeloggten Seite Quick-Wins nutzen.
- nur `Titel` und `Aufwand`
- gemeinsame Quick-Wins sind für alle Nutzer sichtbar und direkt klickbar
- alle Nutzer können im separaten Optionen-Tab neue Quick-Wins anlegen
- für `Sonstiges (bitte auch nutzen)` lassen sich Titel und Aufwand frei wählen
- die Aufgabe wird automatisch dem gerade eingeloggten Nutzer zugewiesen
- Fälligkeit ist direkt `heute`
- die Punkte hängen vom Aufwand ab
@@ -144,8 +146,9 @@ Die Aufwand-Stufen sind:
- Normal
- Dauert etwas
- Aufwendig
- Super aufwendig
Admins können die Punkte je Aufwand unter `Optionen -> Profil & Team` anpassen.
Admins können die Punkte je Aufwand unter `Optionen -> Quick-Wins` anpassen.
### 5. Entwicklungsserver starten
@@ -221,11 +224,13 @@ Putzliga nutzt echte Web-Push-Benachrichtigungen mit Service Worker und VAPID.
```bash
flask --app app.py notify-due
flask --app app.py notify-monthly-winner
flask --app app.py notify-all
```
`notify-due`:
- prüft offene Aufgaben, die heute oder morgen fällig sind
- sendet ab 09:00 Uhr genau einen Sammel-Push pro Nutzer und Tag
- berücksichtigt nur offene Aufgaben, die heute fällig sind
- berücksichtigt die Nutzeroption `notification_task_due_enabled`
`notify-monthly-winner`:
@@ -234,12 +239,24 @@ flask --app app.py notify-monthly-winner
- verweist auf das Scoreboard/Archiv des letzten Monats
- berücksichtigt `notification_monthly_winner_enabled`
Badge-Pushes:
- werden direkt beim Freischalten eines neuen Badges verschickt
- berücksichtigen `notification_badge_enabled`
- funktionieren für Aufgaben-Badges und Monats-Badges
Für iPhone/iPad muss zusätzlich sichergestellt sein, dass der Server ausgehend Apple Web Push erreichen kann. Laut WebKit sollten dafür Verbindungen zu `*.push.apple.com` möglich sein.
### 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-all
```
Wenn du lieber getrennt schedulen willst, funktionieren auch weiterhin:
```bash
flask --app /app/app.py notify-due
flask --app /app/app.py notify-monthly-winner
@@ -343,6 +360,26 @@ Der ausgegebene `VAPID_PRIVATE_KEY` ist bereits `.env`-freundlich mit escaped Ne
## Release Notes
### 0.6.5
- Quick-Wins als gemeinsames Team-Feature ausgebaut
- neuer Optionen-Tab zum Anlegen und Verwalten gemeinsamer Quick-Wins
- bestehende Quick-Wins in den Optionen direkt bearbeitbar gemacht
- Quick-Win-Reihenfolge per Drag & Drop ergänzt und Speichern der Sortierung in den Optionen stabilisiert
- Plus-Dialog von Einzelkarten auf kompakte auswählbare Quick-Win-Chips umgestellt
- mehrere Quick-Wins lassen sich gesammelt als erledigt speichern
- „Sonstiges“ blendet Titel und Aufwand jetzt nur bei Auswahl ein
- neue Aufwand-Stufe `super aufwendig`
- Quick-Win-Popup visuell mit übernommenem Sparkles-Icon aus `heinz.marketing` aufgewertet
- gemeinsame Aufgaben für zwei Personen mit halbierten Punkten pro Person ergänzt
- Aufgabenstatus in `morgen fällig`, `übermorgen fällig` und `bald fällig` feiner aufgeteilt
- Aufgaben können jetzt von allen Nutzern direkt gelöscht werden
- Kalenderdarstellung für lange deutsche Begriffe und Namen bei der Worttrennung nachgeschärft
- deutsche Silbentrennung serverseitig vorbereitet, mit optionalem `pyphen`-Fallback ohne Startfehler im lokalen Dev-Setup
- mobile Layouts für Aufgabenkarten, Bearbeiten-Ansicht und Quick-Win-Verwaltung weiter verdichtet und ausgerichtet
- Footer auf Versionslink, Herkunftshinweis und `hnz.io`-Verweis umgebaut
- Cloudron-Version auf `0.6.5` angehoben
### 0.6.0
- Persönlicher read-only ICS-Feed pro Nutzer für externe Kalender ergänzt
+46
View File
@@ -4,12 +4,18 @@ import json
from pathlib import Path
from flask import Flask
from markupsafe import escape
try:
import pyphen
except ModuleNotFoundError: # pragma: no cover - optional dependency in local dev
pyphen = None
from config import Config
from .cli import register_cli, seed_badges
from .extensions import csrf, db, login_manager
from .forms import QuickTaskForm
from .models import QuickWin
from .routes import auth, main, scoreboard, settings, tasks
from .routes.main import load_icon_svg
from .services.app_settings import get_quick_task_config
@@ -18,6 +24,12 @@ from .services.bootstrap import ensure_schema_and_admins
from .services.dates import MONTH_NAMES, local_now
from .services.monthly import archive_months_missing_up_to_previous
DE_HYPHENATOR = pyphen.Pyphen(lang="de_DE") if pyphen else None
def _fallback_soft_hyphenate(word: str) -> str:
return word
def create_app(config_class: type[Config] = Config) -> Flask:
app = Flask(__name__, static_folder="static", template_folder="templates")
@@ -64,6 +76,7 @@ def create_app(config_class: type[Config] = Config) -> Flask:
(key, values["label"])
for key, values in quick_task_config.items()
]
quick_wins = QuickWin.query.filter_by(active=True).order_by(QuickWin.sort_order.asc(), QuickWin.id.asc()).all()
def asset_version(filename: str) -> int:
path = Path(app.static_folder) / filename
try:
@@ -94,6 +107,7 @@ def create_app(config_class: type[Config] = Config) -> Flask:
"now_local": local_now(),
"quick_task_form": quick_task_form,
"quick_task_config": quick_task_config,
"quick_wins": quick_wins,
}
@app.template_filter("date_de")
@@ -108,4 +122,36 @@ def create_app(config_class: type[Config] = Config) -> Flask:
def month_name(value):
return MONTH_NAMES[value]
@app.template_filter("hyphenate_de")
def hyphenate_de(value):
if not value:
return ""
text = str(value)
parts: list[str] = []
current = []
def flush_word():
if not current:
return
word = "".join(current)
if len(word) >= 6:
if DE_HYPHENATOR:
parts.append(DE_HYPHENATOR.inserted(word, "\u00AD"))
else:
parts.append(_fallback_soft_hyphenate(word))
else:
parts.append(word)
current.clear()
for char in text:
if char.isalpha() or char in "ÄÖÜäöüß":
current.append(char)
else:
flush_word()
parts.append(char)
flush_word()
return escape("".join(parts))
return app
+12 -1
View File
@@ -5,7 +5,7 @@ import click
from .extensions import db
from .models import BadgeDefinition, User
from .services.monthly import archive_months_missing_up_to_previous
from .services.notifications import send_due_notifications, send_monthly_winner_notifications
from .services.notifications import run_scheduled_notifications, send_due_notifications, send_monthly_winner_notifications
DEFAULT_BADGES = [
@@ -175,3 +175,14 @@ def register_cli(app) -> None:
def notify_monthly_winner_command():
result = send_monthly_winner_notifications()
click.echo(f"Winner-Push: sent={result.sent} skipped={result.skipped} failed={result.failed}")
@app.cli.command("notify-all")
def notify_all_command():
results = run_scheduled_notifications()
daily = results["daily_due"]
monthly = results["monthly_winner"]
click.echo(
"Scheduled-Pushes: "
f"daily(sent={daily.sent}, skipped={daily.skipped}, failed={daily.failed}) "
f"monthly(sent={monthly.sent}, skipped={monthly.skipped}, failed={monthly.failed})"
)
+21 -4
View File
@@ -48,6 +48,7 @@ class TaskForm(FlaskForm):
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()])
assigned_user_secondary_id = SelectField("Zweite Person", coerce=int, validators=[Optional()], default=0)
due_date = DateField("Fälligkeitsdatum", format="%Y-%m-%d", validators=[DataRequired()])
recurrence_interval_value = IntegerField(
"Intervallwert",
@@ -74,6 +75,9 @@ class TaskForm(FlaskForm):
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
if self.assigned_user_secondary_id.data and self.assigned_user_secondary_id.data == self.assigned_user_id.data:
self.assigned_user_secondary_id.errors.append("Bitte wähle hier eine andere Person oder keine zweite Person.")
return False
return True
@@ -85,8 +89,9 @@ class SettingsProfileForm(FlaskForm):
"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_task_due_enabled = BooleanField("Push bei heutigen offenen Aufgaben")
notification_monthly_winner_enabled = BooleanField("Push zum Monatssieger")
notification_badge_enabled = BooleanField("Push bei neuen Badges")
submit = SubmitField("Einstellungen speichern")
def __init__(self, original_email: str | None = None, *args, **kwargs):
@@ -115,13 +120,13 @@ class AdminUserForm(FlaskForm):
class QuickTaskForm(FlaskForm):
title = StringField("Titel", validators=[DataRequired(), Length(min=2, max=160)])
title = StringField("Titel", validators=[Optional(), Length(min=2, max=160)])
effort = SelectField(
"Aufwand",
choices=[(key, key) for key, _, _ in QUICK_TASK_EFFORTS],
validators=[DataRequired()],
validators=[Optional()],
)
submit = SubmitField("Schnellaufgabe speichern")
submit = SubmitField("Quick-Win speichern")
class QuickTaskConfigForm(FlaskForm):
@@ -133,4 +138,16 @@ class QuickTaskConfigForm(FlaskForm):
medium_points = IntegerField("Dauert etwas", validators=[DataRequired(), NumberRange(min=1, max=500)])
heavy_label = StringField("Bezeichnung", validators=[DataRequired(), Length(min=2, max=60)])
heavy_points = IntegerField("Aufwendig", validators=[DataRequired(), NumberRange(min=1, max=500)])
super_heavy_label = StringField("Bezeichnung", validators=[DataRequired(), Length(min=2, max=60)])
super_heavy_points = IntegerField("Super aufwendig", validators=[DataRequired(), NumberRange(min=1, max=500)])
submit = SubmitField("Aufwand speichern")
class QuickWinForm(FlaskForm):
title = StringField("Quick-Win", validators=[DataRequired(), Length(min=2, max=160)])
effort = SelectField(
"Aufwand",
choices=[(key, key) for key, _, _ in QUICK_TASK_EFFORTS],
validators=[DataRequired()],
)
submit = SubmitField("Quick-Win speichern")
+57 -3
View File
@@ -28,6 +28,7 @@ class User(UserMixin, TimestampMixin, db.Model):
calendar_feed_token = db.Column(db.String(255), nullable=True, unique=True, index=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)
notification_badge_enabled = db.Column(db.Boolean, nullable=False, default=True)
assigned_task_templates = db.relationship(
"TaskTemplate",
@@ -35,12 +36,24 @@ class User(UserMixin, TimestampMixin, db.Model):
backref="default_assigned_user",
lazy=True,
)
secondary_assigned_task_templates = db.relationship(
"TaskTemplate",
foreign_keys="TaskTemplate.default_assigned_user_secondary_id",
backref="default_assigned_user_secondary",
lazy=True,
)
assigned_tasks = db.relationship(
"TaskInstance",
foreign_keys="TaskInstance.assigned_user_id",
backref="assigned_user",
lazy=True,
)
secondary_assigned_tasks = db.relationship(
"TaskInstance",
foreign_keys="TaskInstance.assigned_user_secondary_id",
backref="assigned_user_secondary",
lazy=True,
)
completed_tasks = db.relationship(
"TaskInstance",
foreign_keys="TaskInstance.completed_by_user_id",
@@ -48,6 +61,7 @@ class User(UserMixin, TimestampMixin, db.Model):
lazy=True,
)
subscriptions = db.relationship("PushSubscription", backref="user", lazy=True, cascade="all, delete-orphan")
created_quick_wins = db.relationship("QuickWin", backref="created_by_user", lazy=True)
awarded_badges = db.relationship(
"UserBadge",
backref="user",
@@ -86,6 +100,7 @@ class TaskTemplate(TimestampMixin, db.Model):
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)
default_assigned_user_secondary_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)
@@ -106,12 +121,22 @@ class TaskTemplate(TimestampMixin, db.Model):
return f"Alle {self.recurrence_interval_value} {unit_label}"
class QuickWin(TimestampMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(160), nullable=False, index=True)
effort = db.Column(db.String(40), nullable=False, index=True)
active = db.Column(db.Boolean, nullable=False, default=True)
created_by_user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False, index=True)
sort_order = db.Column(db.Integer, nullable=False, default=0, index=True)
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)
assigned_user_secondary_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)
@@ -122,21 +147,50 @@ class TaskInstance(TimestampMixin, db.Model):
def is_completed(self) -> bool:
return self.completed_at is not None
@property
def assigned_users(self) -> list[User]:
users: list[User] = []
if self.assigned_user:
users.append(self.assigned_user)
if self.assigned_user_secondary and self.assigned_user_secondary.id not in {user.id for user in users}:
users.append(self.assigned_user_secondary)
return users
@property
def assigned_user_ids(self) -> list[int]:
return [user.id for user in self.assigned_users]
@property
def is_shared_assignment(self) -> bool:
return self.assigned_user_id is not None and self.assigned_user_secondary_id is not None
@property
def assignee_label(self) -> str:
if not self.assigned_users:
return "Ohne Person"
return " & ".join(user.name for user in self.assigned_users)
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"
if self.due_date == reference_date:
return "due_today"
if self.due_date == reference_date + timedelta(days=1):
return "due_tomorrow"
if self.due_date == reference_date + timedelta(days=2):
return "due_day_after_tomorrow"
return "open"
@property
def status_label(self) -> str:
labels = {
"open": "Offen",
"soon": "Bald fällig",
"due_today": "Bald fällig",
"due_tomorrow": "Morgen fällig",
"due_day_after_tomorrow": "Übermorgen fällig",
"overdue": "Überfällig",
"completed": "Erledigt",
}
+144 -18
View File
@@ -5,11 +5,12 @@ 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 sqlalchemy import func
from werkzeug.utils import secure_filename
from ..extensions import csrf, db
from ..forms import AdminUserForm, QuickTaskConfigForm, SettingsProfileForm
from ..models import BadgeDefinition, MonthlyScoreSnapshot, NotificationLog, PushSubscription, TaskInstance, TaskTemplate, User
from ..forms import AdminUserForm, QuickTaskConfigForm, QuickWinForm, SettingsProfileForm
from ..models import BadgeDefinition, MonthlyScoreSnapshot, NotificationLog, PushSubscription, QuickWin, TaskInstance, TaskTemplate, User
from ..services.app_settings import get_quick_task_config, set_setting_int, set_setting_str
from ..services.badges import earned_badges_for_user
from ..services.notifications import push_enabled
@@ -26,7 +27,10 @@ def _require_admin():
def _settings_tabs():
tabs = [("settings.index", "Profil & Team", "gear")]
tabs = [
("settings.index", "Profil & Team", "gear"),
("settings.quick_wins", "Quick-Wins", "plus"),
]
if current_user.is_admin:
tabs.append(("settings.badges", "Badges", "award"))
return tabs
@@ -48,22 +52,12 @@ def index():
current_user.ensure_calendar_feed_token()
form = SettingsProfileForm(original_email=current_user.email, obj=current_user)
admin_form = AdminUserForm(prefix="admin")
quick_task_config_form = QuickTaskConfigForm(prefix="quickconfig")
quick_task_config = get_quick_task_config()
if request.method == "GET":
quick_task_config_form.fast_label.data = quick_task_config["fast"]["label"]
quick_task_config_form.fast_points.data = quick_task_config["fast"]["points"]
quick_task_config_form.normal_label.data = quick_task_config["normal"]["label"]
quick_task_config_form.normal_points.data = quick_task_config["normal"]["points"]
quick_task_config_form.medium_label.data = quick_task_config["medium"]["label"]
quick_task_config_form.medium_points.data = quick_task_config["medium"]["points"]
quick_task_config_form.heavy_label.data = quick_task_config["heavy"]["label"]
quick_task_config_form.heavy_points.data = quick_task_config["heavy"]["points"]
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
current_user.notification_badge_enabled = form.notification_badge_enabled.data
if form.password.data:
current_user.set_password(form.password.data)
if form.avatar.data:
@@ -77,8 +71,6 @@ def index():
"settings/index.html",
form=form,
admin_form=admin_form,
quick_task_config_form=quick_task_config_form,
quick_task_config=quick_task_config,
users=User.query.order_by(User.is_admin.desc(), User.name.asc()).all(),
earned_badges=earned_badges_for_user(current_user.id),
calendar_feed_url=url_for("main.calendar_feed", token=current_user.calendar_feed_token, _external=True),
@@ -89,6 +81,54 @@ def index():
)
@bp.route("/quick-wins", methods=["GET", "POST"])
@login_required
def quick_wins():
quick_win_form = QuickWinForm(prefix="quickwin")
quick_task_config_form = QuickTaskConfigForm(prefix="quickconfig")
quick_task_config = get_quick_task_config()
quick_win_form.effort.choices = [(key, values["label"]) for key, values in quick_task_config.items()]
quick_task_config_form.fast_label.data = quick_task_config["fast"]["label"]
quick_task_config_form.fast_points.data = quick_task_config["fast"]["points"]
quick_task_config_form.normal_label.data = quick_task_config["normal"]["label"]
quick_task_config_form.normal_points.data = quick_task_config["normal"]["points"]
quick_task_config_form.medium_label.data = quick_task_config["medium"]["label"]
quick_task_config_form.medium_points.data = quick_task_config["medium"]["points"]
quick_task_config_form.heavy_label.data = quick_task_config["heavy"]["label"]
quick_task_config_form.heavy_points.data = quick_task_config["heavy"]["points"]
quick_task_config_form.super_heavy_label.data = quick_task_config["super_heavy"]["label"]
quick_task_config_form.super_heavy_points.data = quick_task_config["super_heavy"]["points"]
if quick_win_form.validate_on_submit():
existing_quick_win = QuickWin.query.filter_by(title=quick_win_form.title.data.strip(), active=True).first()
if existing_quick_win:
flash("Diesen Quick-Win gibt es bereits.", "error")
return redirect(url_for("settings.quick_wins"))
quick_win = QuickWin(
title=quick_win_form.title.data.strip(),
effort=quick_win_form.effort.data,
active=True,
created_by_user_id=current_user.id,
sort_order=(db.session.query(func.max(QuickWin.sort_order)).scalar() or -1) + 1,
)
db.session.add(quick_win)
db.session.commit()
flash(f"Quick-Win „{quick_win.title}“ wurde gespeichert.", "success")
return redirect(url_for("settings.quick_wins"))
return render_template(
"settings/quick_wins.html",
quick_win_form=quick_win_form,
quick_task_config_form=quick_task_config_form,
quick_task_config=quick_task_config,
quick_wins=QuickWin.query.filter_by(active=True).order_by(QuickWin.sort_order.asc(), QuickWin.id.asc()).all(),
settings_tabs=_settings_tabs(),
active_settings_tab="settings.quick_wins",
)
@bp.route("/calendar-feed/regenerate", methods=["POST"])
@login_required
def regenerate_calendar_feed():
@@ -174,9 +214,93 @@ def update_quick_task_config():
set_setting_int("quick_task_points_medium", form.medium_points.data)
set_setting_str("quick_task_label_heavy", form.heavy_label.data)
set_setting_int("quick_task_points_heavy", form.heavy_points.data)
set_setting_str("quick_task_label_super_heavy", form.super_heavy_label.data)
set_setting_int("quick_task_points_super_heavy", form.super_heavy_points.data)
db.session.commit()
flash("Schnellaufgaben-Aufwand und Punkte wurden aktualisiert.", "success")
return redirect(url_for("settings.index"))
flash("Quick-Win-Aufwand und Punkte wurden aktualisiert.", "success")
return redirect(url_for("settings.quick_wins"))
@bp.route("/quick-wins/<int:quick_win_id>/delete", methods=["POST"])
@login_required
def delete_quick_win(quick_win_id: int):
quick_win = QuickWin.query.get_or_404(quick_win_id)
if quick_win.created_by_user_id != current_user.id and not current_user.is_admin:
flash("Diesen Quick-Win kannst du nicht entfernen.", "error")
return redirect(url_for("settings.quick_wins"))
quick_win.active = False
db.session.commit()
flash("Quick-Win wurde ausgeblendet.", "success")
return redirect(url_for("settings.quick_wins"))
@bp.route("/quick-wins/<int:quick_win_id>/update", methods=["POST"])
@login_required
def update_quick_win(quick_win_id: int):
quick_win = QuickWin.query.get_or_404(quick_win_id)
quick_task_config = get_quick_task_config()
title = (request.form.get("title") or "").strip()
effort = request.form.get("effort") or ""
if len(title) < 2:
flash("Quick-Wins brauchen einen Titel mit mindestens 2 Zeichen.", "error")
return redirect(url_for("settings.quick_wins"))
if len(title) > 160:
flash("Quick-Win-Titel dürfen maximal 160 Zeichen lang sein.", "error")
return redirect(url_for("settings.quick_wins"))
if effort not in quick_task_config:
flash("Bitte wähle einen gültigen Aufwand.", "error")
return redirect(url_for("settings.quick_wins"))
duplicate = (
QuickWin.query.filter(QuickWin.id != quick_win.id, QuickWin.title == title, QuickWin.active.is_(True))
.first()
)
if duplicate:
flash("Diesen Quick-Win gibt es bereits.", "error")
return redirect(url_for("settings.quick_wins"))
quick_win.title = title
quick_win.effort = effort
db.session.commit()
flash(f"Quick-Win „{quick_win.title}“ wurde aktualisiert.", "success")
return redirect(url_for("settings.quick_wins"))
@bp.route("/quick-wins/reorder", methods=["POST"])
@login_required
@csrf.exempt
def reorder_quick_wins():
payload = request.get_json(silent=True)
if payload is not None:
raw_ids = payload.get("ids", [])
else:
raw_ids = request.form.get("ids", "").split(",")
ordered_ids = [int(item) for item in raw_ids if str(item).isdigit()]
quick_wins = QuickWin.query.filter_by(active=True).all()
quick_wins_by_id = {quick_win.id: quick_win for quick_win in quick_wins}
for position, quick_win_id in enumerate(ordered_ids):
quick_win = quick_wins_by_id.get(quick_win_id)
if quick_win:
quick_win.sort_order = position
used_ids = set(ordered_ids)
remaining = [quick_win for quick_win in quick_wins if quick_win.id not in used_ids]
for offset, quick_win in enumerate(sorted(remaining, key=lambda item: (item.sort_order, item.id)), start=len(ordered_ids)):
quick_win.sort_order = offset
db.session.commit()
if payload is not None:
return jsonify({"ok": True})
flash("Die Quick-Win-Reihenfolge wurde gespeichert.", "success")
return redirect(url_for("settings.quick_wins"))
@bp.route("/users/<int:user_id>/toggle-admin", methods=["POST"])
@@ -219,7 +343,9 @@ def delete_user(user_id: int):
return redirect(url_for("settings.index"))
TaskTemplate.query.filter_by(default_assigned_user_id=user.id).update({"default_assigned_user_id": None})
TaskTemplate.query.filter_by(default_assigned_user_secondary_id=user.id).update({"default_assigned_user_secondary_id": None})
TaskInstance.query.filter_by(assigned_user_id=user.id).update({"assigned_user_id": None})
TaskInstance.query.filter_by(assigned_user_secondary_id=user.id).update({"assigned_user_secondary_id": None})
TaskInstance.query.filter_by(completed_by_user_id=user.id).update({"completed_by_user_id": None})
MonthlyScoreSnapshot.query.filter_by(user_id=user.id).delete()
NotificationLog.query.filter_by(user_id=user.id).delete()
+125 -24
View File
@@ -6,15 +6,17 @@ from datetime import date
from flask import Blueprint, flash, redirect, render_template, request, url_for
from flask_login import current_user, login_required
from sqlalchemy import or_
from ..forms import QuickTaskForm, TaskForm
from ..models import TaskInstance, User
from ..models import QuickWin, TaskInstance, User
from ..services.app_settings import get_quick_task_config
from ..services.dates import month_label, today_local
from ..services.tasks import (
complete_task,
create_quick_task,
create_task_template_and_instance,
delete_task_instance,
refresh_task_statuses,
update_template_and_instance,
)
@@ -27,27 +29,64 @@ def _user_choices() -> list[tuple[int, str]]:
return [(user.id, user.name) for user in User.query.order_by(User.name.asc()).all()]
def _secondary_user_choices() -> list[tuple[int, str]]:
return [(0, "Keine zweite Person")] + _user_choices()
def _my_tasks_soon_priority(task: TaskInstance) -> int:
order = {
"due_tomorrow": 0,
"due_day_after_tomorrow": 1,
"due_today": 2,
}
return order.get(task.status, 99)
@bp.route("/my-tasks")
@login_required
def my_tasks():
tasks = (
TaskInstance.query.filter_by(assigned_user_id=current_user.id)
TaskInstance.query.filter(
or_(
TaskInstance.assigned_user_id == current_user.id,
TaskInstance.assigned_user_secondary_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": []}
sections = {
"open": [],
"due_today": [],
"due_tomorrow": [],
"due_day_after_tomorrow": [],
"overdue": [],
"completed": [],
}
for task in tasks:
sections[task.status].append(task)
soon_tasks = sorted(
sections["due_tomorrow"] + sections["due_day_after_tomorrow"] + sections["due_today"],
key=lambda task: (_my_tasks_soon_priority(task), task.due_date, task.title.lower()),
)
completed_count = len(sections["completed"])
active_count = len(sections["open"]) + len(sections["soon"]) + len(sections["overdue"])
active_count = (
len(sections["open"])
+ len(sections["due_today"])
+ len(sections["due_tomorrow"])
+ len(sections["due_day_after_tomorrow"])
+ 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,
soon_tasks=soon_tasks,
completion_ratio=completion_ratio,
today=today_local(),
)
@@ -63,9 +102,19 @@ def all_tasks():
sort = request.args.get("sort", "due")
if mine == "1":
query = query.filter(TaskInstance.assigned_user_id == current_user.id)
query = query.filter(
or_(
TaskInstance.assigned_user_id == current_user.id,
TaskInstance.assigned_user_secondary_id == current_user.id,
)
)
elif user_filter:
query = query.filter(TaskInstance.assigned_user_id == user_filter)
query = query.filter(
or_(
TaskInstance.assigned_user_id == user_filter,
TaskInstance.assigned_user_secondary_id == user_filter,
)
)
if sort == "points":
query = query.order_by(TaskInstance.points_awarded.desc(), TaskInstance.due_date.asc())
@@ -78,7 +127,17 @@ def all_tasks():
refresh_task_statuses(tasks)
if status != "all":
status_map = {"completed": "completed", "overdue": "overdue", "open": "open", "soon": "soon"}
if status == "soon":
tasks = [task for task in tasks if task.status in {"due_today", "due_tomorrow", "due_day_after_tomorrow"}]
else:
status_map = {
"completed": "completed",
"overdue": "overdue",
"open": "open",
"today": "due_today",
"tomorrow": "due_tomorrow",
"day_after_tomorrow": "due_day_after_tomorrow",
}
selected = status_map.get(status)
if selected:
tasks = [task for task in tasks if task.status == selected]
@@ -96,6 +155,7 @@ def all_tasks():
def create():
form = TaskForm()
form.assigned_user_id.choices = _user_choices()
form.assigned_user_secondary_id.choices = _secondary_user_choices()
if request.method == "GET" and not form.due_date.data:
form.due_date.data = today_local()
@@ -109,26 +169,45 @@ def create():
@bp.route("/tasks/quick", methods=["POST"])
@login_required
def quick_create():
form = QuickTaskForm(prefix="quick")
config = get_quick_task_config()
form.effort.choices = [
(key, values["label"])
for key, values in config.items()
]
created_titles: list[str] = []
if not form.validate_on_submit():
selected_ids = request.form.getlist("quick_win_ids")
if selected_ids:
quick_wins = QuickWin.query.filter(QuickWin.id.in_(selected_ids), QuickWin.active.is_(True)).order_by(QuickWin.id.asc()).all()
for quick_win in quick_wins:
task = create_quick_task(quick_win.title, quick_win.effort, current_user, description="Quick-Win")
complete_task(task, current_user.id)
created_titles.append(task.title)
if request.form.get("include_custom") == "1":
form = QuickTaskForm(prefix="quick")
form.effort.choices = [(key, values["label"]) for key, values in config.items()]
custom_title = (form.title.data or "").strip()
extra_errors: list[str] = []
if not custom_title:
extra_errors.append("Bitte gib für „Sonstiges“ einen Titel ein.")
if not form.effort.data or form.effort.data not in config:
extra_errors.append("Bitte wähle für „Sonstiges“ einen Aufwand aus.")
if not form.validate_on_submit() or extra_errors:
for field_errors in form.errors.values():
for error in field_errors:
flash(error, "error")
for error in extra_errors:
flash(error, "error")
return redirect(request.referrer or url_for("tasks.my_tasks"))
task = create_quick_task(custom_title, form.effort.data, current_user, description="Quick-Win")
complete_task(task, current_user.id)
created_titles.append(task.title)
if not created_titles:
flash("Bitte wähle mindestens einen Quick-Win aus.", "error")
return redirect(request.referrer or url_for("tasks.my_tasks"))
quick_action = request.form.get("quick_action", "save")
task = create_quick_task(form.title.data, form.effort.data, current_user)
if quick_action == "complete":
complete_task(task, current_user.id)
flash(f"Schnellaufgabe „{task.title}“ wurde direkt als erledigt gespeichert.", "success")
if len(created_titles) == 1:
flash(f"Quick-Win „{created_titles[0]}“ wurde als erledigt gespeichert.", "success")
else:
flash(f"Schnellaufgabe „{task.title}“ wurde für dich angelegt.", "success")
flash(f"{len(created_titles)} Quick-Wins wurden als erledigt gespeichert.", "success")
return redirect(request.referrer or url_for("tasks.my_tasks"))
@@ -138,12 +217,15 @@ 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()
form.assigned_user_secondary_id.choices = _secondary_user_choices()
next_url = request.args.get("next") or request.form.get("next") or request.referrer or url_for("tasks.all_tasks")
if request.method == "GET":
form.title.data = task.title
form.description.data = task.description
form.default_points.data = task.points_awarded
form.default_points.data = task.task_template.default_points
form.assigned_user_id.data = task.assigned_user_id or _user_choices()[0][0]
form.assigned_user_secondary_id.data = task.assigned_user_secondary_id or 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
@@ -152,9 +234,21 @@ def edit(task_id: int):
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 redirect(next_url)
return render_template("tasks/task_form.html", form=form, mode="edit", task=task)
return render_template("tasks/task_form.html", form=form, mode="edit", task=task, next_url=next_url)
@bp.route("/tasks/<int:task_id>/delete", methods=["POST"])
@login_required
def delete(task_id: int):
task = TaskInstance.query.get_or_404(task_id)
title = task.title
next_url = request.form.get("next") or url_for("tasks.all_tasks")
delete_task_instance(task)
flash(f"Aufgabe „{title}“ wurde gelöscht.", "success")
return redirect(next_url)
@bp.route("/tasks/<int:task_id>/complete", methods=["POST"])
@@ -167,8 +261,15 @@ def complete(task_id: int):
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
allowed_ids = {current_user.id}
if task.assigned_user_id:
allowed_ids.add(task.assigned_user_id)
if task.assigned_user_secondary_id:
allowed_ids.add(task.assigned_user_secondary_id)
if choice != "me":
selected_user_id = request.form.get("completed_for", type=int)
if selected_user_id in allowed_ids:
completed_by_id = selected_user_id
complete_task(task, completed_by_id)
flash("Punkte verbucht. Gute Arbeit.", "success")
+3
View File
@@ -9,10 +9,12 @@ QUICK_TASK_DEFAULTS = {
"quick_task_label_normal": "Normal",
"quick_task_label_medium": "Dauert etwas",
"quick_task_label_heavy": "Aufwendig",
"quick_task_label_super_heavy": "Super aufwendig",
"quick_task_points_fast": 4,
"quick_task_points_normal": 8,
"quick_task_points_medium": 12,
"quick_task_points_heavy": 18,
"quick_task_points_super_heavy": 28,
}
QUICK_TASK_EFFORTS = [
@@ -20,6 +22,7 @@ QUICK_TASK_EFFORTS = [
("normal", "quick_task_label_normal", "quick_task_points_normal"),
("medium", "quick_task_label_medium", "quick_task_points_medium"),
("heavy", "quick_task_label_heavy", "quick_task_points_heavy"),
("super_heavy", "quick_task_label_super_heavy", "quick_task_points_super_heavy"),
]
+46 -23
View File
@@ -4,7 +4,7 @@ import json
from collections import defaultdict
from datetime import date, datetime, time, timedelta
from sqlalchemy import and_
from sqlalchemy import and_, or_
from ..extensions import db
from ..models import BadgeDefinition, MonthlyScoreSnapshot, TaskInstance, User, UserBadge
@@ -25,24 +25,30 @@ def _max_day_streak(days: set[date]) -> int:
return best
def award_badge(user: User, badge_key: str, *, awarded_at: datetime | None = None, context: dict | None = None) -> bool:
def award_badge(
user: User,
badge_key: str,
*,
awarded_at: datetime | None = None,
context: dict | None = None,
) -> UserBadge | None:
definition = BadgeDefinition.query.filter_by(key=badge_key, active=True).first()
if not definition:
return False
return None
existing = UserBadge.query.filter_by(user_id=user.id, badge_definition_id=definition.id).first()
if existing:
return False
return None
db.session.add(
UserBadge(
award = UserBadge(
user_id=user.id,
badge_definition_id=definition.id,
awarded_at=awarded_at or datetime.utcnow(),
context=json.dumps(context, sort_keys=True) if context else None,
)
)
return True
db.session.add(award)
db.session.flush()
return award
def badge_awards_for_month(user_id: int, year: int, month: int) -> list[UserBadge]:
@@ -92,7 +98,7 @@ def _completion_metrics(user: User) -> dict[str, int]:
metrics["on_time_tasks_completed"] += 1
if completion_day <= task.due_date - timedelta(days=1):
metrics["early_tasks_completed"] += 1
if task.assigned_user_id and task.assigned_user_id != user.id:
if task.assigned_user_ids and user.id not in task.assigned_user_ids:
metrics["foreign_tasks_completed"] += 1
max_points = max(max_points, task.points_awarded)
@@ -101,20 +107,26 @@ def _completion_metrics(user: User) -> dict[str, int]:
return metrics
def evaluate_task_badges(user: User) -> list[str]:
def evaluate_task_badges(user: User, *, notify: bool = False) -> list[UserBadge]:
definitions = BadgeDefinition.query.filter_by(active=True).all()
metrics = _completion_metrics(user)
unlocked: list[str] = []
unlocked: list[UserBadge] = []
for definition in definitions:
metric_value = metrics.get(definition.trigger_type)
if metric_value is None:
continue
if metric_value >= definition.threshold and award_badge(user, definition.key):
unlocked.append(definition.name)
if metric_value >= definition.threshold:
award = award_badge(user, definition.key)
if award:
unlocked.append(award)
if unlocked:
db.session.commit()
if notify:
from .notifications import send_badge_notifications_for_awards
send_badge_notifications_for_awards(unlocked)
return unlocked
@@ -127,7 +139,10 @@ def _user_had_clean_month(user_id: int, year: int, month: int) -> bool:
start_date = date(year, month, 1)
end_date = (month_bounds(year, month)[1] - timedelta(days=1)).date()
tasks = TaskInstance.query.filter(
or_(
TaskInstance.assigned_user_id == user_id,
TaskInstance.assigned_user_secondary_id == user_id,
),
TaskInstance.due_date >= start_date,
TaskInstance.due_date <= end_date,
).all()
@@ -148,31 +163,39 @@ def _winner_user_ids(year: int, month: int) -> set[int]:
return {row.user_id for row in rows}
def evaluate_monthly_badges(year: int, month: int) -> list[str]:
def evaluate_monthly_badges(year: int, month: int, *, notify: bool = False) -> list[UserBadge]:
award_time = _month_end_award_time(year, month)
unlocked: list[str] = []
unlocked: list[UserBadge] = []
winners = _winner_user_ids(year, month)
previous_year, previous_month_value = previous_month(year, month)
previous_winners = _winner_user_ids(previous_year, previous_month_value)
for user in User.query.order_by(User.id.asc()).all():
if user.id in winners:
if award_badge(user, "monthly_champion", awarded_at=award_time, context={"year": year, "month": month}):
unlocked.append(f"{user.name}: Monatssieger")
award = award_badge(user, "monthly_champion", awarded_at=award_time, context={"year": year, "month": month})
if award:
unlocked.append(award)
if user.id in previous_winners:
if award_badge(user, "title_defender", awarded_at=award_time, context={"year": year, "month": month}):
unlocked.append(f"{user.name}: Titelverteidiger")
award = award_badge(user, "title_defender", awarded_at=award_time, context={"year": year, "month": month})
if award:
unlocked.append(award)
elif MonthlyScoreSnapshot.query.filter_by(year=previous_year, month=previous_month_value, user_id=user.id).first():
if award_badge(user, "comeback_kid", awarded_at=award_time, context={"year": year, "month": month}):
unlocked.append(f"{user.name}: Comeback Kid")
award = award_badge(user, "comeback_kid", awarded_at=award_time, context={"year": year, "month": month})
if award:
unlocked.append(award)
if _user_had_clean_month(user.id, year, month):
if award_badge(user, "white_glove", awarded_at=award_time, context={"year": year, "month": month}):
unlocked.append(f"{user.name}: Weiße Weste")
award = award_badge(user, "white_glove", awarded_at=award_time, context={"year": year, "month": month})
if award:
unlocked.append(award)
if unlocked:
db.session.commit()
if notify:
from .notifications import send_badge_notifications_for_awards
send_badge_notifications_for_awards(unlocked)
return unlocked
+71 -6
View File
@@ -5,7 +5,7 @@ import os
from sqlalchemy import inspect, text
from ..extensions import db
from ..models import User
from ..models import QuickWin, User
from .app_settings import ensure_app_settings
@@ -21,6 +21,25 @@ def ensure_schema_and_admins() -> None:
db.session.execute(text("ALTER TABLE user ADD COLUMN calendar_feed_token VARCHAR(255)"))
db.session.commit()
if "notification_badge_enabled" not in column_names:
db.session.execute(text("ALTER TABLE user ADD COLUMN notification_badge_enabled BOOLEAN NOT NULL DEFAULT 1"))
db.session.commit()
task_template_columns = {column["name"] for column in inspector.get_columns("task_template")}
if "default_assigned_user_secondary_id" not in task_template_columns:
db.session.execute(text("ALTER TABLE task_template ADD COLUMN default_assigned_user_secondary_id INTEGER"))
db.session.commit()
task_instance_columns = {column["name"] for column in inspector.get_columns("task_instance")}
if "assigned_user_secondary_id" not in task_instance_columns:
db.session.execute(text("ALTER TABLE task_instance ADD COLUMN assigned_user_secondary_id INTEGER"))
db.session.commit()
quick_win_columns = {column["name"] for column in inspector.get_columns("quick_win")}
if "sort_order" not in quick_win_columns:
db.session.execute(text("ALTER TABLE quick_win ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0"))
db.session.commit()
ensure_app_settings()
users_without_feed = User.query.filter(User.calendar_feed_token.is_(None)).all()
@@ -30,17 +49,63 @@ def ensure_schema_and_admins() -> None:
db.session.commit()
admin_exists = User.query.filter_by(is_admin=True).first()
if admin_exists:
return
default_quick_win_user = admin_exists
preferred_admin_email = os.getenv("DEFAULT_ADMIN_EMAIL", "mail@hnz.io").lower().strip()
preferred_user = User.query.filter(User.email.ilike(preferred_admin_email)).first()
if preferred_user:
if preferred_user and not admin_exists:
preferred_user.is_admin = True
db.session.commit()
return
default_quick_win_user = preferred_user
first_user = User.query.order_by(User.id.asc()).first()
if first_user:
if first_user and not User.query.filter_by(is_admin=True).first():
first_user.is_admin = True
db.session.commit()
default_quick_win_user = first_user
_ensure_default_quick_wins(default_quick_win_user or User.query.order_by(User.id.asc()).first())
_ensure_quick_win_ordering()
def _ensure_default_quick_wins(default_user: User | None) -> None:
if not default_user:
return
defaults = [
("Schnell Aufräumen", "fast"),
("Spülmaschine ausräumen", "normal"),
("Bett machen", "normal"),
("Lüften", "fast"),
("Wäsche zusammenlegen", "medium"),
]
existing_titles = {quick_win.title for quick_win in QuickWin.query.all()}
created = False
next_sort_order = QuickWin.query.count()
for title, effort in defaults:
if title not in existing_titles:
db.session.add(
QuickWin(
title=title,
effort=effort,
active=True,
created_by_user_id=default_user.id,
sort_order=next_sort_order,
)
)
next_sort_order += 1
created = True
if created:
db.session.commit()
def _ensure_quick_win_ordering() -> None:
quick_wins = QuickWin.query.order_by(QuickWin.sort_order.asc(), QuickWin.id.asc()).all()
dirty = False
for index, quick_win in enumerate(quick_wins):
if quick_win.sort_order != index:
quick_win.sort_order = index
dirty = True
if dirty:
db.session.commit()
+8 -1
View File
@@ -2,6 +2,8 @@ from __future__ import annotations
from datetime import UTC, datetime, time, timedelta
from sqlalchemy import or_
from ..models import TaskInstance, User
@@ -23,7 +25,12 @@ def _format_timestamp(value: datetime | None) -> str:
def build_calendar_feed(user: User, base_url: str) -> str:
tasks = (
TaskInstance.query.filter_by(assigned_user_id=user.id)
TaskInstance.query.filter(
or_(
TaskInstance.assigned_user_id == user.id,
TaskInstance.assigned_user_secondary_id == user.id,
)
)
.order_by(TaskInstance.due_date.asc(), TaskInstance.id.asc())
.all()
)
+1 -1
View File
@@ -97,7 +97,7 @@ def archive_months_missing_up_to_previous() -> None:
)
)
db.session.commit()
evaluate_monthly_badges(year, month)
evaluate_monthly_badges(year, month, notify=True)
year, month = next_month(year, month)
+114 -55
View File
@@ -2,14 +2,14 @@ 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 sqlalchemy import or_
from ..extensions import db
from ..models import NotificationLog, PushSubscription, TaskInstance, User
from ..models import NotificationLog, PushSubscription, TaskInstance, User, UserBadge
from .monthly import archive_months_missing_up_to_previous, get_snapshot_rows
from .dates import local_now, previous_month
@@ -60,44 +60,20 @@ def _send_subscription(subscription: PushSubscription, payload: dict) -> bool:
return False
def send_due_notifications() -> NotificationResult:
def _subscriptions_for_user(user_id: int) -> list[PushSubscription]:
return PushSubscription.query.filter_by(user_id=user_id).all()
def _send_payload_to_user(user: User, notification_type: str, marker: dict, payload: dict) -> NotificationResult:
result = NotificationResult()
if not push_enabled():
subscriptions = _subscriptions_for_user(user.id)
if not subscriptions:
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:
if _notification_exists(user.id, notification_type, marker):
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}",
}
return result
sent_any = False
for subscription in subscriptions:
@@ -108,8 +84,72 @@ def send_due_notifications() -> NotificationResult:
result.failed += 1
if sent_any:
_log_notification(user.id, "task_due", payload_marker)
_log_notification(user.id, notification_type, marker)
db.session.commit()
elif result.failed == 0:
result.skipped += 1
return result
def _merge_results(base: NotificationResult, extra: NotificationResult) -> NotificationResult:
base.sent += extra.sent
base.skipped += extra.skipped
base.failed += extra.failed
return base
def send_due_notifications() -> NotificationResult:
result = NotificationResult()
if not push_enabled():
result.skipped += 1
return result
now = local_now()
if now.hour < 9:
result.skipped += 1
return result
today = now.date()
relevant_tasks = TaskInstance.query.filter(
TaskInstance.completed_at.is_(None),
TaskInstance.due_date == today,
or_(
TaskInstance.assigned_user_id.isnot(None),
TaskInstance.assigned_user_secondary_id.isnot(None),
),
).all()
tasks_by_user: dict[int, list[TaskInstance]] = {}
for task in relevant_tasks:
for assigned_user in task.assigned_users:
tasks_by_user.setdefault(assigned_user.id, []).append(task)
for user in User.query.order_by(User.name.asc()).all():
if not user.notification_task_due_enabled:
result.skipped += 1
continue
personal_tasks = tasks_by_user.get(user.id, [])
if not personal_tasks:
result.skipped += 1
continue
task_count = len(personal_tasks)
payload = {
"title": "Putzliga für heute",
"body": (
"Heute wartet 1 offene Aufgabe auf dich. Zeit zum Punkte sammeln."
if task_count == 1
else f"Heute warten {task_count} offene Aufgaben auf dich. Zeit zum Punkte sammeln."
),
"icon": _absolute_url("/static/images/pwa-icon-192.png"),
"badge": _absolute_url("/static/images/pwa-badge.png"),
"url": _absolute_url("/my-tasks"),
"tag": f"due-{today.isoformat()}-{user.id}",
}
marker = {"date": today.isoformat()}
_merge_results(result, _send_payload_to_user(user, "task_due_daily", marker, payload))
return result
@@ -140,14 +180,6 @@ def send_monthly_winner_notifications() -> NotificationResult:
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",
@@ -157,16 +189,43 @@ def send_monthly_winner_notifications() -> NotificationResult:
"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()
_merge_results(result, _send_payload_to_user(user, "monthly_winner", marker, payload))
return result
def send_badge_notifications_for_awards(awards: list[UserBadge]) -> NotificationResult:
result = NotificationResult()
if not push_enabled():
result.skipped += len(awards) or 1
return result
for award in awards:
user = award.user
definition = award.badge_definition
if not user or not definition:
result.skipped += 1
continue
if not user.notification_badge_enabled:
result.skipped += 1
continue
marker = {"user_badge_id": award.id}
payload = {
"title": "Neues Badge freigeschaltet",
"body": f"{definition.name}: {definition.description}",
"icon": _absolute_url("/static/images/pwa-icon-192.png"),
"badge": _absolute_url("/static/images/pwa-badge.png"),
"url": _absolute_url("/settings"),
"tag": f"badge-{award.id}",
}
_merge_results(result, _send_payload_to_user(user, "badge_award", marker, payload))
return result
def run_scheduled_notifications() -> dict[str, NotificationResult]:
return {
"daily_due": send_due_notifications(),
"monthly_winner": send_monthly_winner_notifications(),
}
+36 -7
View File
@@ -25,12 +25,19 @@ def refresh_task_statuses(tasks: list[TaskInstance]) -> None:
db.session.commit()
def effective_points(base_points: int, assigned_user_secondary_id: int | None) -> int:
if assigned_user_secondary_id:
return max(1, base_points // 2)
return base_points
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,
default_assigned_user_secondary_id=form.assigned_user_secondary_id.data or None,
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,
@@ -43,8 +50,9 @@ def create_task_template_and_instance(form) -> TaskInstance:
title=template.title,
description=template.description,
assigned_user_id=template.default_assigned_user_id,
assigned_user_secondary_id=template.default_assigned_user_secondary_id,
due_date=form.due_date.data,
points_awarded=template.default_points,
points_awarded=effective_points(template.default_points, template.default_assigned_user_secondary_id),
status="open",
)
refresh_task_status(task, form.due_date.data)
@@ -59,6 +67,7 @@ def update_template_and_instance(task: TaskInstance, form) -> TaskInstance:
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.default_assigned_user_secondary_id = form.assigned_user_secondary_id.data or None
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
@@ -68,7 +77,8 @@ def update_template_and_instance(task: TaskInstance, form) -> TaskInstance:
task.title = template.title
task.description = template.description
task.assigned_user_id = template.default_assigned_user_id
task.points_awarded = template.default_points
task.assigned_user_secondary_id = template.default_assigned_user_secondary_id
task.points_awarded = effective_points(template.default_points, template.default_assigned_user_secondary_id)
task.due_date = form.due_date.data
refresh_task_status(task, form.due_date.data)
db.session.commit()
@@ -108,8 +118,12 @@ def ensure_next_recurring_task(task: TaskInstance) -> TaskInstance | None:
title=task.task_template.title,
description=task.task_template.description,
assigned_user_id=task.task_template.default_assigned_user_id,
assigned_user_secondary_id=task.task_template.default_assigned_user_secondary_id,
due_date=next_due,
points_awarded=task.task_template.default_points,
points_awarded=effective_points(
task.task_template.default_points,
task.task_template.default_assigned_user_secondary_id,
),
status="open",
)
refresh_task_status(next_task, today_local())
@@ -125,16 +139,16 @@ def complete_task(task: TaskInstance, completed_by_user_id: int) -> TaskInstance
ensure_next_recurring_task(task)
db.session.commit()
if task.completed_by_user:
evaluate_task_badges(task.completed_by_user)
evaluate_task_badges(task.completed_by_user, notify=True)
return task
def create_quick_task(title: str, effort: str, creator: User) -> TaskInstance:
def create_quick_task(title: str, effort: str, creator: User, description: str = "Quick-Win") -> TaskInstance:
config = get_quick_task_config()
effort_config = config[effort]
template = TaskTemplate(
title=title.strip(),
description="Schnellaufgabe",
description=description,
default_points=effort_config["points"],
default_assigned_user_id=creator.id,
recurrence_interval_value=None,
@@ -147,8 +161,9 @@ def create_quick_task(title: str, effort: str, creator: User) -> TaskInstance:
task = TaskInstance(
task_template_id=template.id,
title=template.title,
description="Schnellaufgabe",
description=description,
assigned_user_id=creator.id,
assigned_user_secondary_id=None,
due_date=today_local(),
points_awarded=template.default_points,
status="open",
@@ -157,3 +172,17 @@ def create_quick_task(title: str, effort: str, creator: User) -> TaskInstance:
db.session.add(task)
db.session.commit()
return task
def delete_task_instance(task: TaskInstance) -> None:
template = task.task_template
db.session.delete(task)
db.session.flush()
remaining_instance = db.session.scalar(
select(TaskInstance.id).where(TaskInstance.task_template_id == template.id).limit(1)
)
if remaining_instance is None:
db.session.delete(template)
db.session.commit()
+426 -14
View File
@@ -161,8 +161,15 @@ p {
margin-bottom: 24px;
}
.topbar > div {
min-width: 0;
}
.topbar h1 {
font-size: clamp(1.9rem, 4vw, 2.9rem);
min-width: 0;
overflow-wrap: break-word;
hyphens: auto;
}
.topbar-user {
@@ -188,19 +195,32 @@ p {
.app-footer {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
justify-content: space-between;
gap: 14px;
flex-wrap: wrap;
padding: 18px 0 8px;
color: var(--muted);
font-size: 0.88rem;
text-align: center;
text-align: left;
}
.app-footer__left,
.app-footer__right {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.app-footer__right {
margin-left: auto;
}
.app-footer a {
color: inherit;
}
.app-footer span {
.app-footer__left span {
opacity: 0.7;
}
@@ -275,6 +295,22 @@ p {
display: inline-flex;
align-items: center;
gap: 14px;
min-width: 0;
}
.brand__mark {
width: 58px;
height: 58px;
flex: 0 0 58px;
display: inline-flex;
align-items: center;
justify-content: center;
overflow: hidden;
border-radius: 20px;
background: radial-gradient(circle at 50% 42%, rgba(112, 207, 255, 0.38), rgba(37, 99, 235, 0.08));
box-shadow:
0 18px 38px rgba(37, 99, 235, 0.18),
inset 0 0 0 1px rgba(255, 255, 255, 0.24);
}
.brand strong {
@@ -288,8 +324,37 @@ p {
}
.brand__logo {
width: 48px;
height: 48px;
width: 100%;
height: 100%;
object-fit: cover;
}
.brand--public .brand__mark {
width: 64px;
height: 64px;
flex-basis: 64px;
border-radius: 22px;
}
.hero-card__brand-mark {
width: 108px;
height: 108px;
margin-bottom: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
overflow: hidden;
border-radius: 32px;
background: radial-gradient(circle at 50% 40%, rgba(112, 207, 255, 0.42), rgba(37, 99, 235, 0.12));
box-shadow:
0 22px 48px rgba(37, 99, 235, 0.22),
inset 0 0 0 1px rgba(255, 255, 255, 0.26);
}
.hero-card__brand-mark img {
width: 100%;
height: 100%;
object-fit: cover;
}
.flash-stack {
@@ -374,6 +439,9 @@ p {
.section-heading h2,
.panel h2 {
font-size: 1.5rem;
min-width: 0;
overflow-wrap: break-word;
hyphens: auto;
}
.section-heading__count,
@@ -410,8 +478,37 @@ p {
padding: 20px;
}
.task-card__top {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: flex-start;
gap: 12px;
}
.task-card__title-block {
display: grid;
gap: 10px;
}
.task-card__top > div:first-child,
.task-card__title-block {
min-width: 0;
}
.task-card__title-block .chip-row {
gap: 8px;
}
.task-card__top .icon-button {
align-self: flex-start;
margin-top: 2px;
}
.task-card h3 {
font-size: 1.35rem;
line-height: 1.08;
overflow-wrap: break-word;
hyphens: auto;
}
.task-card--compact {
@@ -460,6 +557,9 @@ p {
color: #1d4ed8;
}
.status-badge--due_today,
.status-badge--due_tomorrow,
.status-badge--due_day_after_tomorrow,
.status-badge--soon {
background: #fff3d6;
color: #b45309;
@@ -501,6 +601,16 @@ p {
color: var(--muted);
}
.task-assignee__avatars {
display: inline-flex;
align-items: center;
}
.task-assignee__avatars .avatar + .avatar {
margin-left: -10px;
border: 2px solid var(--surface-strong);
}
.avatar {
width: 34px;
height: 34px;
@@ -565,6 +675,11 @@ p {
color: var(--text);
}
.button--danger {
border-color: rgba(225, 29, 72, 0.3);
color: var(--danger);
}
.button--wide {
width: 100%;
}
@@ -857,32 +972,37 @@ p {
}
.calendar-task__title {
display: -webkit-box;
overflow: hidden;
display: block;
overflow: visible;
min-width: 0;
font-size: 0.96rem;
line-height: 1.15;
font-weight: 600;
color: var(--text);
word-break: break-word;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
word-break: normal;
overflow-wrap: break-word;
hyphens: manual;
}
.calendar-task__person {
display: block;
overflow: hidden;
overflow: visible;
min-width: 0;
font-size: 0.74rem;
line-height: 1.2;
white-space: nowrap;
text-overflow: ellipsis;
white-space: normal;
word-break: normal;
overflow-wrap: break-word;
hyphens: manual;
}
.calendar-task--open {
border-left: 4px solid #2563eb;
}
.calendar-task--due_today,
.calendar-task--due_tomorrow,
.calendar-task--due_day_after_tomorrow,
.calendar-task--soon {
border-left: 4px solid #f59e0b;
}
@@ -1030,6 +1150,291 @@ p {
margin: 0;
}
.quick-win-list {
display: grid;
gap: 12px;
}
.quick-win-list__toolbar {
display: flex;
justify-content: flex-end;
margin-bottom: 14px;
}
.quick-win-manage-card {
display: grid;
gap: 12px;
padding: 18px;
border-radius: var(--radius-md);
background: var(--surface-soft);
border: 1px solid rgba(132, 152, 190, 0.22);
}
.quick-win-manage-card {
align-items: stretch;
cursor: move;
}
.quick-win-manage-card.is-dragging {
opacity: 0.72;
}
.quick-win-manage-form {
display: grid;
gap: 12px;
padding-top: 4px;
}
.quick-win-manage-form[hidden] {
display: none !important;
}
.quick-win-manage-card__summary {
display: grid;
gap: 10px;
}
.quick-win-manage-card__title strong {
display: block;
font-size: 1.12rem;
line-height: 1.25;
}
.quick-win-manage-card__drag {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--muted);
font-size: 0.9rem;
}
.quick-win-manage-card__actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.quick-win-grid {
display: grid;
gap: 12px;
}
.quick-win-dialog-header {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 14px;
align-items: start;
}
.quick-win-dialog-header__badge {
width: 52px;
height: 52px;
border-radius: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, rgba(37, 99, 235, 0.18), rgba(52, 211, 153, 0.24));
border: 1px solid rgba(132, 152, 190, 0.22);
color: var(--primary-strong);
box-shadow: 0 16px 30px rgba(37, 99, 235, 0.16);
}
.quick-win-dialog-header__badge svg {
width: 24px;
height: 24px;
}
.quick-win-manage-card p {
color: var(--muted);
}
.quick-win-tag-grid {
display: flex;
flex-wrap: wrap;
gap: 12px;
width: 100%;
min-width: 0;
overflow-x: hidden;
}
.quick-win-tag {
position: relative;
min-width: 0;
flex: 0 0 auto;
max-width: 100%;
}
.quick-win-tag input {
position: absolute;
inset: 0;
opacity: 0;
pointer-events: none;
}
.quick-win-tag span {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 0;
max-width: 100%;
padding: 8px 13px;
border-radius: 999px;
border: 1px solid rgba(132, 152, 190, 0.22);
background: var(--surface-soft);
color: var(--text);
font-weight: 700;
font-size: 0.9rem;
text-align: center;
line-height: 1.1;
white-space: nowrap;
transition: transform 0.18s ease, border-color 0.18s ease, background 0.18s ease, box-shadow 0.18s ease, color 0.18s ease;
cursor: pointer;
}
.quick-win-tag input:checked + span {
background: linear-gradient(135deg, rgba(37, 99, 235, 0.18), rgba(52, 211, 153, 0.16));
border-color: rgba(37, 99, 235, 0.34);
color: var(--primary-strong);
box-shadow: 0 16px 28px rgba(37, 99, 235, 0.16);
transform: translateY(-1px);
}
.quick-win-tag--custom span {
border-style: dashed;
}
.quick-win-tag--custom {
flex-basis: 100%;
}
.quick-win-tag--custom span {
width: fit-content;
}
.quick-win-custom-fields {
display: grid;
gap: 14px;
}
.quick-win-custom-fields[hidden] {
display: none !important;
}
.dialog-actions--stack {
display: grid;
gap: 12px;
}
@media (max-width: 640px) {
.brand {
gap: 12px;
}
.brand__mark {
width: 50px;
height: 50px;
flex-basis: 50px;
border-radius: 18px;
}
.brand--public .brand__mark,
.hero-card__brand-mark {
width: 88px;
height: 88px;
flex-basis: 88px;
border-radius: 28px;
}
.task-card {
gap: 14px;
padding: 18px;
}
.task-card__top {
gap: 10px;
}
.task-card__title-block {
gap: 8px;
}
.task-card__title-block .chip-row {
gap: 6px;
}
.task-card__top .icon-button {
width: 44px;
min-width: 44px;
height: 44px;
border-radius: 14px;
margin-top: 0;
}
.task-card h3 {
font-size: 1.15rem;
}
.form-panel h2 {
font-size: 1.85rem;
line-height: 1.08;
}
.complete-dialog__surface {
width: min(100vw - 16px, 410px);
max-width: calc(100vw - 16px);
padding: 18px 16px;
gap: 14px;
overflow-x: hidden;
}
.quick-win-dialog-header {
grid-template-columns: 1fr;
gap: 10px;
}
.quick-win-dialog-header__badge {
width: 42px;
height: 42px;
border-radius: 14px;
}
.quick-win-dialog-header__badge svg {
width: 19px;
height: 19px;
}
.quick-win-tag-grid {
gap: 8px;
}
.quick-win-tag {
max-width: 100%;
flex: 0 1 auto;
}
.quick-win-tag span {
width: auto;
max-width: 100%;
padding: 6px 11px;
font-size: 0.84rem;
white-space: nowrap;
text-align: center;
justify-content: center;
}
.quick-win-tag--custom span {
width: auto;
}
.quick-win-list__toolbar {
justify-content: stretch;
}
.quick-win-list__toolbar .button {
width: 100%;
}
}
.push-box__state {
align-items: flex-start;
padding: 16px;
@@ -1135,6 +1540,10 @@ p {
.complete-dialog__surface--task {
width: min(520px, calc(100vw - 24px));
max-width: calc(100vw - 24px);
overflow-x: hidden;
overscroll-behavior-x: contain;
touch-action: pan-y;
}
.choice-grid {
@@ -1239,6 +1648,9 @@ p {
color: #8db7ff;
}
.status-badge--due_today,
.status-badge--due_tomorrow,
.status-badge--due_day_after_tomorrow,
.status-badge--soon {
background: rgba(245, 158, 11, 0.18);
color: #ffd38a;
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path opacity=".4" fill="currentColor" d="M376 512L448 544L480 616L512 544L584 512L512 480L480 408L448 480L376 512zM408 128L480 160L512 232L544 160L616 128L544 96L512 24L480 96L408 128z"/><path fill="currentColor" d="M160 256L224 112L288 256L432 320L288 384L224 528L160 384L16 320L160 256z"/></svg>

After

Width:  |  Height:  |  Size: 528 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 631 KiB

+177 -7
View File
@@ -3,28 +3,70 @@
const dialogForm = document.getElementById("completeDialogForm");
const dialogChoice = document.getElementById("completeDialogChoice");
const dialogText = document.getElementById("completeDialogText");
const dialogChoices = document.getElementById("completeDialogChoices");
const closeButton = document.getElementById("completeDialogClose");
const quickTaskDialog = document.getElementById("quickTaskDialog");
const quickTaskOpen = document.getElementById("quickTaskOpen");
const quickTaskClose = document.getElementById("quickTaskClose");
const quickWinsSubmit = document.getElementById("quickWinsSubmit");
const quickWinInputs = document.querySelectorAll("[data-quick-win-input]");
const quickWinCustomToggle = document.querySelector("[data-quick-win-custom-toggle]");
const quickWinCustomFields = document.getElementById("quickWinCustomFields");
const quickWinTitle = document.getElementById("quick-title");
const quickWinEffort = document.getElementById("quick-effort");
const currentUserId = document.body.dataset.currentUserId;
const currentUserName = document.body.dataset.currentUserName;
const quickWinSortList = document.querySelector("[data-quick-win-sort-list]");
const quickWinSortIds = document.getElementById("quickWinSortIds");
const quickWinSortSave = document.getElementById("quickWinSortSave");
const quickWinToggleButtons = document.querySelectorAll("[data-quick-win-toggle]");
let draggedQuickWin = null;
let quickWinSortDirty = false;
function buildCompletionOptions(button) {
const options = [];
const assignedPairs = [
[button.dataset.assignedPrimaryId, button.dataset.assignedPrimaryName],
[button.dataset.assignedSecondaryId, button.dataset.assignedSecondaryName],
];
assignedPairs.forEach(([id, label]) => {
if (id && label && !options.some((option) => option.value === id)) {
options.push({ value: id, label });
}
});
if (currentUserId && currentUserName && !options.some((option) => option.value === currentUserId)) {
options.push({ value: currentUserId, label: "Ich" });
}
return options;
}
document.querySelectorAll("[data-complete-action]").forEach((button) => {
button.addEventListener("click", () => {
if (!dialog || !dialogForm || !dialogChoice || !dialogText) {
if (!dialog || !dialogForm || !dialogChoice || !dialogText || !dialogChoices) {
return;
}
dialogForm.action = button.dataset.completeAction;
dialogText.textContent = `Die Aufgabe "${button.dataset.completeTitle}" war ${button.dataset.completeAssigned} zugewiesen. Wer hat sie erledigt?`;
dialog.showModal();
});
});
dialogChoices.innerHTML = "";
document.querySelectorAll("[data-complete-choice]").forEach((button) => {
button.addEventListener("click", () => {
dialogChoice.value = button.dataset.completeChoice || "me";
buildCompletionOptions(button).forEach((option, index) => {
const choiceButton = document.createElement("button");
choiceButton.type = "button";
choiceButton.className = index === 0 ? "button button--secondary" : "button";
choiceButton.dataset.completeChoice = option.value;
choiceButton.textContent = option.label;
choiceButton.addEventListener("click", () => {
dialogChoice.value = option.value;
dialog.close();
dialogForm.submit();
});
dialogChoices.appendChild(choiceButton);
});
dialog.showModal();
});
});
if (closeButton && dialog) {
@@ -39,6 +81,134 @@
quickTaskClose.addEventListener("click", () => quickTaskDialog.close());
}
function updateQuickWinsState() {
const selectedPresetCount = [...quickWinInputs].filter((input) => input.checked).length;
const customSelected = quickWinCustomToggle?.checked === true;
const totalCount = selectedPresetCount + (customSelected ? 1 : 0);
if (quickWinCustomFields) {
quickWinCustomFields.hidden = !customSelected;
}
if (quickWinTitle) {
quickWinTitle.disabled = !customSelected;
quickWinTitle.required = customSelected;
}
if (quickWinEffort) {
quickWinEffort.disabled = !customSelected;
quickWinEffort.required = customSelected;
}
if (quickWinsSubmit) {
quickWinsSubmit.disabled = totalCount === 0;
quickWinsSubmit.textContent = totalCount <= 1 ? "Quick-Win sichern" : "Quick Wins sichern";
}
}
quickWinInputs.forEach((input) => input.addEventListener("change", updateQuickWinsState));
if (quickWinCustomToggle) {
quickWinCustomToggle.addEventListener("change", updateQuickWinsState);
}
updateQuickWinsState();
if (quickTaskDialog) {
quickTaskDialog.addEventListener("close", () => {
const quickWinsForm = document.getElementById("quickWinsForm");
if (!quickWinsForm) {
return;
}
quickWinsForm.reset();
updateQuickWinsState();
});
}
function syncQuickWinSortIds() {
if (!quickWinSortList || !quickWinSortIds) {
return;
}
const ids = [...quickWinSortList.querySelectorAll("[data-quick-win-sort-item]")]
.map((item) => item.dataset.quickWinSortItem)
.filter(Boolean);
quickWinSortIds.value = ids.join(",");
}
function setQuickWinSortDirty(isDirty) {
quickWinSortDirty = isDirty;
if (quickWinSortSave) {
quickWinSortSave.disabled = !isDirty;
}
}
if (quickWinSortList) {
syncQuickWinSortIds();
setQuickWinSortDirty(false);
quickWinSortList.querySelectorAll("[data-quick-win-sort-item]").forEach((item) => {
item.addEventListener("dragstart", () => {
draggedQuickWin = item;
item.classList.add("is-dragging");
});
item.addEventListener("dragend", () => {
item.classList.remove("is-dragging");
draggedQuickWin = null;
});
item.addEventListener("dragover", (event) => {
event.preventDefault();
});
item.addEventListener("drop", async (event) => {
event.preventDefault();
if (!draggedQuickWin || draggedQuickWin === item) {
return;
}
const items = [...quickWinSortList.querySelectorAll("[data-quick-win-sort-item]")];
const draggedIndex = items.indexOf(draggedQuickWin);
const targetIndex = items.indexOf(item);
if (draggedIndex < targetIndex) {
item.after(draggedQuickWin);
} else {
item.before(draggedQuickWin);
}
syncQuickWinSortIds();
setQuickWinSortDirty(true);
});
});
}
quickWinToggleButtons.forEach((button) => {
button.addEventListener("click", () => {
const targetId = button.dataset.target;
if (!targetId) {
return;
}
const target = document.getElementById(targetId);
if (!target) {
return;
}
const willOpen = target.hidden;
document.querySelectorAll("[data-quick-win-edit]").forEach((editForm) => {
editForm.hidden = true;
});
quickWinToggleButtons.forEach((toggle) => {
toggle.setAttribute("aria-expanded", "false");
toggle.textContent = "Bearbeiten";
});
if (willOpen) {
target.hidden = false;
button.setAttribute("aria-expanded", "true");
button.textContent = "Schließen";
}
});
});
const pushButton = document.getElementById("pushToggle");
const pushHint = document.getElementById("pushHint");
const vapidKey = document.body.dataset.pushKey;
+3 -2
View File
@@ -1,8 +1,9 @@
const CACHE_NAME = "putzliga-shell-v2";
const CACHE_NAME = "putzliga-shell-v3";
const ASSETS = [
"/static/css/style.css",
"/static/js/app.js",
"/static/images/logo.svg",
"/static/images/favicon.png",
"/static/images/logo-mark.png",
"/static/images/pwa-icon-192.png",
"/static/images/pwa-icon-512.png"
];
+3
View File
@@ -3,6 +3,9 @@
{% block content %}
<section class="auth-layout">
<div class="hero-card hero-card--brand">
<div class="hero-card__brand-mark">
<img src="{{ url_for('static', filename='images/logo-mark.png') }}" alt="Putzliga Logo">
</div>
<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>
+3 -1
View File
@@ -3,6 +3,9 @@
{% block content %}
<section class="auth-layout">
<div class="hero-card hero-card--brand">
<div class="hero-card__brand-mark">
<img src="{{ url_for('static', filename='images/logo-mark.png') }}" alt="Putzliga Logo">
</div>
<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>
@@ -39,4 +42,3 @@
</section>
</section>
{% endblock %}
+49 -20
View File
@@ -12,17 +12,24 @@
<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="icon" type="image/png" sizes="128x128" href="{{ url_for('static', filename='images/favicon.png') }}">
<link rel="shortcut icon" href="{{ url_for('static', filename='images/favicon.png') }}">
<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', v=asset_version('css/style.css')) }}">
</head>
<body data-push-key="{{ config['VAPID_PUBLIC_KEY'] if current_user.is_authenticated else '' }}">
<body
data-push-key="{{ config['VAPID_PUBLIC_KEY'] if current_user.is_authenticated else '' }}"
data-current-user-id="{{ current_user.id if current_user.is_authenticated else '' }}"
data-current-user-name="{{ current_user.name 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">
<span class="brand__mark">
<img src="{{ url_for('static', filename='images/logo-mark.png') }}" alt="Putzliga Logo" class="brand__logo">
</span>
<div>
<strong>Putzliga</strong>
<span>Haushalt mit Punktestand</span>
@@ -64,7 +71,9 @@
</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">
<span class="brand__mark">
<img src="{{ url_for('static', filename='images/logo-mark.png') }}" alt="Putzliga Logo" class="brand__logo">
</span>
<div>
<strong>Putzliga</strong>
<span>Haushaltsaufgaben mit Liga-Gefühl</span>
@@ -88,9 +97,14 @@
</main>
<footer class="app-footer">
<div class="app-footer__left">
<a href="https://git.hnz.io/hnzio/putzliga/releases" target="_blank" rel="noreferrer">Version {{ app_version }}</a>
<span>·</span>
<a href="https://hnz.io" target="_blank" rel="noreferrer">(c) 2026 @hnz.io</a>
<span>Made with ❤️ in Göttingen.</span>
</div>
<div class="app-footer__right">
<a href="https://hnz.io" target="_blank" rel="noreferrer">© 2026 @ hnz.io</a>
</div>
</footer>
</div>
</div>
@@ -105,7 +119,7 @@
{% endfor %}
</nav>
<button type="button" class="fab-quick-task" id="quickTaskOpen" aria-label="Schnellaufgabe anlegen">
<button type="button" class="fab-quick-task" id="quickTaskOpen" aria-label="Quick-Wins öffnen">
{{ nav_icon('plus') }}
</button>
@@ -114,32 +128,47 @@
<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>
<div class="choice-grid" id="completeDialogChoices"></div>
<button type="button" class="button button--ghost" id="completeDialogClose">Abbrechen</button>
</form>
</dialog>
<dialog class="complete-dialog" id="quickTaskDialog">
<form method="post" action="{{ url_for('tasks.quick_create') }}" class="complete-dialog__surface complete-dialog__surface--task">
<form method="post" action="{{ url_for('tasks.quick_create') }}" class="complete-dialog__surface complete-dialog__surface--task" id="quickWinsForm">
{{ quick_task_form.hidden_tag() }}
<p class="eyebrow">Schnellaufgabe</p>
<h2>Direkt etwas für dich anlegen</h2>
<p class="muted">Titel und Aufwand reichen. Die Aufgabe wird automatisch dir zugewiesen und auf heute gesetzt.</p>
<div class="quick-win-dialog-header">
<span class="quick-win-dialog-header__badge" aria-hidden="true">{{ icon_svg('quick-wins-sparkles')|safe }}</span>
<div>
<p class="eyebrow">Quick-Wins</p>
<h2>Schnell Punkte abstauben</h2>
<p class="muted">Alle Quick-Wins sind für das ganze Team sichtbar. Für „Sonstiges“ kannst du Titel und Aufwand frei wählen.</p>
</div>
</div>
<div class="quick-win-tag-grid">
{% for quick_win in quick_wins %}
<label class="quick-win-tag" data-quick-win-tag>
<input type="checkbox" name="quick_win_ids" value="{{ quick_win.id }}" data-quick-win-input>
<span>{{ quick_win.title }}</span>
</label>
{% endfor %}
<label class="quick-win-tag quick-win-tag--custom" data-quick-win-tag>
<input type="checkbox" name="include_custom" value="1" data-quick-win-custom-toggle>
<span>Sonstiges</span>
</label>
</div>
<div class="quick-win-custom-fields" id="quickWinCustomFields" hidden>
<div class="field">
{{ quick_task_form.title.label }}
{{ quick_task_form.title(placeholder="Zum Beispiel: Küche kurz aufräumen") }}
{{ quick_task_form.title(placeholder="Zum Beispiel: Flur kurz aufräumen", required=False) }}
</div>
<div class="field">
{{ quick_task_form.effort.label }}
{{ quick_task_form.effort() }}
{{ quick_task_form.effort(required=False) }}
</div>
<div class="dialog-actions">
<button type="submit" class="button" name="quick_action" value="save">Aufgabe speichern</button>
<button type="submit" class="button button--secondary" name="quick_action" value="complete">Aufgabe als erledigt speichern</button>
<button type="button" class="button button--ghost" id="quickTaskClose">Abbrechen</button>
</div>
<div class="dialog-actions dialog-actions--stack">
<button type="submit" class="button button--wide" id="quickWinsSubmit" disabled>Quick-Win sichern</button>
<button type="button" class="button button--ghost button--wide" id="quickTaskClose">Abbrechen</button>
</div>
</form>
</dialog>
+15 -7
View File
@@ -45,10 +45,10 @@
{% 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="task-card__title-block">
<div class="chip-row">
{{ status_badge(task) }}
<span class="point-pill">{{ task.points_awarded }} Punkte</span>
<span class="point-pill">{{ task.points_awarded }} Punkte{% if task.is_shared_assignment %} / Person{% endif %}</span>
</div>
<h3>{{ task.title }}</h3>
</div>
@@ -68,7 +68,7 @@
</div>
<div>
<dt>Zuständig</dt>
<dd>{{ task.assigned_user.name if task.assigned_user else 'Unverteilt' }}</dd>
<dd>{{ task.assignee_label }}</dd>
</div>
<div>
<dt>Rhythmus</dt>
@@ -84,18 +84,26 @@
<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>
<span class="task-assignee__avatars">
{% for assigned_user in task.assigned_users %}
{{ avatar(assigned_user) }}
{% endfor %}
</span>
<span>{{ task.assignee_label }}</span>
</div>
{% if not task.completed_at %}
{% if task.assigned_user_id and task.assigned_user_id != current_user.id %}
{% if task.assigned_users and current_user.id not in task.assigned_user_ids %}
<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' }}"
data-complete-assigned="{{ task.assignee_label }}"
data-assigned-primary-id="{{ task.assigned_user.id if task.assigned_user else '' }}"
data-assigned-primary-name="{{ task.assigned_user.name if task.assigned_user else '' }}"
data-assigned-secondary-id="{{ task.assigned_user_secondary.id if task.assigned_user_secondary else '' }}"
data-assigned-secondary-name="{{ task.assigned_user_secondary.name if task.assigned_user_secondary else '' }}"
>
{{ nav_icon('check') }}
<span>Erledigen</span>
+5 -69
View File
@@ -40,12 +40,16 @@
</div>
<label class="checkbox">
{{ form.notification_task_due_enabled() }}
<span>Push für heute oder morgen fällige Aufgaben</span>
<span>Täglicher Push um 09:00 Uhr, wenn heute Aufgaben für dich offen sind</span>
</label>
<label class="checkbox">
{{ form.notification_monthly_winner_enabled() }}
<span>Push zum Monatssieger am 1. um 09:00 Uhr</span>
</label>
<label class="checkbox">
{{ form.notification_badge_enabled() }}
<span>Push, wenn du ein neues Badge freischaltest</span>
</label>
{{ form.submit(class_='button') }}
</form>
</article>
@@ -182,73 +186,5 @@
{% endfor %}
</div>
</section>
<section class="panel">
<p class="eyebrow">Admin</p>
<h2>Schnellaufgabe-Aufwand</h2>
<p class="muted">Hier definierst du die sichtbaren Aufwand-Stufen und die dazugehörigen Punkte. Im Schnellaufgaben-Dialog wird nur die Bezeichnung angezeigt.</p>
<form method="post" action="{{ url_for('settings.update_quick_task_config') }}" class="badge-settings">
{{ quick_task_config_form.hidden_tag() }}
<div class="badge-setting-card">
<div>
<strong>Slot 1</strong>
<p class="muted">Kleine Sache für zwischendurch.</p>
</div>
<div class="field">
{{ quick_task_config_form.fast_label.label }}
{{ quick_task_config_form.fast_label() }}
</div>
<div class="field">
<label for="{{ quick_task_config_form.fast_points.id }}">Punkte</label>
{{ quick_task_config_form.fast_points() }}
</div>
</div>
<div class="badge-setting-card">
<div>
<strong>Slot 2</strong>
<p class="muted">Typische Alltagssache.</p>
</div>
<div class="field">
{{ quick_task_config_form.normal_label.label }}
{{ quick_task_config_form.normal_label() }}
</div>
<div class="field">
<label for="{{ quick_task_config_form.normal_points.id }}">Punkte</label>
{{ quick_task_config_form.normal_points() }}
</div>
</div>
<div class="badge-setting-card">
<div>
<strong>Slot 3</strong>
<p class="muted">Braucht etwas mehr Zeit oder Konzentration.</p>
</div>
<div class="field">
{{ quick_task_config_form.medium_label.label }}
{{ quick_task_config_form.medium_label() }}
</div>
<div class="field">
<label for="{{ quick_task_config_form.medium_points.id }}">Punkte</label>
{{ quick_task_config_form.medium_points() }}
</div>
</div>
<div class="badge-setting-card">
<div>
<strong>Slot 4</strong>
<p class="muted">Spürbarer Aufwand mit mehr Punkten.</p>
</div>
<div class="field">
{{ quick_task_config_form.heavy_label.label }}
{{ quick_task_config_form.heavy_label() }}
</div>
<div class="field">
<label for="{{ quick_task_config_form.heavy_points.id }}">Punkte</label>
{{ quick_task_config_form.heavy_points() }}
</div>
</div>
<div class="field field--full">
{{ quick_task_config_form.submit(class_='button button--secondary') }}
</div>
</form>
</section>
{% endif %}
{% endblock %}
+198
View File
@@ -0,0 +1,198 @@
{% extends "base.html" %}
{% from "partials/macros.html" import nav_icon %}
{% block title %}Quick-Wins · Putzliga{% endblock %}
{% block page_title %}Quick-Wins{% endblock %}
{% block content %}
<section class="settings-tabs">
{% for endpoint, label, icon in settings_tabs %}
<a href="{{ url_for(endpoint) }}" class="settings-tab {% if active_settings_tab == endpoint %}is-active{% endif %}">
{{ nav_icon(icon) }}
<span>{{ label }}</span>
</a>
{% endfor %}
</section>
<section class="two-column">
<article class="panel">
<p class="eyebrow">Gemeinsame Vorlagen</p>
<h2>Quick-Win anlegen</h2>
<p class="muted">Alle hier angelegten Quick-Wins sind direkt für das ganze Team im Plus-Menü verfügbar.</p>
<form method="post" class="form-grid">
{{ quick_win_form.hidden_tag() }}
<div class="field">
{{ quick_win_form.title.label }}
{{ quick_win_form.title(placeholder="Zum Beispiel: Müllbeutel wechseln") }}
{% for error in quick_win_form.title.errors %}<small class="error">{{ error }}</small>{% endfor %}
</div>
<div class="field">
{{ quick_win_form.effort.label }}
{{ quick_win_form.effort() }}
{% for error in quick_win_form.effort.errors %}<small class="error">{{ error }}</small>{% endfor %}
</div>
{{ quick_win_form.submit(class_='button') }}
</form>
</article>
<article class="panel">
<p class="eyebrow">Direkt sichtbar</p>
<h2>Aktive Quick-Wins bearbeiten</h2>
<p class="muted">Per Drag & Drop kannst du die Reihenfolge festlegen, die später auch bei den Quick-Win-Chips erscheint.</p>
<form method="post" action="{{ url_for('settings.reorder_quick_wins') }}" id="quickWinSortForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="ids" id="quickWinSortIds" value="{{ quick_wins|map(attribute='id')|join(',') }}">
</form>
<div class="quick-win-list__toolbar">
<button type="submit" class="button button--secondary" id="quickWinSortSave" form="quickWinSortForm" disabled>Reihenfolge speichern</button>
</div>
<div class="quick-win-list" data-quick-win-sort-list>
{% for quick_win in quick_wins %}
<article class="quick-win-manage-card" draggable="true" data-quick-win-sort-item="{{ quick_win.id }}">
<div class="quick-win-manage-card__summary">
<div class="quick-win-manage-card__drag">
{{ nav_icon('list') }}
<span>Ziehen zum Sortieren</span>
</div>
<div class="quick-win-manage-card__title">
<strong>{{ quick_win.title }}</strong>
</div>
<div class="quick-win-manage-card__actions">
<button
type="button"
class="button button--ghost"
data-quick-win-toggle
data-target="quick-win-edit-{{ quick_win.id }}"
aria-expanded="false"
aria-controls="quick-win-edit-{{ quick_win.id }}"
>
Bearbeiten
</button>
</div>
</div>
<form
method="post"
action="{{ url_for('settings.update_quick_win', quick_win_id=quick_win.id) }}"
class="quick-win-manage-form"
id="quick-win-edit-{{ quick_win.id }}"
data-quick-win-edit
hidden
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="field">
<label for="quick-win-title-{{ quick_win.id }}">Titel</label>
<input id="quick-win-title-{{ quick_win.id }}" type="text" name="title" value="{{ quick_win.title }}" minlength="2" maxlength="160" required>
</div>
<div class="field">
<label for="quick-win-effort-{{ quick_win.id }}">Aufwand</label>
<select id="quick-win-effort-{{ quick_win.id }}" name="effort" required>
{% for effort_key, effort_values in quick_task_config.items() %}
<option value="{{ effort_key }}" {% if quick_win.effort == effort_key %}selected{% endif %}>{{ effort_values.label }}</option>
{% endfor %}
</select>
</div>
<p class="muted">Von {{ quick_win.created_by_user.name }}</p>
<div class="quick-win-manage-card__actions">
<button type="submit" class="button button--secondary">Speichern</button>
{% if quick_win.created_by_user_id == current_user.id or current_user.is_admin %}
<button
type="submit"
class="button button--ghost"
formaction="{{ url_for('settings.delete_quick_win', quick_win_id=quick_win.id) }}"
formmethod="post"
>
Entfernen
</button>
{% endif %}
</div>
</form>
</article>
{% else %}
<div class="empty-state">Noch keine Quick-Wins angelegt. Der erste steht gleich oben bereit.</div>
{% endfor %}
</div>
</article>
</section>
{% if current_user.is_admin %}
<section class="panel">
<p class="eyebrow">Admin</p>
<h2>Quick-Win-Aufwand</h2>
<p class="muted">Hier definierst du die sichtbaren Aufwand-Stufen und die dazugehörigen Punkte. Im Quick-Wins-Dialog wird nur die Bezeichnung angezeigt.</p>
<form method="post" action="{{ url_for('settings.update_quick_task_config') }}" class="badge-settings">
{{ quick_task_config_form.hidden_tag() }}
<div class="badge-setting-card">
<div>
<strong>Slot 1</strong>
<p class="muted">Kleine Sache für zwischendurch.</p>
</div>
<div class="field">
{{ quick_task_config_form.fast_label.label }}
{{ quick_task_config_form.fast_label() }}
</div>
<div class="field">
<label for="{{ quick_task_config_form.fast_points.id }}">Punkte</label>
{{ quick_task_config_form.fast_points() }}
</div>
</div>
<div class="badge-setting-card">
<div>
<strong>Slot 2</strong>
<p class="muted">Typische Alltagssache.</p>
</div>
<div class="field">
{{ quick_task_config_form.normal_label.label }}
{{ quick_task_config_form.normal_label() }}
</div>
<div class="field">
<label for="{{ quick_task_config_form.normal_points.id }}">Punkte</label>
{{ quick_task_config_form.normal_points() }}
</div>
</div>
<div class="badge-setting-card">
<div>
<strong>Slot 3</strong>
<p class="muted">Braucht etwas mehr Zeit oder Konzentration.</p>
</div>
<div class="field">
{{ quick_task_config_form.medium_label.label }}
{{ quick_task_config_form.medium_label() }}
</div>
<div class="field">
<label for="{{ quick_task_config_form.medium_points.id }}">Punkte</label>
{{ quick_task_config_form.medium_points() }}
</div>
</div>
<div class="badge-setting-card">
<div>
<strong>Slot 4</strong>
<p class="muted">Spürbarer Aufwand mit mehr Punkten.</p>
</div>
<div class="field">
{{ quick_task_config_form.heavy_label.label }}
{{ quick_task_config_form.heavy_label() }}
</div>
<div class="field">
<label for="{{ quick_task_config_form.heavy_points.id }}">Punkte</label>
{{ quick_task_config_form.heavy_points() }}
</div>
</div>
<div class="badge-setting-card">
<div>
<strong>Slot 5</strong>
<p class="muted">Extra große Aufgabe für echte Kraftakte.</p>
</div>
<div class="field">
{{ quick_task_config_form.super_heavy_label.label }}
{{ quick_task_config_form.super_heavy_label() }}
</div>
<div class="field">
<label for="{{ quick_task_config_form.super_heavy_points.id }}">Punkte</label>
{{ quick_task_config_form.super_heavy_points() }}
</div>
</div>
<div class="field field--full">
{{ quick_task_config_form.submit(class_='button button--secondary') }}
</div>
</form>
</section>
{% endif %}
{% endblock %}
+3 -1
View File
@@ -11,6 +11,9 @@
<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="today" {% if filters.status == 'today' %}selected{% endif %}>Heute fällig</option>
<option value="tomorrow" {% if filters.status == 'tomorrow' %}selected{% endif %}>Morgen fällig</option>
<option value="day_after_tomorrow" {% if filters.status == 'day_after_tomorrow' %}selected{% endif %}>Übermorgen 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>
@@ -48,4 +51,3 @@
{% endfor %}
</section>
{% endblock %}
+12 -12
View File
@@ -71,14 +71,14 @@
<div class="calendar-mobile-day__tasks">
{% for task in group.tasks %}
<a href="{{ url_for('tasks.edit', task_id=task.id) }}" class="calendar-task calendar-task--{{ task.status }}">
<strong class="calendar-task__title">{{ task.title }}</strong>
<small class="calendar-task__person">
<strong class="calendar-task__title" lang="de">{{ task.title|hyphenate_de }}</strong>
<small class="calendar-task__person" lang="de">
{% if task.completed_by_user %}
{{ task.completed_by_user.name }}
{% elif task.assigned_user %}
{{ task.assigned_user.name }}
{{ task.completed_by_user.name|hyphenate_de }}
{% elif task.assigned_users %}
{{ task.assignee_label|hyphenate_de }}
{% else %}
Ohne Zuweisung
{{ 'Ohne Zuweisung'|hyphenate_de }}
{% endif %}
</small>
</a>
@@ -104,14 +104,14 @@
<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 }}">
<strong class="calendar-task__title">{{ task.title }}</strong>
<small class="calendar-task__person">
<strong class="calendar-task__title" lang="de">{{ task.title|hyphenate_de }}</strong>
<small class="calendar-task__person" lang="de">
{% if task.completed_by_user %}
{{ task.completed_by_user.name }}
{% elif task.assigned_user %}
{{ task.assigned_user.name }}
{{ task.completed_by_user.name|hyphenate_de }}
{% elif task.assigned_users %}
{{ task.assignee_label|hyphenate_de }}
{% else %}
Ohne Zuweisung
{{ 'Ohne Zuweisung'|hyphenate_de }}
{% endif %}
</small>
</a>
+3 -4
View File
@@ -46,13 +46,13 @@
<section class="stack">
<div class="section-heading">
<h2>Bald fällig</h2>
<span class="section-heading__count">{{ sections.soon|length }}</span>
<span class="section-heading__count">{{ soon_tasks|length }}</span>
</div>
<div class="task-grid">
{% for task in sections.soon %}
{% for task in soon_tasks %}
{{ task_card(task, current_user) }}
{% else %}
<div class="empty-state">Gerade nichts, was in den nächsten Tagen drängt.</div>
<div class="empty-state">Gerade ist nichts bald fällig. Sehr stark.</div>
{% endfor %}
</div>
</section>
@@ -85,4 +85,3 @@
</div>
</section>
{% endblock %}
+21 -1
View File
@@ -7,6 +7,9 @@
<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() }}
{% if mode == 'edit' %}
<input type="hidden" name="next" value="{{ next_url }}">
{% endif %}
<div class="field field--full">
{{ form.title.label }}
@@ -32,6 +35,13 @@
{% for error in form.assigned_user_id.errors %}<small class="error">{{ error }}</small>{% endfor %}
</div>
<div class="field">
{{ form.assigned_user_secondary_id.label }}
{{ form.assigned_user_secondary_id() }}
<small class="muted">Wenn du hier noch jemanden auswählst, zählen die Punkte pro Person halbiert.</small>
{% for error in form.assigned_user_secondary_id.errors %}<small class="error">{{ error }}</small>{% endfor %}
</div>
<div class="field">
{{ form.due_date.label }}
{{ form.due_date() }}
@@ -57,8 +67,18 @@
<div class="form-actions field--full">
{{ form.submit(class_='button') }}
<a class="button button--ghost" href="{{ url_for('tasks.all_tasks') }}">Abbrechen</a>
{% if mode == 'edit' %}
<button
type="submit"
class="button button--ghost button--danger"
formmethod="post"
formaction="{{ url_for('tasks.delete', task_id=task.id) }}"
onclick="return confirm('Diese Aufgabe wirklich löschen?');"
>
Aufgabe löschen
</button>
{% endif %}
</div>
</form>
</section>
{% endblock %}
+1
View File
@@ -4,5 +4,6 @@ Flask-SQLAlchemy==3.1.1
Flask-WTF==1.2.2
email-validator==2.2.0
gunicorn==23.0.0
pyphen==0.17.2
pywebpush==2.0.3
python-dotenv==1.0.1