20 Commits

Author SHA1 Message Date
hnzio 67d362f1d9 feat: release 0.7.0 celebration flow 2026-04-16 13:41:22 +02:00
hnzio ba4a112bbc fix: constrain quick win dialog on mobile 2026-04-16 13:26:14 +02:00
hnzio 7bf5b8b09d fix: prevent mobile form and quick win overflow 2026-04-16 12:39:50 +02:00
hnzio 81c8f5fd9b fix: label due today tasks correctly 2026-04-16 12:30:49 +02:00
hnzio ae055841e7 fix: refresh app icons and raise mobile quick win button 2026-04-16 12:07:16 +02:00
hnzio 11ebb568db fix: improve quick actions and mobile archive nav 2026-04-16 12:04:55 +02:00
hnzio 03d3a50169 feat: add task archive and simplify task cards 2026-04-16 12:00:07 +02:00
hnzio 8cab2d1929 feat: use refreshed logo as favicon 2026-04-15 14:38:52 +02:00
hnzio 0eb1024b0f feat: add refreshed logo asset 2026-04-15 14:37:31 +02:00
hnzio e4589df111 feat: improve scheduled push notifications 2026-04-15 14:28:31 +02:00
hnzio dba87ebcf2 fix: regroup soon tasks in my tasks view 2026-04-15 13:47:13 +02:00
hnzio ce7a371caf fix: persist quick win reorder changes 2026-04-15 13:34:12 +02:00
hnzio 25459216bc fix: refine task card header spacing 2026-04-15 13:29:17 +02:00
hnzio e7a22ec27d fix: tighten mobile task edit and quick win settings layout 2026-04-15 13:25:25 +02:00
hnzio f44b7bf465 refactor: simplify quick win settings and mobile dialog 2026-04-15 13:23:31 +02:00
hnzio 4233175067 feat: add shared task assignments and quick win sorting 2026-04-15 13:18:50 +02:00
hnzio f8f3641811 feat: add task deletion for all users 2026-04-15 12:37:57 +02:00
hnzio 2f2e543a79 feat: refine quick wins workflow and calendar layout 2026-04-15 12:17:58 +02:00
hnzio 4aa4447c01 feat: add shared quick wins workflow 2026-04-15 11:49:46 +02:00
hnzio 07ab0461e9 feat: add personal ics feeds for assigned tasks 2026-04-13 11:34:14 +02:00
38 changed files with 2469 additions and 365 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.5.1",
"version": "0.7.0",
"manifestVersion": 2,
"healthCheckPath": "/healthz",
"httpPort": 8000,
+61 -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,44 @@ Der ausgegebene `VAPID_PRIVATE_KEY` ist bereits `.env`-freundlich mit escaped Ne
## Release Notes
### 0.7.0
- Aufgaben und Quick-Wins lösen jetzt eine kurze, subtile Punkte-Animation mit Glas-Look und Firework-Effekt aus
- Celebration-Zahl für Mobilgeräte deutlich vergrößert und direkt auf transparent schimmernde Ziffern umgestellt
- Quick-Wins-Dialog technisch auf robusteres natives Dialog-Verhalten zurückgeführt
- Quick-Wins lassen sich jetzt wieder zuverlässig schließen, auch mobil
- Tap auf den Dialog-Backdrop schließt Abschluss- und Quick-Win-Dialog jetzt ebenfalls sauber
- Scrollposition bleibt beim Erledigen von Aufgaben und Quick-Wins erhalten, statt nach oben zu springen
- Redirect nach Abschluss übergibt die verbuchten Punkte gezielt an die UI, ohne die URL dauerhaft zu verschmutzen
- Cloudron-Version auf `0.7.0` angehoben
### 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
- Export enthält nur eigene zugewiesene Aufgaben mit Titel, Beschreibung und Fälligkeitsdatum
- Kalender-Link in den Optionen sichtbar gemacht und Token-Neugenerierung ergänzt
- Cloudron-Version auf `0.6.0` angehoben
### 0.5.1
- Footer mit automatischer Versionsanzeige und Links zu Releases und hnz.io
+48
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:
@@ -77,6 +90,7 @@ def create_app(config_class: type[Config] = Config) -> Flask:
"nav_items": [
("tasks.my_tasks", "Meine Aufgaben", "house"),
("tasks.all_tasks", "Alle", "list"),
("tasks.archive_view", "Archiv", "check-double"),
("tasks.create", "Neu", "plus"),
("tasks.calendar_view", "Kalender", "calendar"),
("scoreboard.index", "Highscore", "trophy"),
@@ -85,6 +99,7 @@ def create_app(config_class: type[Config] = Config) -> Flask:
"mobile_nav_items": [
("tasks.my_tasks", "Meine Aufgaben", "house"),
("tasks.all_tasks", "Alle Aufgaben", "list"),
("tasks.archive_view", "Archiv", "check-double"),
("tasks.calendar_view", "Kalender", "calendar"),
("scoreboard.index", "Highscore", "trophy"),
("settings.index", "Optionen", "gear"),
@@ -94,6 +109,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 +124,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")
+64 -3
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import secrets
from datetime import UTC, date, datetime, timedelta
from flask_login import UserMixin
@@ -24,8 +25,10 @@ class User(UserMixin, TimestampMixin, db.Model):
password_hash = db.Column(db.String(255), nullable=False)
is_admin = db.Column(db.Boolean, nullable=False, default=False)
avatar_path = db.Column(db.String(255), nullable=True)
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",
@@ -33,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",
@@ -46,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",
@@ -60,6 +76,11 @@ class User(UserMixin, TimestampMixin, db.Model):
def check_password(self, password: str) -> bool:
return check_password_hash(self.password_hash, password)
def ensure_calendar_feed_token(self) -> str:
if not self.calendar_feed_token:
self.calendar_feed_token = secrets.token_urlsafe(32)
return self.calendar_feed_token
@property
def display_avatar(self) -> str:
return self.avatar_path or "images/avatars/default.svg"
@@ -79,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)
@@ -99,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)
@@ -115,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": "Heute fällig",
"due_tomorrow": "Morgen fällig",
"due_day_after_tomorrow": "Übermorgen fällig",
"overdue": "Überfällig",
"completed": "Erledigt",
}
+13 -1
View File
@@ -3,9 +3,11 @@ from __future__ import annotations
from functools import lru_cache
from pathlib import Path
from flask import Blueprint, current_app, redirect, send_from_directory, url_for
from flask import Blueprint, Response, current_app, redirect, send_from_directory, url_for
from flask_login import current_user
from ..models import User
from ..services.calendar_feeds import build_calendar_feed
bp = Blueprint("main", __name__)
@@ -39,6 +41,16 @@ def uploads(filename: str):
return send_from_directory(current_app.config["UPLOAD_FOLDER"], filename)
@bp.route("/calendar-feed/<token>.ics")
def calendar_feed(token: str):
user = User.query.filter_by(calendar_feed_token=token).first_or_404()
body = build_calendar_feed(user, url_for("tasks.my_tasks", _external=True))
response = Response(body, content_type="text/calendar; charset=utf-8")
response.headers["Content-Disposition"] = 'inline; filename="putzliga.ics"'
response.headers["Cache-Control"] = "private, max-age=300"
return response
@lru_cache(maxsize=64)
def load_icon_svg(name: str, static_folder: str) -> str:
path = Path(static_folder) / "icons" / f"{name}.svg"
+156 -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
@@ -45,24 +49,15 @@ def _save_avatar(file_storage) -> str:
@bp.route("", methods=["GET", "POST"])
@login_required
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:
@@ -76,10 +71,9 @@ 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),
push_ready=push_enabled(),
has_subscription=bool(subscriptions),
settings_tabs=_settings_tabs(),
@@ -87,6 +81,64 @@ 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():
current_user.calendar_feed_token = None
current_user.ensure_calendar_feed_token()
db.session.commit()
flash("Dein persönlicher Kalender-Link wurde neu erzeugt.", "success")
return redirect(url_for("settings.index"))
@bp.route("/badges")
@login_required
def badges():
@@ -162,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"])
@@ -207,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()
+245 -38
View File
@@ -2,19 +2,22 @@ from __future__ import annotations
import calendar
from collections import defaultdict
from datetime import date
from datetime import date, timedelta
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
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,22 +30,112 @@ 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,
"open": 2,
}
return order.get(task.status, 99)
def _task_sort_key(task: TaskInstance, sort: str) -> tuple:
if sort == "points":
return (-task.points_awarded, task.due_date, task.title.lower())
if sort == "user":
return (task.assignee_label.lower(), task.due_date, task.title.lower())
return (task.due_date, task.title.lower())
def _group_active_tasks(tasks: list[TaskInstance], *, sort: str = "due") -> dict[str, list[TaskInstance]]:
sections = {
"overdue": [],
"today": [],
"soon": [],
"open": [],
}
for task in tasks:
if task.is_completed:
continue
if task.status == "overdue":
sections["overdue"].append(task)
elif task.status == "due_today":
sections["today"].append(task)
elif task.status in {"due_tomorrow", "due_day_after_tomorrow"}:
sections["soon"].append(task)
else:
sections["open"].append(task)
sections["overdue"].sort(key=lambda task: _task_sort_key(task, sort))
sections["today"].sort(key=lambda task: _task_sort_key(task, sort))
sections["soon"].sort(
key=lambda task: (_my_tasks_soon_priority(task),) + _task_sort_key(task, sort)
)
sections["open"].sort(key=lambda task: _task_sort_key(task, sort))
return sections
def _archive_day_priority(day: date, today: date) -> tuple[int, int]:
if day == today:
return (0, 0)
if day == today - timedelta(days=1):
return (1, 0)
if day == today - timedelta(days=2):
return (2, 0)
return (3, -day.toordinal())
def _archive_day_label(day: date, today: date) -> str:
if day == today:
return "Heute"
if day == today - timedelta(days=1):
return "Gestern"
if day == today - timedelta(days=2):
return "Vorgestern"
return day.strftime("%d.%m.%Y")
def _redirect_with_celebration(target_url: str, points: int | None = None):
if not points or points <= 0:
return redirect(target_url)
parts = urlsplit(target_url)
query = dict(parse_qsl(parts.query, keep_blank_values=True))
query["celebrate_points"] = str(points)
redirect_url = urlunsplit(
(parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment)
)
return redirect(redirect_url)
@bp.route("/my-tasks")
@login_required
def my_tasks():
tasks = (
TaskInstance.query.filter_by(assigned_user_id=current_user.id)
all_tasks = (
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)
refresh_task_statuses(all_tasks)
sections = _group_active_tasks(all_tasks)
sections = {"open": [], "soon": [], "overdue": [], "completed": []}
for task in tasks:
sections[task.status].append(task)
completed_count = len(sections["completed"])
active_count = len(sections["open"]) + len(sections["soon"]) + len(sections["overdue"])
completed_count = len([task for task in all_tasks if task.is_completed])
active_count = (
len(sections["overdue"])
+ len(sections["today"])
+ len(sections["soon"])
+ len(sections["open"])
)
completion_ratio = 0 if completed_count + active_count == 0 else round(completed_count / (completed_count + active_count) * 100)
return render_template(
@@ -63,9 +156,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,24 +181,77 @@ def all_tasks():
refresh_task_statuses(tasks)
if status != "all":
status_map = {"completed": "completed", "overdue": "overdue", "open": "open", "soon": "soon"}
selected = status_map.get(status)
if selected:
tasks = [task for task in tasks if task.status == selected]
if status == "soon":
tasks = [task for task in tasks if task.status in {"due_tomorrow", "due_day_after_tomorrow"}]
else:
status_map = {
"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]
tasks = [task for task in tasks if not task.is_completed]
else:
tasks = [task for task in tasks if not task.is_completed]
sections = _group_active_tasks(tasks, sort=sort)
return render_template(
"tasks/all_tasks.html",
tasks=tasks,
sections=sections,
users=User.query.order_by(User.name.asc()).all(),
filters={"status": status, "mine": mine, "user_id": user_filter, "sort": sort},
)
@bp.route("/archive")
@login_required
def archive_view():
selected_user_id = request.args.get("user_id", type=int) or current_user.id
archive_users = User.query.order_by(User.name.asc()).all()
selected_user = next((user for user in archive_users if user.id == selected_user_id), current_user)
ordered_users = sorted(archive_users, key=lambda user: (user.id != current_user.id, user.name.lower()))
completed_tasks = (
TaskInstance.query.filter_by(completed_by_user_id=selected_user.id)
.filter(TaskInstance.completed_at.isnot(None))
.order_by(TaskInstance.completed_at.desc(), TaskInstance.updated_at.desc())
.all()
)
today = today_local()
grouped: dict[date, list[TaskInstance]] = defaultdict(list)
for task in completed_tasks:
grouped[task.completed_at.date()].append(task)
archive_sections = [
{
"label": _archive_day_label(day, today),
"day": day,
"tasks": grouped[day],
}
for day in sorted(grouped.keys(), key=lambda value: _archive_day_priority(value, today))
]
return render_template(
"tasks/archive.html",
archive_users=ordered_users,
selected_user=selected_user,
archive_sections=archive_sections,
)
@bp.route("/tasks/new", methods=["GET", "POST"])
@login_required
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,27 +265,52 @@ 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] = []
total_points = 0
if not form.validate_on_submit():
for field_errors in form.errors.values():
for error in field_errors:
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)
total_points += task.points_awarded
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)
total_points += task.points_awarded
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")
return redirect(request.referrer or url_for("tasks.my_tasks"))
flash(f"{len(created_titles)} Quick-Wins wurden als erledigt gespeichert.", "success")
return _redirect_with_celebration(
request.referrer or url_for("tasks.my_tasks"),
total_points,
)
@bp.route("/tasks/<int:task_id>/edit", methods=["GET", "POST"])
@@ -138,12 +319,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 +336,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,12 +363,23 @@ 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
awarded_points = task.points_awarded
complete_task(task, completed_by_id)
flash("Punkte verbucht. Gute Arbeit.", "success")
return redirect(request.referrer or url_for("tasks.my_tasks"))
return _redirect_with_celebration(
request.referrer or url_for("tasks.my_tasks"),
awarded_points,
)
@bp.route("/calendar")
+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"),
]
+51 -28
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(
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,
)
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(
TaskInstance.assigned_user_id == user_id,
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
+81 -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
@@ -17,20 +17,95 @@ def ensure_schema_and_admins() -> None:
db.session.execute(text("ALTER TABLE user ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT 0"))
db.session.commit()
if "calendar_feed_token" not in column_names:
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()
if users_without_feed:
for user in users_without_feed:
user.ensure_calendar_feed_token()
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()
+67
View File
@@ -0,0 +1,67 @@
from __future__ import annotations
from datetime import UTC, datetime, time, timedelta
from sqlalchemy import or_
from ..models import TaskInstance, User
def _ics_escape(value: str | None) -> str:
text = (value or "").replace("\\", "\\\\")
text = text.replace(";", "\\;").replace(",", "\\,")
text = text.replace("\r\n", "\\n").replace("\n", "\\n").replace("\r", "\\n")
return text
def _format_date(value) -> str:
return value.strftime("%Y%m%d")
def _format_timestamp(value: datetime | None) -> str:
timestamp = value or datetime.now(UTC).replace(tzinfo=None)
return timestamp.strftime("%Y%m%dT%H%M%SZ")
def build_calendar_feed(user: User, base_url: str) -> str:
tasks = (
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()
)
lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//hnz.io//Putzliga//DE",
"CALSCALE:GREGORIAN",
"METHOD:PUBLISH",
f"X-WR-CALNAME:{_ics_escape(f'Putzliga - {user.name}')}",
f"X-WR-CALDESC:{_ics_escape('Persoenlicher Aufgabenfeed aus Putzliga')}",
]
for task in tasks:
due_end = task.due_date + timedelta(days=1)
description = _ics_escape(task.description)
lines.extend(
[
"BEGIN:VEVENT",
f"UID:taskinstance-{task.id}@putzliga",
f"DTSTAMP:{_format_timestamp(task.updated_at)}",
f"LAST-MODIFIED:{_format_timestamp(task.updated_at)}",
f"SUMMARY:{_ics_escape(task.title)}",
f"DESCRIPTION:{description}",
f"DTSTART;VALUE=DATE:{_format_date(task.due_date)}",
f"DTEND;VALUE=DATE:{_format_date(due_end)}",
f"URL:{_ics_escape(base_url)}",
"END:VEVENT",
]
)
lines.append("END:VCALENDAR")
return "\r\n".join(lines) + "\r\n"
+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)
+108 -49
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,56 +60,96 @@ def _send_subscription(subscription: PushSubscription, payload: dict) -> bool:
return False
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()
subscriptions = _subscriptions_for_user(user.id)
if not subscriptions:
result.skipped += 1
return result
if _notification_exists(user.id, notification_type, marker):
result.skipped += 1
return result
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, 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
today = local_now().date()
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.assigned_user_id.isnot(None),
TaskInstance.due_date <= today + timedelta(days=1),
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:
user = task.assigned_user
if not user or not user.notification_task_due_enabled:
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
payload_marker = {"task_instance_id": task.id, "due_date": task.due_date.isoformat()}
if _notification_exists(user.id, "task_due", payload_marker):
personal_tasks = tasks_by_user.get(user.id, [])
if not personal_tasks:
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."
task_count = len(personal_tasks)
payload = {
"title": f"Putzliga erinnert: {task.title}",
"body": body,
"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"task-{task.id}",
"tag": f"due-{today.isoformat()}-{user.id}",
}
sent_any = False
for subscription in subscriptions:
if _send_subscription(subscription, payload):
sent_any = True
result.sent += 1
else:
result.failed += 1
if sent_any:
_log_notification(user.id, "task_due", payload_marker)
db.session.commit()
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()
+687 -31
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 {
@@ -345,6 +410,11 @@ p {
gap: 14px;
}
.form-actions {
flex-wrap: wrap;
min-width: 0;
}
.progress {
margin-top: 12px;
height: 14px;
@@ -374,6 +444,9 @@ p {
.section-heading h2,
.panel h2 {
font-size: 1.5rem;
min-width: 0;
overflow-wrap: break-word;
hyphens: auto;
}
.section-heading__count,
@@ -406,12 +479,41 @@ p {
.task-card {
display: grid;
gap: 16px;
padding: 20px;
gap: 14px;
padding: 18px;
}
.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;
font-size: 1.24rem;
line-height: 1.08;
overflow-wrap: break-word;
hyphens: auto;
}
.task-card--compact {
@@ -460,6 +562,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;
@@ -477,8 +582,8 @@ p {
.task-meta {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 10px 14px;
}
.task-meta dt {
@@ -494,11 +599,30 @@ p {
font-weight: 600;
}
.task-card--compact .task-meta {
grid-template-columns: 1fr;
}
.task-assignee {
display: inline-flex;
align-items: center;
gap: 10px;
color: var(--muted);
min-width: 0;
}
.task-assignee__avatars {
display: inline-flex;
align-items: center;
}
.task-assignee__avatars .avatar + .avatar {
margin-left: -10px;
border: 2px solid var(--surface-strong);
}
.task-assignee span:last-child {
overflow-wrap: break-word;
}
.avatar {
@@ -565,6 +689,11 @@ p {
color: var(--text);
}
.button--danger {
border-color: rgba(225, 29, 72, 0.3);
color: var(--danger);
}
.button--wide {
width: 100%;
}
@@ -642,6 +771,46 @@ p {
gap: 14px;
}
.quick-actions {
display: grid;
gap: 12px;
}
.archive-user-tabs {
display: flex;
gap: 12px;
overflow-x: auto;
padding-bottom: 4px;
margin-bottom: 8px;
}
.archive-user-tab {
flex: 0 0 auto;
min-width: 108px;
display: grid;
justify-items: center;
gap: 8px;
padding: 14px 16px;
border-radius: 20px;
border: 1px solid var(--border);
background: var(--surface);
box-shadow: var(--shadow);
color: var(--muted);
text-align: center;
}
.archive-user-tab span {
font-size: 0.92rem;
font-weight: 700;
line-height: 1.15;
}
.archive-user-tab.is-active {
color: var(--text);
border-color: rgba(37, 99, 235, 0.22);
background: linear-gradient(135deg, rgba(37, 99, 235, 0.14), rgba(52, 211, 153, 0.1));
}
.panel--toolbar {
display: flex;
justify-content: space-between;
@@ -857,32 +1026,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 +1204,330 @@ 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;
}
.archive-user-tabs {
gap: 10px;
margin-bottom: 4px;
}
.archive-user-tab {
min-width: 92px;
padding: 12px 12px;
gap: 6px;
border-radius: 18px;
}
.archive-user-tab span {
font-size: 0.82rem;
}
.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;
overflow-wrap: break-word;
hyphens: auto;
}
.complete-dialog__surface {
width: min(410px, 100%);
max-width: 100%;
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: 100%;
max-width: 100%;
padding: 6px 11px;
font-size: 0.84rem;
white-space: normal;
overflow-wrap: anywhere;
text-align: center;
justify-content: center;
}
.quick-win-tag--custom span {
width: auto;
}
.form-actions {
display: grid;
grid-template-columns: 1fr;
align-items: stretch;
}
.celebration-score {
width: 80vw;
font-size: clamp(5.5rem, 28vw, 8.5rem);
}
.celebration-glow {
width: min(78vw, 240px);
}
.form-actions .button,
.form-actions a.button {
width: 100%;
}
.quick-win-list__toolbar {
justify-content: stretch;
}
.quick-win-list__toolbar .button {
width: 100%;
}
}
.push-box__state {
align-items: flex-start;
padding: 16px;
@@ -1064,8 +1562,8 @@ p {
right: 8px;
bottom: calc(10px + env(safe-area-inset-bottom));
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 4px;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 3px;
padding: 8px 8px;
border-radius: 22px;
background: var(--nav-bg);
@@ -1079,13 +1577,13 @@ p {
.nav-link {
display: grid;
justify-items: center;
gap: 5px;
gap: 4px;
min-width: 0;
padding: 10px 4px 9px;
padding: 10px 3px 9px;
color: var(--muted);
border-radius: 16px;
text-align: center;
font-size: 0.66rem;
font-size: 0.61rem;
font-weight: 700;
line-height: 1.05;
}
@@ -1093,7 +1591,7 @@ p {
.bottom-nav__item span {
display: block;
width: 100%;
min-height: 2.1em;
min-height: 2.3em;
white-space: normal;
word-break: keep-all;
overflow-wrap: break-word;
@@ -1107,15 +1605,33 @@ p {
.nav-icon,
.nav-icon svg {
width: 20px;
height: 20px;
width: 18px;
height: 18px;
display: inline-block;
}
.complete-dialog {
inset: 0;
width: 100vw;
max-width: 100vw;
min-height: 100vh;
min-height: 100dvh;
margin: 0;
padding: 12px;
border: 0;
padding: 0;
background: transparent;
overflow: visible;
box-sizing: border-box;
}
.complete-dialog:not([open]) {
display: none;
}
.complete-dialog[open] {
display: flex;
align-items: center;
justify-content: center;
}
.complete-dialog::backdrop {
@@ -1124,17 +1640,87 @@ p {
}
.complete-dialog__surface {
width: min(460px, calc(100vw - 24px));
width: min(460px, 100%);
max-width: 100%;
padding: 24px;
border-radius: 28px;
background: var(--surface-strong);
box-shadow: var(--shadow);
display: grid;
gap: 18px;
min-width: 0;
}
.complete-dialog__surface--task {
width: min(520px, calc(100vw - 24px));
width: min(520px, 100%);
max-width: 100%;
overflow-x: hidden;
overscroll-behavior-x: contain;
touch-action: pan-y;
}
.celebration-layer {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 90;
overflow: hidden;
}
.celebration-score,
.celebration-glow,
.celebration-particle {
position: absolute;
left: 50%;
top: 50%;
}
.celebration-score {
width: min(80vw, 520px);
padding: 0;
background:
linear-gradient(
135deg,
rgba(255, 255, 255, 0.98) 0%,
rgba(214, 234, 255, 0.92) 18%,
rgba(94, 168, 255, 0.72) 42%,
rgba(52, 211, 153, 0.74) 70%,
rgba(255, 255, 255, 0.92) 100%
);
color: transparent;
-webkit-text-fill-color: transparent;
-webkit-background-clip: text;
background-clip: text;
-webkit-text-stroke: 1px rgba(255, 255, 255, 0.22);
font-size: clamp(6.5rem, 28vw, 12rem);
font-weight: 900;
line-height: 0.9;
letter-spacing: -0.08em;
text-align: center;
transform: translate(-50%, -50%);
filter:
drop-shadow(0 10px 24px rgba(94, 168, 255, 0.18))
drop-shadow(0 0 18px rgba(255, 255, 255, 0.18));
animation: celebration-score-in 1.15s cubic-bezier(0.18, 0.84, 0.24, 1) forwards;
}
.celebration-glow {
width: min(56vw, 280px);
aspect-ratio: 1;
border-radius: 50%;
background:
radial-gradient(circle, rgba(94, 168, 255, 0.26) 0%, rgba(52, 211, 153, 0.2) 42%, rgba(94, 168, 255, 0) 74%);
transform: translate(-50%, -50%);
filter: blur(10px);
animation: celebration-glow 0.95s ease-out forwards;
}
.celebration-particle {
width: var(--size, 10px);
height: var(--size, 10px);
border-radius: 999px;
transform: translate(-50%, -50%) rotate(var(--angle)) translateY(-4px);
animation: celebration-particle 0.82s ease-out var(--delay, 0s) forwards;
}
.choice-grid {
@@ -1156,7 +1742,7 @@ p {
.fab-quick-task {
position: fixed;
right: max(16px, env(safe-area-inset-right));
bottom: calc(72px + var(--safe-bottom));
bottom: calc(86px + var(--safe-bottom));
width: 62px;
height: 62px;
border: 2px solid rgba(255, 255, 255, 0.55);
@@ -1177,6 +1763,61 @@ p {
height: 24px;
}
@keyframes celebration-score-in {
0% {
opacity: 0;
transform: translate(-50%, -42%) scale(0.74);
filter: blur(16px);
}
12% {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
filter: blur(0);
}
58% {
opacity: 1;
transform: translate(-50%, -53%) scale(1.03);
filter: blur(0);
}
100% {
opacity: 0;
transform: translate(-50%, -76%) scale(1.08);
filter: blur(20px);
}
}
@keyframes celebration-glow {
0% {
opacity: 0;
transform: translate(-50%, -50%) scale(0.32);
}
22% {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
100% {
opacity: 0;
transform: translate(-50%, -50%) scale(1.42);
}
}
@keyframes celebration-particle {
0% {
opacity: 0;
transform: translate(-50%, -50%) rotate(var(--angle)) translateY(0) scale(0.4);
filter: blur(4px);
}
18% {
opacity: 1;
filter: blur(0);
}
100% {
opacity: 0;
transform: translate(-50%, -50%) rotate(var(--angle)) translateY(calc(var(--distance) * -1)) scale(0.9);
filter: blur(6px);
}
}
@media (max-width: 759px) {
.calendar-toolbar-mobile__header {
align-items: flex-start;
@@ -1239,6 +1880,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;
@@ -1377,6 +2021,18 @@ p {
}
}
@media (prefers-reduced-motion: reduce) {
.celebration-score {
animation-duration: 0.46s;
}
.celebration-glow,
.celebration-particle {
animation: none;
opacity: 0;
}
}
@media (min-width: 1100px) {
.app-shell {
display: grid;
+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.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 631 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 587 KiB

+328 -11
View File
@@ -3,27 +3,111 @@
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]");
const celebrationLayer = document.getElementById("celebrationLayer");
const celebratePoints = Number.parseInt(document.body.dataset.celebratePoints || "", 10);
const scrollRestoreKey = "putzliga:scroll-restore";
let draggedQuickWin = null;
let quickWinSortDirty = false;
function rememberScrollPosition() {
try {
window.sessionStorage.setItem(
scrollRestoreKey,
JSON.stringify({
path: window.location.pathname,
y: window.scrollY,
}),
);
} catch (_) {
// Ignore storage errors and continue normally.
}
}
function restoreScrollPosition() {
try {
const rawValue = window.sessionStorage.getItem(scrollRestoreKey);
if (!rawValue) {
return;
}
const saved = JSON.parse(rawValue);
window.sessionStorage.removeItem(scrollRestoreKey);
if (!saved || saved.path !== window.location.pathname || typeof saved.y !== "number") {
return;
}
window.requestAnimationFrame(() => {
window.requestAnimationFrame(() => {
window.scrollTo({ top: saved.y, behavior: "auto" });
});
});
} catch (_) {
// Ignore malformed storage data.
}
}
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";
dialog.close();
dialogForm.submit();
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;
rememberScrollPosition();
dialog.close();
dialogForm.submit();
});
dialogChoices.appendChild(choiceButton);
});
dialog.showModal();
});
});
@@ -32,13 +116,239 @@
}
if (quickTaskOpen && quickTaskDialog) {
quickTaskOpen.addEventListener("click", () => quickTaskDialog.showModal());
quickTaskOpen.addEventListener("click", () => {
if (!quickTaskDialog.open) {
quickTaskDialog.showModal();
}
});
}
if (quickTaskClose && quickTaskDialog) {
quickTaskClose.addEventListener("click", () => quickTaskDialog.close());
quickTaskClose.addEventListener("click", () => {
if (quickTaskDialog.open) {
quickTaskDialog.close();
}
});
}
[dialog, quickTaskDialog].forEach((activeDialog) => {
if (!activeDialog) {
return;
}
activeDialog.addEventListener("click", (event) => {
if (event.target === activeDialog && activeDialog.open) {
activeDialog.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();
});
}
if (dialogForm) {
dialogForm.addEventListener("submit", () => {
rememberScrollPosition();
});
}
document.querySelectorAll('form[action*="/complete"]').forEach((form) => {
form.addEventListener("submit", () => {
rememberScrollPosition();
});
});
const quickWinsForm = document.getElementById("quickWinsForm");
if (quickWinsForm) {
quickWinsForm.addEventListener("submit", () => {
rememberScrollPosition();
});
}
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;
}
}
function clearCelebrationQuery() {
if (!window.history.replaceState) {
return;
}
const url = new URL(window.location.href);
if (!url.searchParams.has("celebrate_points")) {
return;
}
url.searchParams.delete("celebrate_points");
const nextUrl = `${url.pathname}${url.search}${url.hash}`;
window.history.replaceState({}, document.title, nextUrl);
}
function celebrateCompletion(points) {
if (!celebrationLayer || !Number.isFinite(points) || points <= 0) {
return;
}
const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
celebrationLayer.hidden = false;
celebrationLayer.setAttribute("aria-hidden", "false");
celebrationLayer.innerHTML = "";
const score = document.createElement("div");
score.className = "celebration-score";
score.textContent = points;
celebrationLayer.appendChild(score);
const glow = document.createElement("div");
glow.className = "celebration-glow";
celebrationLayer.appendChild(glow);
if (!prefersReducedMotion) {
const colors = [
"rgba(94, 168, 255, 0.96)",
"rgba(52, 211, 153, 0.95)",
"rgba(250, 204, 21, 0.92)",
"rgba(191, 219, 254, 0.96)",
];
Array.from({ length: 14 }).forEach((_, index) => {
const particle = document.createElement("span");
particle.className = "celebration-particle";
particle.style.setProperty("--angle", `${Math.round((360 / 14) * index + Math.random() * 18)}deg`);
particle.style.setProperty("--distance", `${72 + Math.round(Math.random() * 44)}px`);
particle.style.setProperty("--delay", `${(Math.random() * 0.08).toFixed(2)}s`);
particle.style.setProperty("--size", `${7 + Math.round(Math.random() * 7)}px`);
particle.style.background = colors[index % colors.length];
celebrationLayer.appendChild(particle);
});
}
window.setTimeout(() => {
celebrationLayer.hidden = true;
celebrationLayer.setAttribute("aria-hidden", "true");
celebrationLayer.innerHTML = "";
}, prefersReducedMotion ? 520 : 1500);
}
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;
@@ -112,4 +422,11 @@
togglePush().catch((error) => console.error("Push toggle failed", error));
});
}
if (Number.isFinite(celebratePoints) && celebratePoints > 0) {
celebrateCompletion(celebratePoints);
clearCelebrationQuery();
}
restoreScrollPosition();
})();
+4 -2
View File
@@ -1,8 +1,10 @@
const CACHE_NAME = "putzliga-shell-v2";
const CACHE_NAME = "putzliga-shell-v4";
const ASSETS = [
"/static/css/style.css",
"/static/js/app.js",
"/static/images/logo.svg",
"/static/images/favicon.png",
"/static/images/apple-touch-icon.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 %}
+58 -26
View File
@@ -12,17 +12,25 @@
<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 '' }}"
data-celebrate-points="{{ request.args.get('celebrate_points', '') }}"
>
{% 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 +72,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,14 +98,21 @@
</main>
<footer class="app-footer">
<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>
<div class="app-footer__left">
<a href="https://git.hnz.io/hnzio/putzliga/releases" target="_blank" rel="noreferrer">Version {{ app_version }}</a>
<span>·</span>
<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>
{% if current_user.is_authenticated %}
<div class="celebration-layer" id="celebrationLayer" hidden aria-hidden="true"></div>
<nav class="bottom-nav" aria-label="Mobile Navigation">
{% for endpoint, label, icon in mobile_nav_items %}
<a href="{{ url_for(endpoint) }}" class="bottom-nav__item {% if request.endpoint == endpoint %}is-active{% endif %}">
@@ -105,7 +122,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 +131,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="field">
{{ quick_task_form.title.label }}
{{ quick_task_form.title(placeholder="Zum Beispiel: Küche kurz aufräumen") }}
<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="field">
{{ quick_task_form.effort.label }}
{{ quick_task_form.effort() }}
<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="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 class="quick-win-custom-fields" id="quickWinCustomFields" hidden>
<div class="field">
{{ quick_task_form.title.label }}
{{ 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(required=False) }}
</div>
</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>
+16 -16
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>
@@ -66,36 +66,36 @@
<dt>Fällig</dt>
<dd>{{ task.due_date|date_de }}</dd>
</div>
<div>
<dt>Zuständig</dt>
<dd>{{ task.assigned_user.name if task.assigned_user else 'Unverteilt' }}</dd>
</div>
<div>
<dt>Rhythmus</dt>
<dd>{{ task.task_template.recurrence_label }}</dd>
</div>
{% if task.completed_at %}
<div>
<dt>Erledigt von</dt>
<dd>{{ task.completed_by_user.name if task.completed_by_user else '—' }}</dd>
<dt>Erledigt</dt>
<dd>{{ task.completed_at|datetime_de }}</dd>
</div>
{% endif %}
</dl>
<div class="task-card__footer">
<div class="task-assignee">
{{ avatar(task.assigned_user) }}
<span>{{ task.assigned_user.name if task.assigned_user else 'Ohne Person' }}</span>
<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>
+24 -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>
@@ -95,6 +99,25 @@
{% endif %}
</section>
<section class="panel">
<p class="eyebrow">Kalender-Abo</p>
<h2>Persönlicher ICS-Feed</h2>
<p class="muted">Dieser Read-only-Link zeigt in externen Kalendern nur Aufgaben an, die dir zugewiesen sind. Exportiert werden nur Titel, Beschreibung und Fälligkeitsdatum.</p>
<div class="push-box">
<div class="field">
<label for="calendarFeedUrl">Persönliche Kalender-URL</label>
<input id="calendarFeedUrl" type="text" value="{{ calendar_feed_url }}" readonly>
</div>
<div class="form-actions">
<a class="button button--secondary" href="{{ calendar_feed_url }}" target="_blank" rel="noreferrer">ICS öffnen</a>
<form method="post" action="{{ url_for('settings.regenerate_calendar_feed') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="button button--ghost">Link neu erzeugen</button>
</form>
</div>
</div>
</section>
{% if current_user.is_admin %}
<section class="panel">
<p class="eyebrow">Admin</p>
@@ -163,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 %}
+68 -9
View File
@@ -11,8 +11,10 @@
<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>
</div>
<div class="field field--compact">
@@ -40,12 +42,69 @@
</form>
</section>
<section class="task-grid">
{% for task in tasks %}
{{ task_card(task, current_user) }}
{% else %}
<div class="empty-state">Für diese Filter gibt es gerade keine Aufgaben.</div>
{% endfor %}
</section>
{% endblock %}
{% if filters.status == 'all' %}
<section class="stack">
<div class="section-heading">
<h2>Überfällig</h2>
<span class="section-heading__count">{{ sections.overdue|length }}</span>
</div>
<div class="task-grid">
{% for task in sections.overdue %}
{{ task_card(task, current_user) }}
{% else %}
<div class="empty-state">Für diese Auswahl ist nichts überfällig.</div>
{% endfor %}
</div>
</section>
<section class="stack">
<div class="section-heading">
<h2>Heute</h2>
<span class="section-heading__count">{{ sections.today|length }}</span>
</div>
<div class="task-grid">
{% for task in sections.today %}
{{ task_card(task, current_user) }}
{% else %}
<div class="empty-state">Heute ist hier gerade nichts offen.</div>
{% endfor %}
</div>
</section>
<section class="stack">
<div class="section-heading">
<h2>Bald fällig</h2>
<span class="section-heading__count">{{ sections.soon|length }}</span>
</div>
<div class="task-grid">
{% for task in sections.soon %}
{{ task_card(task, current_user) }}
{% else %}
<div class="empty-state">In den nächsten Tagen ist hier gerade nichts fällig.</div>
{% endfor %}
</div>
</section>
<section class="stack">
<div class="section-heading">
<h2>Offen</h2>
<span class="section-heading__count">{{ sections.open|length }}</span>
</div>
<div class="task-grid">
{% for task in sections.open %}
{{ task_card(task, current_user) }}
{% else %}
<div class="empty-state">Keine weiteren offenen Aufgaben für diese Auswahl.</div>
{% endfor %}
</div>
</section>
{% else %}
<section class="task-grid">
{% for task in tasks %}
{{ task_card(task, current_user) }}
{% else %}
<div class="empty-state">Für diese Filter gibt es gerade keine Aufgaben.</div>
{% endfor %}
</section>
{% endif %}
{% endblock %}
+48
View File
@@ -0,0 +1,48 @@
{% extends "base.html" %}
{% from "partials/macros.html" import avatar, task_card %}
{% block title %}Archiv · Putzliga{% endblock %}
{% block page_title %}Archiv{% endblock %}
{% block content %}
<section class="hero-grid">
<article class="hero-card">
<p class="eyebrow">Erledigte Aufgaben</p>
<h2>Was schon geschafft wurde</h2>
<p>Hier landen alle erledigten Aufgaben. Du kannst pro Person sehen, was heute, gestern, vorgestern und davor erledigt wurde.</p>
</article>
<article class="panel highlight-panel">
<p class="eyebrow">Ansicht</p>
<h2>{{ selected_user.name }}</h2>
<p class="muted">Wechsle über die Tabs direkt zwischen den erledigten Aufgaben der einzelnen Nutzer.</p>
</article>
</section>
<section class="archive-user-tabs" aria-label="Archiv nach Nutzer">
{% for user in archive_users %}
<a
href="{{ url_for('tasks.archive_view', user_id=user.id) }}"
class="archive-user-tab {% if selected_user.id == user.id %}is-active{% endif %}"
>
{{ avatar(user) }}
<span>{{ user.name }}</span>
</a>
{% endfor %}
</section>
{% for section in archive_sections %}
<section class="stack">
<div class="section-heading">
<h2>{{ section.label }}</h2>
<span class="section-heading__count">{{ section.tasks|length }}</span>
</div>
<div class="task-grid">
{% for task in section.tasks %}
{{ task_card(task, current_user, compact=true) }}
{% endfor %}
</div>
</section>
{% else %}
<section class="panel">
<div class="empty-state">Für {{ selected_user.name }} gibt es im Archiv noch keine erledigten Aufgaben.</div>
</section>
{% endfor %}
{% 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>
+29 -24
View File
@@ -18,14 +18,20 @@
</article>
<article class="panel highlight-panel">
<p class="eyebrow">Schnellzugriff</p>
<a class="button button--wide" href="{{ url_for('tasks.create') }}">
{{ nav_icon('plus') }}
<span>Neue Aufgabe anlegen</span>
</a>
<a class="button button--secondary button--wide" href="{{ url_for('scoreboard.index') }}">
{{ nav_icon('trophy') }}
<span>Zum aktuellen Highscore</span>
</a>
<div class="quick-actions">
<a class="button button--wide" href="{{ url_for('tasks.create') }}">
{{ nav_icon('plus') }}
<span>Neue Aufgabe anlegen</span>
</a>
<a class="button button--ghost button--wide" href="{{ url_for('tasks.archive_view') }}">
{{ nav_icon('check-double') }}
<span>Archiv</span>
</a>
<a class="button button--secondary button--wide" href="{{ url_for('scoreboard.index') }}">
{{ nav_icon('trophy') }}
<span>Zum aktuellen Highscore</span>
</a>
</div>
</article>
</section>
@@ -43,6 +49,20 @@
</div>
</section>
<section class="stack">
<div class="section-heading">
<h2>Heute</h2>
<span class="section-heading__count">{{ sections.today|length }}</span>
</div>
<div class="task-grid">
{% for task in sections.today %}
{{ task_card(task, current_user) }}
{% else %}
<div class="empty-state">Heute ist gerade nichts mehr offen. Sehr stark.</div>
{% endfor %}
</div>
</section>
<section class="stack">
<div class="section-heading">
<h2>Bald fällig</h2>
@@ -52,7 +72,7 @@
{% for task in sections.soon %}
{{ task_card(task, current_user) }}
{% else %}
<div class="empty-state">Gerade nichts, was in den nächsten Tagen drängt.</div>
<div class="empty-state">Gerade ist nichts in den nächsten Tagen fällig.</div>
{% endfor %}
</div>
</section>
@@ -70,19 +90,4 @@
{% endfor %}
</div>
</section>
<section class="stack">
<div class="section-heading">
<h2>Erledigt</h2>
<span class="section-heading__count">{{ sections.completed|length }}</span>
</div>
<div class="task-grid">
{% for task in sections.completed %}
{{ task_card(task, current_user, compact=true) }}
{% else %}
<div class="empty-state">Noch keine erledigten Aufgaben in deiner Liste.</div>
{% endfor %}
</div>
</section>
{% endblock %}
+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 %}
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 151 KiB

+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