19 Commits

Author SHA1 Message Date
hnzio 8c1ade6403 feat: allow updating recurring task assignee 2026-04-26 12:41:33 +02:00
hnzio b77efa880b fix: stabilize quick win redirects and task ownership 2026-04-26 12:35:19 +02:00
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
34 changed files with 1662 additions and 229 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
"author": "hnzio <mail@example.com>", "author": "hnzio <mail@example.com>",
"description": "Spielerische Haushalts-App mit Aufgaben, Punkten, Monats-Highscore, Kalender und PWA-Push.", "description": "Spielerische Haushalts-App mit Aufgaben, Punkten, Monats-Highscore, Kalender und PWA-Push.",
"tagline": "Haushalt mit Liga-Gefühl", "tagline": "Haushalt mit Liga-Gefühl",
"version": "0.6.5", "version": "0.7.0",
"manifestVersion": 2, "manifestVersion": 2,
"healthCheckPath": "/healthz", "healthCheckPath": "/healthz",
"httpPort": 8000, "httpPort": 8000,
+31 -1
View File
@@ -224,11 +224,13 @@ Putzliga nutzt echte Web-Push-Benachrichtigungen mit Service Worker und VAPID.
```bash ```bash
flask --app app.py notify-due flask --app app.py notify-due
flask --app app.py notify-monthly-winner flask --app app.py notify-monthly-winner
flask --app app.py notify-all
``` ```
`notify-due`: `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` - berücksichtigt die Nutzeroption `notification_task_due_enabled`
`notify-monthly-winner`: `notify-monthly-winner`:
@@ -237,12 +239,24 @@ flask --app app.py notify-monthly-winner
- verweist auf das Scoreboard/Archiv des letzten Monats - verweist auf das Scoreboard/Archiv des letzten Monats
- berücksichtigt `notification_monthly_winner_enabled` - 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. 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 ### Produktiver Betrieb
Auf Cloudron oder einem anderen Server solltest du diese Kommandos regelmäßig per Cronjob oder Task ausführen, zum Beispiel: 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 ```bash
flask --app /app/app.py notify-due flask --app /app/app.py notify-due
flask --app /app/app.py notify-monthly-winner flask --app /app/app.py notify-monthly-winner
@@ -346,18 +360,34 @@ Der ausgegebene `VAPID_PRIVATE_KEY` ist bereits `.env`-freundlich mit escaped Ne
## Release Notes ## 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 ### 0.6.5
- Quick-Wins als gemeinsames Team-Feature ausgebaut - Quick-Wins als gemeinsames Team-Feature ausgebaut
- neuer Optionen-Tab zum Anlegen und Verwalten gemeinsamer Quick-Wins - neuer Optionen-Tab zum Anlegen und Verwalten gemeinsamer Quick-Wins
- bestehende Quick-Wins in den Optionen direkt bearbeitbar gemacht - 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 - Plus-Dialog von Einzelkarten auf kompakte auswählbare Quick-Win-Chips umgestellt
- mehrere Quick-Wins lassen sich gesammelt als erledigt speichern - mehrere Quick-Wins lassen sich gesammelt als erledigt speichern
- „Sonstiges“ blendet Titel und Aufwand jetzt nur bei Auswahl ein - „Sonstiges“ blendet Titel und Aufwand jetzt nur bei Auswahl ein
- neue Aufwand-Stufe `super aufwendig` - neue Aufwand-Stufe `super aufwendig`
- Quick-Win-Popup visuell mit übernommenem Sparkles-Icon aus `heinz.marketing` aufgewertet - 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 - 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 - 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 - Footer auf Versionslink, Herkunftshinweis und `hnz.io`-Verweis umgebaut
- Cloudron-Version auf `0.6.5` angehoben - Cloudron-Version auf `0.6.5` angehoben
+3 -1
View File
@@ -76,7 +76,7 @@ def create_app(config_class: type[Config] = Config) -> Flask:
(key, values["label"]) (key, values["label"])
for key, values in quick_task_config.items() for key, values in quick_task_config.items()
] ]
quick_wins = QuickWin.query.filter_by(active=True).order_by(QuickWin.id.asc()).all() quick_wins = QuickWin.query.filter_by(active=True).order_by(QuickWin.sort_order.asc(), QuickWin.id.asc()).all()
def asset_version(filename: str) -> int: def asset_version(filename: str) -> int:
path = Path(app.static_folder) / filename path = Path(app.static_folder) / filename
try: try:
@@ -90,6 +90,7 @@ def create_app(config_class: type[Config] = Config) -> Flask:
"nav_items": [ "nav_items": [
("tasks.my_tasks", "Meine Aufgaben", "house"), ("tasks.my_tasks", "Meine Aufgaben", "house"),
("tasks.all_tasks", "Alle", "list"), ("tasks.all_tasks", "Alle", "list"),
("tasks.archive_view", "Archiv", "check-double"),
("tasks.create", "Neu", "plus"), ("tasks.create", "Neu", "plus"),
("tasks.calendar_view", "Kalender", "calendar"), ("tasks.calendar_view", "Kalender", "calendar"),
("scoreboard.index", "Highscore", "trophy"), ("scoreboard.index", "Highscore", "trophy"),
@@ -98,6 +99,7 @@ def create_app(config_class: type[Config] = Config) -> Flask:
"mobile_nav_items": [ "mobile_nav_items": [
("tasks.my_tasks", "Meine Aufgaben", "house"), ("tasks.my_tasks", "Meine Aufgaben", "house"),
("tasks.all_tasks", "Alle Aufgaben", "list"), ("tasks.all_tasks", "Alle Aufgaben", "list"),
("tasks.archive_view", "Archiv", "check-double"),
("tasks.calendar_view", "Kalender", "calendar"), ("tasks.calendar_view", "Kalender", "calendar"),
("scoreboard.index", "Highscore", "trophy"), ("scoreboard.index", "Highscore", "trophy"),
("settings.index", "Optionen", "gear"), ("settings.index", "Optionen", "gear"),
+12 -1
View File
@@ -5,7 +5,7 @@ import click
from .extensions import db from .extensions import db
from .models import BadgeDefinition, User from .models import BadgeDefinition, User
from .services.monthly import archive_months_missing_up_to_previous 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 = [ DEFAULT_BADGES = [
@@ -175,3 +175,14 @@ def register_cli(app) -> None:
def notify_monthly_winner_command(): def notify_monthly_winner_command():
result = send_monthly_winner_notifications() result = send_monthly_winner_notifications()
click.echo(f"Winner-Push: sent={result.sent} skipped={result.skipped} failed={result.failed}") 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})"
)
+7 -1
View File
@@ -48,6 +48,7 @@ class TaskForm(FlaskForm):
description = TextAreaField("Beschreibung", validators=[Optional(), Length(max=2000)]) description = TextAreaField("Beschreibung", validators=[Optional(), Length(max=2000)])
default_points = IntegerField("Punkte", validators=[DataRequired(), NumberRange(min=1, max=500)], default=10) 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_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()]) due_date = DateField("Fälligkeitsdatum", format="%Y-%m-%d", validators=[DataRequired()])
recurrence_interval_value = IntegerField( recurrence_interval_value = IntegerField(
"Intervallwert", "Intervallwert",
@@ -65,6 +66,7 @@ class TaskForm(FlaskForm):
validators=[DataRequired()], validators=[DataRequired()],
) )
active = BooleanField("Vorlage aktiv", default=True) active = BooleanField("Vorlage aktiv", default=True)
apply_assignment_to_template = BooleanField("Für Wiederholungen übernehmen")
submit = SubmitField("Speichern") submit = SubmitField("Speichern")
def validate(self, extra_validators=None): def validate(self, extra_validators=None):
@@ -74,6 +76,9 @@ class TaskForm(FlaskForm):
if self.recurrence_interval_unit.data != "none" and not self.recurrence_interval_value.data: 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.") self.recurrence_interval_value.errors.append("Bitte gib einen Intervallwert an.")
return False 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 return True
@@ -85,8 +90,9 @@ class SettingsProfileForm(FlaskForm):
"Avatar", "Avatar",
validators=[Optional(), FileAllowed(["png", "jpg", "jpeg", "gif", "webp", "svg"], "Bitte ein Bild hochladen.")], 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_monthly_winner_enabled = BooleanField("Push zum Monatssieger")
notification_badge_enabled = BooleanField("Push bei neuen Badges")
submit = SubmitField("Einstellungen speichern") submit = SubmitField("Einstellungen speichern")
def __init__(self, original_email: str | None = None, *args, **kwargs): def __init__(self, original_email: str | None = None, *args, **kwargs):
+48 -3
View File
@@ -28,6 +28,7 @@ class User(UserMixin, TimestampMixin, db.Model):
calendar_feed_token = db.Column(db.String(255), nullable=True, unique=True, index=True) 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_task_due_enabled = db.Column(db.Boolean, nullable=False, default=True)
notification_monthly_winner_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( assigned_task_templates = db.relationship(
"TaskTemplate", "TaskTemplate",
@@ -35,12 +36,24 @@ class User(UserMixin, TimestampMixin, db.Model):
backref="default_assigned_user", backref="default_assigned_user",
lazy=True, 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( assigned_tasks = db.relationship(
"TaskInstance", "TaskInstance",
foreign_keys="TaskInstance.assigned_user_id", foreign_keys="TaskInstance.assigned_user_id",
backref="assigned_user", backref="assigned_user",
lazy=True, 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( completed_tasks = db.relationship(
"TaskInstance", "TaskInstance",
foreign_keys="TaskInstance.completed_by_user_id", foreign_keys="TaskInstance.completed_by_user_id",
@@ -87,6 +100,7 @@ class TaskTemplate(TimestampMixin, db.Model):
description = db.Column(db.Text, nullable=True) description = db.Column(db.Text, nullable=True)
default_points = db.Column(db.Integer, nullable=False, default=10) 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_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_value = db.Column(db.Integer, nullable=True)
recurrence_interval_unit = db.Column(db.String(20), nullable=False, default="none") recurrence_interval_unit = db.Column(db.String(20), nullable=False, default="none")
active = db.Column(db.Boolean, nullable=False, default=True) active = db.Column(db.Boolean, nullable=False, default=True)
@@ -113,6 +127,7 @@ class QuickWin(TimestampMixin, db.Model):
effort = db.Column(db.String(40), nullable=False, index=True) effort = db.Column(db.String(40), nullable=False, index=True)
active = db.Column(db.Boolean, nullable=False, default=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) 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): class TaskInstance(TimestampMixin, db.Model):
@@ -121,6 +136,7 @@ class TaskInstance(TimestampMixin, db.Model):
title = db.Column(db.String(160), nullable=False) title = db.Column(db.String(160), nullable=False)
description = db.Column(db.Text, nullable=True) description = db.Column(db.Text, nullable=True)
assigned_user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True, index=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) due_date = db.Column(db.Date, nullable=False, index=True)
status = db.Column(db.String(20), nullable=False, default="open", index=True) status = db.Column(db.String(20), nullable=False, default="open", index=True)
completed_at = db.Column(db.DateTime, nullable=True, index=True) completed_at = db.Column(db.DateTime, nullable=True, index=True)
@@ -131,21 +147,50 @@ class TaskInstance(TimestampMixin, db.Model):
def is_completed(self) -> bool: def is_completed(self) -> bool:
return self.completed_at is not None 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: def compute_status(self, reference_date: date | None = None) -> str:
reference_date = reference_date or date.today() reference_date = reference_date or date.today()
if self.completed_at: if self.completed_at:
return "completed" return "completed"
if self.due_date < reference_date: if self.due_date < reference_date:
return "overdue" return "overdue"
if self.due_date <= reference_date + timedelta(days=2): if self.due_date == reference_date:
return "soon" 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" return "open"
@property @property
def status_label(self) -> str: def status_label(self) -> str:
labels = { labels = {
"open": "Offen", "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", "overdue": "Überfällig",
"completed": "Erledigt", "completed": "Erledigt",
} }
+38 -1
View File
@@ -5,6 +5,7 @@ from uuid import uuid4
from flask import Blueprint, current_app, flash, jsonify, redirect, render_template, request, url_for from flask import Blueprint, current_app, flash, jsonify, redirect, render_template, request, url_for
from flask_login import current_user, login_required from flask_login import current_user, login_required
from sqlalchemy import func
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
from ..extensions import csrf, db from ..extensions import csrf, db
@@ -56,6 +57,7 @@ def index():
current_user.email = form.email.data.lower().strip() current_user.email = form.email.data.lower().strip()
current_user.notification_task_due_enabled = form.notification_task_due_enabled.data 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_monthly_winner_enabled = form.notification_monthly_winner_enabled.data
current_user.notification_badge_enabled = form.notification_badge_enabled.data
if form.password.data: if form.password.data:
current_user.set_password(form.password.data) current_user.set_password(form.password.data)
if form.avatar.data: if form.avatar.data:
@@ -109,6 +111,7 @@ def quick_wins():
effort=quick_win_form.effort.data, effort=quick_win_form.effort.data,
active=True, active=True,
created_by_user_id=current_user.id, 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.add(quick_win)
db.session.commit() db.session.commit()
@@ -120,7 +123,7 @@ def quick_wins():
quick_win_form=quick_win_form, quick_win_form=quick_win_form,
quick_task_config_form=quick_task_config_form, quick_task_config_form=quick_task_config_form,
quick_task_config=quick_task_config, quick_task_config=quick_task_config,
quick_wins=QuickWin.query.filter_by(active=True).order_by(QuickWin.id.asc()).all(), quick_wins=QuickWin.query.filter_by(active=True).order_by(QuickWin.sort_order.asc(), QuickWin.id.asc()).all(),
settings_tabs=_settings_tabs(), settings_tabs=_settings_tabs(),
active_settings_tab="settings.quick_wins", active_settings_tab="settings.quick_wins",
) )
@@ -268,6 +271,38 @@ def update_quick_win(quick_win_id: int):
return redirect(url_for("settings.quick_wins")) 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"]) @bp.route("/users/<int:user_id>/toggle-admin", methods=["POST"])
@login_required @login_required
def toggle_admin(user_id: int): def toggle_admin(user_id: int):
@@ -308,7 +343,9 @@ def delete_user(user_id: int):
return redirect(url_for("settings.index")) 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_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_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}) 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() MonthlyScoreSnapshot.query.filter_by(user_id=user.id).delete()
NotificationLog.query.filter_by(user_id=user.id).delete() NotificationLog.query.filter_by(user_id=user.id).delete()
+231 -26
View File
@@ -2,10 +2,12 @@ from __future__ import annotations
import calendar import calendar
from collections import defaultdict 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 import Blueprint, flash, redirect, render_template, request, url_for
from flask_login import current_user, login_required from flask_login import current_user, login_required
from sqlalchemy import or_
from ..forms import QuickTaskForm, TaskForm from ..forms import QuickTaskForm, TaskForm
from ..models import QuickWin, TaskInstance, User from ..models import QuickWin, TaskInstance, User
@@ -15,6 +17,7 @@ from ..services.tasks import (
complete_task, complete_task,
create_quick_task, create_quick_task,
create_task_template_and_instance, create_task_template_and_instance,
delete_task_instance,
refresh_task_statuses, refresh_task_statuses,
update_template_and_instance, update_template_and_instance,
) )
@@ -27,22 +30,129 @@ def _user_choices() -> list[tuple[int, str]]:
return [(user.id, user.name) for user in User.query.order_by(User.name.asc()).all()] 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)
def _safe_referrer_target(fallback: str) -> str:
referrer = request.referrer
if not referrer:
return fallback
parts = urlsplit(referrer)
if parts.netloc and parts.netloc != request.host:
return fallback
query = dict(parse_qsl(parts.query, keep_blank_values=True))
query.pop("celebrate_points", None)
target = urlunsplit(("", "", parts.path or fallback, urlencode(query), parts.fragment))
if not target.startswith("/") or len(target) > 1200:
return fallback
return target
@bp.route("/my-tasks") @bp.route("/my-tasks")
@login_required @login_required
def my_tasks(): def my_tasks():
tasks = ( all_tasks = (
TaskInstance.query.filter_by(assigned_user_id=current_user.id) TaskInstance.query.filter(
or_(
TaskInstance.assigned_user_id == current_user.id,
TaskInstance.assigned_user_secondary_id == current_user.id,
)
)
.order_by(TaskInstance.completed_at.is_(None).desc(), TaskInstance.due_date.asc()) .order_by(TaskInstance.completed_at.is_(None).desc(), TaskInstance.due_date.asc())
.all() .all()
) )
refresh_task_statuses(tasks) refresh_task_statuses(all_tasks)
sections = _group_active_tasks(all_tasks)
sections = {"open": [], "soon": [], "overdue": [], "completed": []} completed_count = len([task for task in all_tasks if task.is_completed])
for task in tasks: active_count = (
sections[task.status].append(task) len(sections["overdue"])
+ len(sections["today"])
completed_count = len(sections["completed"]) + len(sections["soon"])
active_count = len(sections["open"]) + len(sections["soon"]) + len(sections["overdue"]) + len(sections["open"])
)
completion_ratio = 0 if completed_count + active_count == 0 else round(completed_count / (completed_count + active_count) * 100) completion_ratio = 0 if completed_count + active_count == 0 else round(completed_count / (completed_count + active_count) * 100)
return render_template( return render_template(
@@ -63,9 +173,19 @@ def all_tasks():
sort = request.args.get("sort", "due") sort = request.args.get("sort", "due")
if mine == "1": 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: 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": if sort == "points":
query = query.order_by(TaskInstance.points_awarded.desc(), TaskInstance.due_date.asc()) query = query.order_by(TaskInstance.points_awarded.desc(), TaskInstance.due_date.asc())
@@ -78,24 +198,77 @@ def all_tasks():
refresh_task_statuses(tasks) refresh_task_statuses(tasks)
if status != "all": if status != "all":
status_map = {"completed": "completed", "overdue": "overdue", "open": "open", "soon": "soon"} if status == "soon":
selected = status_map.get(status) tasks = [task for task in tasks if task.status in {"due_tomorrow", "due_day_after_tomorrow"}]
if selected: else:
tasks = [task for task in tasks if task.status == selected] 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( return render_template(
"tasks/all_tasks.html", "tasks/all_tasks.html",
tasks=tasks, tasks=tasks,
sections=sections,
users=User.query.order_by(User.name.asc()).all(), users=User.query.order_by(User.name.asc()).all(),
filters={"status": status, "mine": mine, "user_id": user_filter, "sort": sort}, 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"]) @bp.route("/tasks/new", methods=["GET", "POST"])
@login_required @login_required
def create(): def create():
form = TaskForm() form = TaskForm()
form.assigned_user_id.choices = _user_choices() 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: if request.method == "GET" and not form.due_date.data:
form.due_date.data = today_local() form.due_date.data = today_local()
@@ -111,6 +284,7 @@ def create():
def quick_create(): def quick_create():
config = get_quick_task_config() config = get_quick_task_config()
created_titles: list[str] = [] created_titles: list[str] = []
total_points = 0
selected_ids = request.form.getlist("quick_win_ids") selected_ids = request.form.getlist("quick_win_ids")
if selected_ids: if selected_ids:
@@ -119,6 +293,7 @@ def quick_create():
task = create_quick_task(quick_win.title, quick_win.effort, current_user, description="Quick-Win") task = create_quick_task(quick_win.title, quick_win.effort, current_user, description="Quick-Win")
complete_task(task, current_user.id) complete_task(task, current_user.id)
created_titles.append(task.title) created_titles.append(task.title)
total_points += task.points_awarded
if request.form.get("include_custom") == "1": if request.form.get("include_custom") == "1":
form = QuickTaskForm(prefix="quick") form = QuickTaskForm(prefix="quick")
@@ -135,20 +310,24 @@ def quick_create():
flash(error, "error") flash(error, "error")
for error in extra_errors: for error in extra_errors:
flash(error, "error") flash(error, "error")
return redirect(request.referrer or url_for("tasks.my_tasks")) return redirect(_safe_referrer_target(url_for("tasks.my_tasks")))
task = create_quick_task(custom_title, form.effort.data, current_user, description="Quick-Win") task = create_quick_task(custom_title, form.effort.data, current_user, description="Quick-Win")
complete_task(task, current_user.id) complete_task(task, current_user.id)
created_titles.append(task.title) created_titles.append(task.title)
total_points += task.points_awarded
if not created_titles: if not created_titles:
flash("Bitte wähle mindestens einen Quick-Win aus.", "error") flash("Bitte wähle mindestens einen Quick-Win aus.", "error")
return redirect(request.referrer or url_for("tasks.my_tasks")) return redirect(_safe_referrer_target(url_for("tasks.my_tasks")))
if len(created_titles) == 1: if len(created_titles) == 1:
flash(f"Quick-Win „{created_titles[0]}“ wurde als erledigt gespeichert.", "success") flash(f"Quick-Win „{created_titles[0]}“ wurde als erledigt gespeichert.", "success")
else: else:
flash(f"{len(created_titles)} Quick-Wins wurden als erledigt gespeichert.", "success") flash(f"{len(created_titles)} Quick-Wins wurden als erledigt gespeichert.", "success")
return redirect(request.referrer or url_for("tasks.my_tasks")) return _redirect_with_celebration(
_safe_referrer_target(url_for("tasks.my_tasks")),
total_points,
)
@bp.route("/tasks/<int:task_id>/edit", methods=["GET", "POST"]) @bp.route("/tasks/<int:task_id>/edit", methods=["GET", "POST"])
@@ -157,12 +336,15 @@ def edit(task_id: int):
task = TaskInstance.query.get_or_404(task_id) task = TaskInstance.query.get_or_404(task_id)
form = TaskForm(obj=task.task_template) form = TaskForm(obj=task.task_template)
form.assigned_user_id.choices = _user_choices() 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": if request.method == "GET":
form.title.data = task.title form.title.data = task.title
form.description.data = task.description 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_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.due_date.data = task.due_date
form.recurrence_interval_value.data = task.task_template.recurrence_interval_value or 1 form.recurrence_interval_value.data = task.task_template.recurrence_interval_value or 1
form.recurrence_interval_unit.data = task.task_template.recurrence_interval_unit form.recurrence_interval_unit.data = task.task_template.recurrence_interval_unit
@@ -171,9 +353,21 @@ def edit(task_id: int):
if form.validate_on_submit(): if form.validate_on_submit():
update_template_and_instance(task, form) update_template_and_instance(task, form)
flash("Aufgabe und Vorlage wurden aktualisiert.", "success") 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"]) @bp.route("/tasks/<int:task_id>/complete", methods=["POST"])
@@ -183,15 +377,26 @@ def complete(task_id: int):
choice = request.form.get("completed_for", "me") choice = request.form.get("completed_for", "me")
if task.is_completed: if task.is_completed:
flash("Diese Aufgabe ist bereits erledigt.", "info") flash("Diese Aufgabe ist bereits erledigt.", "info")
return redirect(request.referrer or url_for("tasks.my_tasks")) return redirect(_safe_referrer_target(url_for("tasks.my_tasks")))
completed_by_id = current_user.id completed_by_id = current_user.id
if task.assigned_user_id and task.assigned_user_id != current_user.id and choice == "assigned": allowed_ids = {current_user.id}
completed_by_id = task.assigned_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) complete_task(task, completed_by_id)
flash("Punkte verbucht. Gute Arbeit.", "success") flash("Punkte verbucht. Gute Arbeit.", "success")
return redirect(request.referrer or url_for("tasks.my_tasks")) return _redirect_with_celebration(
_safe_referrer_target(url_for("tasks.my_tasks")),
awarded_points,
)
@bp.route("/calendar") @bp.route("/calendar")
+51 -28
View File
@@ -4,7 +4,7 @@ import json
from collections import defaultdict from collections import defaultdict
from datetime import date, datetime, time, timedelta from datetime import date, datetime, time, timedelta
from sqlalchemy import and_ from sqlalchemy import and_, or_
from ..extensions import db from ..extensions import db
from ..models import BadgeDefinition, MonthlyScoreSnapshot, TaskInstance, User, UserBadge from ..models import BadgeDefinition, MonthlyScoreSnapshot, TaskInstance, User, UserBadge
@@ -25,24 +25,30 @@ def _max_day_streak(days: set[date]) -> int:
return best 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() definition = BadgeDefinition.query.filter_by(key=badge_key, active=True).first()
if not definition: if not definition:
return False return None
existing = UserBadge.query.filter_by(user_id=user.id, badge_definition_id=definition.id).first() existing = UserBadge.query.filter_by(user_id=user.id, badge_definition_id=definition.id).first()
if existing: if existing:
return False return None
db.session.add( award = UserBadge(
UserBadge( user_id=user.id,
user_id=user.id, badge_definition_id=definition.id,
badge_definition_id=definition.id, awarded_at=awarded_at or datetime.utcnow(),
awarded_at=awarded_at or datetime.utcnow(), context=json.dumps(context, sort_keys=True) if context else None,
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]: 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 metrics["on_time_tasks_completed"] += 1
if completion_day <= task.due_date - timedelta(days=1): if completion_day <= task.due_date - timedelta(days=1):
metrics["early_tasks_completed"] += 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 metrics["foreign_tasks_completed"] += 1
max_points = max(max_points, task.points_awarded) max_points = max(max_points, task.points_awarded)
@@ -101,20 +107,26 @@ def _completion_metrics(user: User) -> dict[str, int]:
return metrics 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() definitions = BadgeDefinition.query.filter_by(active=True).all()
metrics = _completion_metrics(user) metrics = _completion_metrics(user)
unlocked: list[str] = [] unlocked: list[UserBadge] = []
for definition in definitions: for definition in definitions:
metric_value = metrics.get(definition.trigger_type) metric_value = metrics.get(definition.trigger_type)
if metric_value is None: if metric_value is None:
continue continue
if metric_value >= definition.threshold and award_badge(user, definition.key): if metric_value >= definition.threshold:
unlocked.append(definition.name) award = award_badge(user, definition.key)
if award:
unlocked.append(award)
if unlocked: if unlocked:
db.session.commit() db.session.commit()
if notify:
from .notifications import send_badge_notifications_for_awards
send_badge_notifications_for_awards(unlocked)
return 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) start_date = date(year, month, 1)
end_date = (month_bounds(year, month)[1] - timedelta(days=1)).date() end_date = (month_bounds(year, month)[1] - timedelta(days=1)).date()
tasks = TaskInstance.query.filter( 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 >= start_date,
TaskInstance.due_date <= end_date, TaskInstance.due_date <= end_date,
).all() ).all()
@@ -148,31 +163,39 @@ def _winner_user_ids(year: int, month: int) -> set[int]:
return {row.user_id for row in rows} 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) award_time = _month_end_award_time(year, month)
unlocked: list[str] = [] unlocked: list[UserBadge] = []
winners = _winner_user_ids(year, month) winners = _winner_user_ids(year, month)
previous_year, previous_month_value = previous_month(year, month) previous_year, previous_month_value = previous_month(year, month)
previous_winners = _winner_user_ids(previous_year, previous_month_value) previous_winners = _winner_user_ids(previous_year, previous_month_value)
for user in User.query.order_by(User.id.asc()).all(): for user in User.query.order_by(User.id.asc()).all():
if user.id in winners: if user.id in winners:
if award_badge(user, "monthly_champion", awarded_at=award_time, context={"year": year, "month": month}): award = award_badge(user, "monthly_champion", awarded_at=award_time, context={"year": year, "month": month})
unlocked.append(f"{user.name}: Monatssieger") if award:
unlocked.append(award)
if user.id in previous_winners: if user.id in previous_winners:
if award_badge(user, "title_defender", awarded_at=award_time, context={"year": year, "month": month}): award = award_badge(user, "title_defender", awarded_at=award_time, context={"year": year, "month": month})
unlocked.append(f"{user.name}: Titelverteidiger") if award:
unlocked.append(award)
elif MonthlyScoreSnapshot.query.filter_by(year=previous_year, month=previous_month_value, user_id=user.id).first(): 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}): award = award_badge(user, "comeback_kid", awarded_at=award_time, context={"year": year, "month": month})
unlocked.append(f"{user.name}: Comeback Kid") if award:
unlocked.append(award)
if _user_had_clean_month(user.id, year, month): if _user_had_clean_month(user.id, year, month):
if award_badge(user, "white_glove", awarded_at=award_time, context={"year": year, "month": month}): award = award_badge(user, "white_glove", awarded_at=award_time, context={"year": year, "month": month})
unlocked.append(f"{user.name}: Weiße Weste") if award:
unlocked.append(award)
if unlocked: if unlocked:
db.session.commit() db.session.commit()
if notify:
from .notifications import send_badge_notifications_for_awards
send_badge_notifications_for_awards(unlocked)
return unlocked return unlocked
+34
View File
@@ -21,6 +21,25 @@ def ensure_schema_and_admins() -> None:
db.session.execute(text("ALTER TABLE user ADD COLUMN calendar_feed_token VARCHAR(255)")) db.session.execute(text("ALTER TABLE user ADD COLUMN calendar_feed_token VARCHAR(255)"))
db.session.commit() 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() ensure_app_settings()
users_without_feed = User.query.filter(User.calendar_feed_token.is_(None)).all() users_without_feed = User.query.filter(User.calendar_feed_token.is_(None)).all()
@@ -46,6 +65,7 @@ def ensure_schema_and_admins() -> None:
default_quick_win_user = first_user default_quick_win_user = first_user
_ensure_default_quick_wins(default_quick_win_user or User.query.order_by(User.id.asc()).first()) _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: def _ensure_default_quick_wins(default_user: User | None) -> None:
@@ -62,6 +82,7 @@ def _ensure_default_quick_wins(default_user: User | None) -> None:
existing_titles = {quick_win.title for quick_win in QuickWin.query.all()} existing_titles = {quick_win.title for quick_win in QuickWin.query.all()}
created = False created = False
next_sort_order = QuickWin.query.count()
for title, effort in defaults: for title, effort in defaults:
if title not in existing_titles: if title not in existing_titles:
db.session.add( db.session.add(
@@ -70,8 +91,21 @@ def _ensure_default_quick_wins(default_user: User | None) -> None:
effort=effort, effort=effort,
active=True, active=True,
created_by_user_id=default_user.id, created_by_user_id=default_user.id,
sort_order=next_sort_order,
) )
) )
next_sort_order += 1
created = True created = True
if created: if created:
db.session.commit() db.session.commit()
def _ensure_quick_win_ordering() -> None:
quick_wins = QuickWin.query.order_by(QuickWin.sort_order.asc(), QuickWin.id.asc()).all()
dirty = False
for index, quick_win in enumerate(quick_wins):
if quick_win.sort_order != index:
quick_win.sort_order = index
dirty = True
if dirty:
db.session.commit()
+8 -1
View File
@@ -2,6 +2,8 @@ from __future__ import annotations
from datetime import UTC, datetime, time, timedelta from datetime import UTC, datetime, time, timedelta
from sqlalchemy import or_
from ..models import TaskInstance, User from ..models import TaskInstance, User
@@ -23,7 +25,12 @@ def _format_timestamp(value: datetime | None) -> str:
def build_calendar_feed(user: User, base_url: str) -> str: def build_calendar_feed(user: User, base_url: str) -> str:
tasks = ( tasks = (
TaskInstance.query.filter_by(assigned_user_id=user.id) TaskInstance.query.filter(
or_(
TaskInstance.assigned_user_id == user.id,
TaskInstance.assigned_user_secondary_id == user.id,
)
)
.order_by(TaskInstance.due_date.asc(), TaskInstance.id.asc()) .order_by(TaskInstance.due_date.asc(), TaskInstance.id.asc())
.all() .all()
) )
+1 -1
View File
@@ -97,7 +97,7 @@ def archive_months_missing_up_to_previous() -> None:
) )
) )
db.session.commit() db.session.commit()
evaluate_monthly_badges(year, month) evaluate_monthly_badges(year, month, notify=True)
year, month = next_month(year, month) year, month = next_month(year, month)
+108 -49
View File
@@ -2,14 +2,14 @@ from __future__ import annotations
import json import json
from dataclasses import dataclass from dataclasses import dataclass
from datetime import timedelta
from urllib.parse import urljoin from urllib.parse import urljoin
from flask import current_app from flask import current_app
from pywebpush import WebPushException, webpush from pywebpush import WebPushException, webpush
from sqlalchemy import or_
from ..extensions import db 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 .monthly import archive_months_missing_up_to_previous, get_snapshot_rows
from .dates import local_now, previous_month from .dates import local_now, previous_month
@@ -60,56 +60,96 @@ def _send_subscription(subscription: PushSubscription, payload: dict) -> bool:
return False 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: def send_due_notifications() -> NotificationResult:
result = NotificationResult() result = NotificationResult()
if not push_enabled(): if not push_enabled():
result.skipped += 1 result.skipped += 1
return result 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( relevant_tasks = TaskInstance.query.filter(
TaskInstance.completed_at.is_(None), TaskInstance.completed_at.is_(None),
TaskInstance.assigned_user_id.isnot(None), TaskInstance.due_date == today,
TaskInstance.due_date <= today + timedelta(days=1), or_(
TaskInstance.assigned_user_id.isnot(None),
TaskInstance.assigned_user_secondary_id.isnot(None),
),
).all() ).all()
tasks_by_user: dict[int, list[TaskInstance]] = {}
for task in relevant_tasks: for task in relevant_tasks:
user = task.assigned_user for assigned_user in task.assigned_users:
if not user or not user.notification_task_due_enabled: 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 result.skipped += 1
continue continue
payload_marker = {"task_instance_id": task.id, "due_date": task.due_date.isoformat()} personal_tasks = tasks_by_user.get(user.id, [])
if _notification_exists(user.id, "task_due", payload_marker): if not personal_tasks:
result.skipped += 1 result.skipped += 1
continue continue
subscriptions = PushSubscription.query.filter_by(user_id=user.id).all() task_count = len(personal_tasks)
if not subscriptions:
result.skipped += 1
continue
body = "Heute ist ein guter Tag für Punkte." if task.due_date <= today else "Morgen wird's fällig."
payload = { payload = {
"title": f"Putzliga erinnert: {task.title}", "title": "Putzliga für heute",
"body": body, "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"), "icon": _absolute_url("/static/images/pwa-icon-192.png"),
"badge": _absolute_url("/static/images/pwa-badge.png"), "badge": _absolute_url("/static/images/pwa-badge.png"),
"url": _absolute_url("/my-tasks"), "url": _absolute_url("/my-tasks"),
"tag": f"task-{task.id}", "tag": f"due-{today.isoformat()}-{user.id}",
} }
marker = {"date": today.isoformat()}
sent_any = False _merge_results(result, _send_payload_to_user(user, "task_due_daily", marker, payload))
for subscription in subscriptions:
if _send_subscription(subscription, payload):
sent_any = True
result.sent += 1
else:
result.failed += 1
if sent_any:
_log_notification(user.id, "task_due", payload_marker)
db.session.commit()
return result return result
@@ -140,14 +180,6 @@ def send_monthly_winner_notifications() -> NotificationResult:
if not user.notification_monthly_winner_enabled: if not user.notification_monthly_winner_enabled:
result.skipped += 1 result.skipped += 1
continue 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 = { payload = {
"title": "Der Haushalts-Champion des letzten Monats steht fest", "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}"), "url": _absolute_url(f"/scoreboard?archive={target_year}-{target_month:02d}"),
"tag": f"winner-{target_year}-{target_month}", "tag": f"winner-{target_year}-{target_month}",
} }
sent_any = False _merge_results(result, _send_payload_to_user(user, "monthly_winner", marker, payload))
for subscription in subscriptions:
if _send_subscription(subscription, payload):
sent_any = True
result.sent += 1
else:
result.failed += 1
if sent_any:
_log_notification(user.id, "monthly_winner", marker)
db.session.commit()
return result 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 -6
View File
@@ -25,12 +25,19 @@ def refresh_task_statuses(tasks: list[TaskInstance]) -> None:
db.session.commit() 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: def create_task_template_and_instance(form) -> TaskInstance:
template = TaskTemplate( template = TaskTemplate(
title=form.title.data.strip(), title=form.title.data.strip(),
description=(form.description.data or "").strip(), description=(form.description.data or "").strip(),
default_points=form.default_points.data, default_points=form.default_points.data,
default_assigned_user_id=form.assigned_user_id.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_value=form.recurrence_interval_value.data if form.recurrence_interval_unit.data != "none" else None,
recurrence_interval_unit=form.recurrence_interval_unit.data, recurrence_interval_unit=form.recurrence_interval_unit.data,
active=form.active.data, active=form.active.data,
@@ -43,8 +50,9 @@ def create_task_template_and_instance(form) -> TaskInstance:
title=template.title, title=template.title,
description=template.description, description=template.description,
assigned_user_id=template.default_assigned_user_id, assigned_user_id=template.default_assigned_user_id,
assigned_user_secondary_id=template.default_assigned_user_secondary_id,
due_date=form.due_date.data, 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", status="open",
) )
refresh_task_status(task, form.due_date.data) refresh_task_status(task, form.due_date.data)
@@ -58,17 +66,20 @@ def update_template_and_instance(task: TaskInstance, form) -> TaskInstance:
template.title = form.title.data.strip() template.title = form.title.data.strip()
template.description = (form.description.data or "").strip() template.description = (form.description.data or "").strip()
template.default_points = form.default_points.data template.default_points = form.default_points.data
template.default_assigned_user_id = form.assigned_user_id.data
template.recurrence_interval_unit = form.recurrence_interval_unit.data template.recurrence_interval_unit = form.recurrence_interval_unit.data
template.recurrence_interval_value = ( template.recurrence_interval_value = (
form.recurrence_interval_value.data if form.recurrence_interval_unit.data != "none" else None form.recurrence_interval_value.data if form.recurrence_interval_unit.data != "none" else None
) )
template.active = form.active.data template.active = form.active.data
if form.apply_assignment_to_template.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
task.title = template.title task.title = template.title
task.description = template.description task.description = template.description
task.assigned_user_id = template.default_assigned_user_id task.assigned_user_id = form.assigned_user_id.data
task.points_awarded = template.default_points task.assigned_user_secondary_id = form.assigned_user_secondary_id.data or None
task.points_awarded = effective_points(template.default_points, task.assigned_user_secondary_id)
task.due_date = form.due_date.data task.due_date = form.due_date.data
refresh_task_status(task, form.due_date.data) refresh_task_status(task, form.due_date.data)
db.session.commit() db.session.commit()
@@ -108,8 +119,12 @@ def ensure_next_recurring_task(task: TaskInstance) -> TaskInstance | None:
title=task.task_template.title, title=task.task_template.title,
description=task.task_template.description, description=task.task_template.description,
assigned_user_id=task.task_template.default_assigned_user_id, 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, 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", status="open",
) )
refresh_task_status(next_task, today_local()) refresh_task_status(next_task, today_local())
@@ -125,7 +140,7 @@ def complete_task(task: TaskInstance, completed_by_user_id: int) -> TaskInstance
ensure_next_recurring_task(task) ensure_next_recurring_task(task)
db.session.commit() db.session.commit()
if task.completed_by_user: if task.completed_by_user:
evaluate_task_badges(task.completed_by_user) evaluate_task_badges(task.completed_by_user, notify=True)
return task return task
@@ -149,6 +164,7 @@ def create_quick_task(title: str, effort: str, creator: User, description: str =
title=template.title, title=template.title,
description=description, description=description,
assigned_user_id=creator.id, assigned_user_id=creator.id,
assigned_user_secondary_id=None,
due_date=today_local(), due_date=today_local(),
points_awarded=template.default_points, points_awarded=template.default_points,
status="open", status="open",
@@ -157,3 +173,17 @@ def create_quick_task(title: str, effort: str, creator: User, description: str =
db.session.add(task) db.session.add(task)
db.session.commit() db.session.commit()
return task 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()
+501 -28
View File
@@ -161,8 +161,15 @@ p {
margin-bottom: 24px; margin-bottom: 24px;
} }
.topbar > div {
min-width: 0;
}
.topbar h1 { .topbar h1 {
font-size: clamp(1.9rem, 4vw, 2.9rem); font-size: clamp(1.9rem, 4vw, 2.9rem);
min-width: 0;
overflow-wrap: break-word;
hyphens: auto;
} }
.topbar-user { .topbar-user {
@@ -288,6 +295,22 @@ p {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 14px; 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 { .brand strong {
@@ -301,8 +324,37 @@ p {
} }
.brand__logo { .brand__logo {
width: 48px; width: 100%;
height: 48px; 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 { .flash-stack {
@@ -358,6 +410,11 @@ p {
gap: 14px; gap: 14px;
} }
.form-actions {
flex-wrap: wrap;
min-width: 0;
}
.progress { .progress {
margin-top: 12px; margin-top: 12px;
height: 14px; height: 14px;
@@ -387,6 +444,9 @@ p {
.section-heading h2, .section-heading h2,
.panel h2 { .panel h2 {
font-size: 1.5rem; font-size: 1.5rem;
min-width: 0;
overflow-wrap: break-word;
hyphens: auto;
} }
.section-heading__count, .section-heading__count,
@@ -419,12 +479,41 @@ p {
.task-card { .task-card {
display: grid; display: grid;
gap: 16px; gap: 14px;
padding: 20px; 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 { .task-card h3 {
font-size: 1.35rem; font-size: 1.24rem;
line-height: 1.08;
overflow-wrap: break-word;
hyphens: auto;
} }
.task-card--compact { .task-card--compact {
@@ -473,6 +562,9 @@ p {
color: #1d4ed8; color: #1d4ed8;
} }
.status-badge--due_today,
.status-badge--due_tomorrow,
.status-badge--due_day_after_tomorrow,
.status-badge--soon { .status-badge--soon {
background: #fff3d6; background: #fff3d6;
color: #b45309; color: #b45309;
@@ -490,8 +582,8 @@ p {
.task-meta { .task-meta {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 14px; gap: 10px 14px;
} }
.task-meta dt { .task-meta dt {
@@ -507,11 +599,30 @@ p {
font-weight: 600; font-weight: 600;
} }
.task-card--compact .task-meta {
grid-template-columns: 1fr;
}
.task-assignee { .task-assignee {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
color: var(--muted); 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 { .avatar {
@@ -578,6 +689,11 @@ p {
color: var(--text); color: var(--text);
} }
.button--danger {
border-color: rgba(225, 29, 72, 0.3);
color: var(--danger);
}
.button--wide { .button--wide {
width: 100%; width: 100%;
} }
@@ -655,6 +771,46 @@ p {
gap: 14px; 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 { .panel--toolbar {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -898,6 +1054,9 @@ p {
border-left: 4px solid #2563eb; border-left: 4px solid #2563eb;
} }
.calendar-task--due_today,
.calendar-task--due_tomorrow,
.calendar-task--due_day_after_tomorrow,
.calendar-task--soon { .calendar-task--soon {
border-left: 4px solid #f59e0b; border-left: 4px solid #f59e0b;
} }
@@ -1050,6 +1209,12 @@ p {
gap: 12px; gap: 12px;
} }
.quick-win-list__toolbar {
display: flex;
justify-content: flex-end;
margin-bottom: 14px;
}
.quick-win-manage-card { .quick-win-manage-card {
display: grid; display: grid;
gap: 12px; gap: 12px;
@@ -1061,11 +1226,40 @@ p {
.quick-win-manage-card { .quick-win-manage-card {
align-items: stretch; align-items: stretch;
cursor: move;
}
.quick-win-manage-card.is-dragging {
opacity: 0.72;
} }
.quick-win-manage-form { .quick-win-manage-form {
display: grid; display: grid;
gap: 12px; 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 { .quick-win-manage-card__actions {
@@ -1112,6 +1306,9 @@ p {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 12px; gap: 12px;
width: 100%;
min-width: 0;
overflow-x: hidden;
} }
.quick-win-tag { .quick-win-tag {
@@ -1134,15 +1331,15 @@ p {
justify-content: center; justify-content: center;
min-height: 0; min-height: 0;
max-width: 100%; max-width: 100%;
padding: 10px 16px; padding: 8px 13px;
border-radius: 999px; border-radius: 999px;
border: 1px solid rgba(132, 152, 190, 0.22); border: 1px solid rgba(132, 152, 190, 0.22);
background: var(--surface-soft); background: var(--surface-soft);
color: var(--text); color: var(--text);
font-weight: 700; font-weight: 700;
font-size: 0.97rem; font-size: 0.9rem;
text-align: center; text-align: center;
line-height: 1.2; line-height: 1.1;
white-space: nowrap; 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; 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; cursor: pointer;
@@ -1183,34 +1380,152 @@ p {
} }
@media (max-width: 640px) { @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 { .quick-win-dialog-header {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 10px;
} }
.quick-win-dialog-header__badge { .quick-win-dialog-header__badge {
width: 48px; width: 42px;
height: 48px; height: 42px;
border-radius: 14px;
}
.quick-win-dialog-header__badge svg {
width: 19px;
height: 19px;
} }
.quick-win-tag-grid { .quick-win-tag-grid {
gap: 10px; gap: 8px;
} }
.quick-win-tag { .quick-win-tag {
max-width: 100%; max-width: 100%;
flex: 0 1 auto;
} }
.quick-win-tag span { .quick-win-tag span {
width: auto; width: 100%;
max-width: 100%; max-width: 100%;
padding: 6px 11px;
font-size: 0.84rem;
white-space: normal; white-space: normal;
text-align: left; overflow-wrap: anywhere;
justify-content: flex-start; text-align: center;
justify-content: center;
} }
.quick-win-tag--custom span { .quick-win-tag--custom span {
width: auto; 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 { .push-box__state {
@@ -1247,8 +1562,8 @@ p {
right: 8px; right: 8px;
bottom: calc(10px + env(safe-area-inset-bottom)); bottom: calc(10px + env(safe-area-inset-bottom));
display: grid; display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr)); grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 4px; gap: 3px;
padding: 8px 8px; padding: 8px 8px;
border-radius: 22px; border-radius: 22px;
background: var(--nav-bg); background: var(--nav-bg);
@@ -1262,13 +1577,13 @@ p {
.nav-link { .nav-link {
display: grid; display: grid;
justify-items: center; justify-items: center;
gap: 5px; gap: 4px;
min-width: 0; min-width: 0;
padding: 10px 4px 9px; padding: 10px 3px 9px;
color: var(--muted); color: var(--muted);
border-radius: 16px; border-radius: 16px;
text-align: center; text-align: center;
font-size: 0.66rem; font-size: 0.61rem;
font-weight: 700; font-weight: 700;
line-height: 1.05; line-height: 1.05;
} }
@@ -1276,7 +1591,7 @@ p {
.bottom-nav__item span { .bottom-nav__item span {
display: block; display: block;
width: 100%; width: 100%;
min-height: 2.1em; min-height: 2.3em;
white-space: normal; white-space: normal;
word-break: keep-all; word-break: keep-all;
overflow-wrap: break-word; overflow-wrap: break-word;
@@ -1290,15 +1605,33 @@ p {
.nav-icon, .nav-icon,
.nav-icon svg { .nav-icon svg {
width: 20px; width: 18px;
height: 20px; height: 18px;
display: inline-block; display: inline-block;
} }
.complete-dialog { .complete-dialog {
inset: 0;
width: 100vw;
max-width: 100vw;
min-height: 100vh;
min-height: 100dvh;
margin: 0;
padding: 12px;
border: 0; border: 0;
padding: 0;
background: transparent; 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 { .complete-dialog::backdrop {
@@ -1307,17 +1640,87 @@ p {
} }
.complete-dialog__surface { .complete-dialog__surface {
width: min(460px, calc(100vw - 24px)); width: min(460px, 100%);
max-width: 100%;
padding: 24px; padding: 24px;
border-radius: 28px; border-radius: 28px;
background: var(--surface-strong); background: var(--surface-strong);
box-shadow: var(--shadow); box-shadow: var(--shadow);
display: grid; display: grid;
gap: 18px; gap: 18px;
min-width: 0;
} }
.complete-dialog__surface--task { .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 { .choice-grid {
@@ -1339,7 +1742,7 @@ p {
.fab-quick-task { .fab-quick-task {
position: fixed; position: fixed;
right: max(16px, env(safe-area-inset-right)); right: max(16px, env(safe-area-inset-right));
bottom: calc(72px + var(--safe-bottom)); bottom: calc(86px + var(--safe-bottom));
width: 62px; width: 62px;
height: 62px; height: 62px;
border: 2px solid rgba(255, 255, 255, 0.55); border: 2px solid rgba(255, 255, 255, 0.55);
@@ -1360,6 +1763,61 @@ p {
height: 24px; 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) { @media (max-width: 759px) {
.calendar-toolbar-mobile__header { .calendar-toolbar-mobile__header {
align-items: flex-start; align-items: flex-start;
@@ -1422,6 +1880,9 @@ p {
color: #8db7ff; color: #8db7ff;
} }
.status-badge--due_today,
.status-badge--due_tomorrow,
.status-badge--due_day_after_tomorrow,
.status-badge--soon { .status-badge--soon {
background: rgba(245, 158, 11, 0.18); background: rgba(245, 158, 11, 0.18);
color: #ffd38a; color: #ffd38a;
@@ -1560,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) { @media (min-width: 1100px) {
.app-shell { .app-shell {
display: grid; display: grid;
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

+280 -11
View File
@@ -3,6 +3,7 @@
const dialogForm = document.getElementById("completeDialogForm"); const dialogForm = document.getElementById("completeDialogForm");
const dialogChoice = document.getElementById("completeDialogChoice"); const dialogChoice = document.getElementById("completeDialogChoice");
const dialogText = document.getElementById("completeDialogText"); const dialogText = document.getElementById("completeDialogText");
const dialogChoices = document.getElementById("completeDialogChoices");
const closeButton = document.getElementById("completeDialogClose"); const closeButton = document.getElementById("completeDialogClose");
const quickTaskDialog = document.getElementById("quickTaskDialog"); const quickTaskDialog = document.getElementById("quickTaskDialog");
const quickTaskOpen = document.getElementById("quickTaskOpen"); const quickTaskOpen = document.getElementById("quickTaskOpen");
@@ -13,23 +14,100 @@
const quickWinCustomFields = document.getElementById("quickWinCustomFields"); const quickWinCustomFields = document.getElementById("quickWinCustomFields");
const quickWinTitle = document.getElementById("quick-title"); const quickWinTitle = document.getElementById("quick-title");
const quickWinEffort = document.getElementById("quick-effort"); 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) => { document.querySelectorAll("[data-complete-action]").forEach((button) => {
button.addEventListener("click", () => { button.addEventListener("click", () => {
if (!dialog || !dialogForm || !dialogChoice || !dialogText) { if (!dialog || !dialogForm || !dialogChoice || !dialogText || !dialogChoices) {
return; return;
} }
dialogForm.action = button.dataset.completeAction; dialogForm.action = button.dataset.completeAction;
dialogText.textContent = `Die Aufgabe "${button.dataset.completeTitle}" war ${button.dataset.completeAssigned} zugewiesen. Wer hat sie erledigt?`; 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) => { buildCompletionOptions(button).forEach((option, index) => {
button.addEventListener("click", () => { const choiceButton = document.createElement("button");
dialogChoice.value = button.dataset.completeChoice || "me"; choiceButton.type = "button";
dialog.close(); choiceButton.className = index === 0 ? "button button--secondary" : "button";
dialogForm.submit(); 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();
}); });
}); });
@@ -38,13 +116,33 @@
} }
if (quickTaskOpen && quickTaskDialog) { if (quickTaskOpen && quickTaskDialog) {
quickTaskOpen.addEventListener("click", () => quickTaskDialog.showModal()); quickTaskOpen.addEventListener("click", () => {
if (!quickTaskDialog.open) {
quickTaskDialog.showModal();
}
});
} }
if (quickTaskClose && quickTaskDialog) { 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() { function updateQuickWinsState() {
const selectedPresetCount = [...quickWinInputs].filter((input) => input.checked).length; const selectedPresetCount = [...quickWinInputs].filter((input) => input.checked).length;
const customSelected = quickWinCustomToggle?.checked === true; const customSelected = quickWinCustomToggle?.checked === true;
@@ -87,6 +185,170 @@
}); });
} }
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 pushButton = document.getElementById("pushToggle");
const pushHint = document.getElementById("pushHint"); const pushHint = document.getElementById("pushHint");
const vapidKey = document.body.dataset.pushKey; const vapidKey = document.body.dataset.pushKey;
@@ -160,4 +422,11 @@
togglePush().catch((error) => console.error("Push toggle failed", error)); 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 = [ const ASSETS = [
"/static/css/style.css", "/static/css/style.css",
"/static/js/app.js", "/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-192.png",
"/static/images/pwa-icon-512.png" "/static/images/pwa-icon-512.png"
]; ];
+3
View File
@@ -3,6 +3,9 @@
{% block content %} {% block content %}
<section class="auth-layout"> <section class="auth-layout">
<div class="hero-card hero-card--brand"> <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> <p class="eyebrow">Leichtgewichtige Haushalts-App</p>
<h2>Putzliga bringt Punkte, Rhythmus und ein bisschen Liga-Stimmung in den Alltag.</h2> <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> <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 %} {% block content %}
<section class="auth-layout"> <section class="auth-layout">
<div class="hero-card hero-card--brand"> <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> <p class="eyebrow">Gemeinsam sauberer</p>
<h2>Erstelle dein Konto und steig direkt in die Liga ein.</h2> <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> <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>
</section> </section>
{% endblock %} {% endblock %}
+17 -8
View File
@@ -12,17 +12,25 @@
<meta name="description" content="Putzliga macht Haushaltsaufgaben leichter, motivierender und fair verteilt."> <meta name="description" content="Putzliga macht Haushaltsaufgaben leichter, motivierender und fair verteilt.">
<title>{% block title %}{{ app_name }}{% endblock %}</title> <title>{% block title %}{{ app_name }}{% endblock %}</title>
<link rel="manifest" href="{{ url_for('main.manifest') }}"> <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="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')) }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css', v=asset_version('css/style.css')) }}">
</head> </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 %} {% from "partials/macros.html" import nav_icon %}
<div class="app-shell {% if not current_user.is_authenticated %}app-shell--auth{% endif %}"> <div class="app-shell {% if not current_user.is_authenticated %}app-shell--auth{% endif %}">
{% if current_user.is_authenticated %} {% if current_user.is_authenticated %}
<aside class="sidebar"> <aside class="sidebar">
<a class="brand" href="{{ url_for('tasks.my_tasks') }}"> <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> <div>
<strong>Putzliga</strong> <strong>Putzliga</strong>
<span>Haushalt mit Punktestand</span> <span>Haushalt mit Punktestand</span>
@@ -64,7 +72,9 @@
</a> </a>
{% else %} {% else %}
<a class="brand brand--public" href="{{ url_for('auth.login') }}"> <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> <div>
<strong>Putzliga</strong> <strong>Putzliga</strong>
<span>Haushaltsaufgaben mit Liga-Gefühl</span> <span>Haushaltsaufgaben mit Liga-Gefühl</span>
@@ -101,6 +111,8 @@
</div> </div>
{% if current_user.is_authenticated %} {% if current_user.is_authenticated %}
<div class="celebration-layer" id="celebrationLayer" hidden aria-hidden="true"></div>
<nav class="bottom-nav" aria-label="Mobile Navigation"> <nav class="bottom-nav" aria-label="Mobile Navigation">
{% for endpoint, label, icon in mobile_nav_items %} {% for endpoint, label, icon in mobile_nav_items %}
<a href="{{ url_for(endpoint) }}" class="bottom-nav__item {% if request.endpoint == endpoint %}is-active{% endif %}"> <a href="{{ url_for(endpoint) }}" class="bottom-nav__item {% if request.endpoint == endpoint %}is-active{% endif %}">
@@ -119,10 +131,7 @@
<p class="eyebrow">Punkte fair verbuchen</p> <p class="eyebrow">Punkte fair verbuchen</p>
<h2>Wer hat diese Aufgabe erledigt?</h2> <h2>Wer hat diese Aufgabe erledigt?</h2>
<p id="completeDialogText">Bitte wähle aus, wem die Punkte gutgeschrieben werden sollen.</p> <p id="completeDialogText">Bitte wähle aus, wem die Punkte gutgeschrieben werden sollen.</p>
<div class="choice-grid"> <div class="choice-grid" id="completeDialogChoices"></div>
<button type="button" class="button button--secondary" data-complete-choice="assigned">Zugewiesene Person</button>
<button type="button" class="button" data-complete-choice="me">Ich</button>
</div>
<button type="button" class="button button--ghost" id="completeDialogClose">Abbrechen</button> <button type="button" class="button button--ghost" id="completeDialogClose">Abbrechen</button>
</form> </form>
</dialog> </dialog>
+16 -16
View File
@@ -45,10 +45,10 @@
{% macro task_card(task, current_user, compact=false) -%} {% macro task_card(task, current_user, compact=false) -%}
<article class="task-card {% if compact %}task-card--compact{% endif %}"> <article class="task-card {% if compact %}task-card--compact{% endif %}">
<div class="task-card__top"> <div class="task-card__top">
<div> <div class="task-card__title-block">
<div class="chip-row"> <div class="chip-row">
{{ status_badge(task) }} {{ 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> </div>
<h3>{{ task.title }}</h3> <h3>{{ task.title }}</h3>
</div> </div>
@@ -66,36 +66,36 @@
<dt>Fällig</dt> <dt>Fällig</dt>
<dd>{{ task.due_date|date_de }}</dd> <dd>{{ task.due_date|date_de }}</dd>
</div> </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 %} {% if task.completed_at %}
<div> <div>
<dt>Erledigt von</dt> <dt>Erledigt</dt>
<dd>{{ task.completed_by_user.name if task.completed_by_user else '—' }}</dd> <dd>{{ task.completed_at|datetime_de }}</dd>
</div> </div>
{% endif %} {% endif %}
</dl> </dl>
<div class="task-card__footer"> <div class="task-card__footer">
<div class="task-assignee"> <div class="task-assignee">
{{ avatar(task.assigned_user) }} <span class="task-assignee__avatars">
<span>{{ task.assigned_user.name if task.assigned_user else 'Ohne Person' }}</span> {% for assigned_user in task.assigned_users %}
{{ avatar(assigned_user) }}
{% endfor %}
</span>
<span>{{ task.assignee_label }}</span>
</div> </div>
{% if not task.completed_at %} {% 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 <button
type="button" type="button"
class="button" class="button"
data-complete-action="{{ url_for('tasks.complete', task_id=task.id) }}" data-complete-action="{{ url_for('tasks.complete', task_id=task.id) }}"
data-complete-title="{{ task.title }}" 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') }} {{ nav_icon('check') }}
<span>Erledigen</span> <span>Erledigen</span>
+5 -1
View File
@@ -40,12 +40,16 @@
</div> </div>
<label class="checkbox"> <label class="checkbox">
{{ form.notification_task_due_enabled() }} {{ 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>
<label class="checkbox"> <label class="checkbox">
{{ form.notification_monthly_winner_enabled() }} {{ form.notification_monthly_winner_enabled() }}
<span>Push zum Monatssieger am 1. um 09:00 Uhr</span> <span>Push zum Monatssieger am 1. um 09:00 Uhr</span>
</label> </label>
<label class="checkbox">
{{ form.notification_badge_enabled() }}
<span>Push, wenn du ein neues Badge freischaltest</span>
</label>
{{ form.submit(class_='button') }} {{ form.submit(class_='button') }}
</form> </form>
</article> </article>
+39 -3
View File
@@ -36,10 +36,46 @@
<article class="panel"> <article class="panel">
<p class="eyebrow">Direkt sichtbar</p> <p class="eyebrow">Direkt sichtbar</p>
<h2>Aktive Quick-Wins bearbeiten</h2> <h2>Aktive Quick-Wins bearbeiten</h2>
<div class="quick-win-list"> <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 %} {% for quick_win in quick_wins %}
<article class="quick-win-manage-card"> <article class="quick-win-manage-card" draggable="true" data-quick-win-sort-item="{{ quick_win.id }}">
<form method="post" action="{{ url_for('settings.update_quick_win', quick_win_id=quick_win.id) }}" class="quick-win-manage-form"> <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() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="field"> <div class="field">
<label for="quick-win-title-{{ quick_win.id }}">Titel</label> <label for="quick-win-title-{{ quick_win.id }}">Titel</label>
+68 -9
View File
@@ -11,8 +11,10 @@
<option value="all" {% if filters.status == 'all' %}selected{% endif %}>Alle</option> <option value="all" {% if filters.status == 'all' %}selected{% endif %}>Alle</option>
<option value="open" {% if filters.status == 'open' %}selected{% endif %}>Offen</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="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="overdue" {% if filters.status == 'overdue' %}selected{% endif %}>Überfällig</option>
<option value="completed" {% if filters.status == 'completed' %}selected{% endif %}>Erledigt</option>
</select> </select>
</div> </div>
<div class="field field--compact"> <div class="field field--compact">
@@ -40,12 +42,69 @@
</form> </form>
</section> </section>
<section class="task-grid"> {% if filters.status == 'all' %}
{% for task in tasks %} <section class="stack">
{{ task_card(task, current_user) }} <div class="section-heading">
{% else %} <h2>Überfällig</h2>
<div class="empty-state">Für diese Filter gibt es gerade keine Aufgaben.</div> <span class="section-heading__count">{{ sections.overdue|length }}</span>
{% endfor %} </div>
</section> <div class="task-grid">
{% endblock %} {% 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 %}
+4 -4
View File
@@ -75,8 +75,8 @@
<small class="calendar-task__person" lang="de"> <small class="calendar-task__person" lang="de">
{% if task.completed_by_user %} {% if task.completed_by_user %}
{{ task.completed_by_user.name|hyphenate_de }} {{ task.completed_by_user.name|hyphenate_de }}
{% elif task.assigned_user %} {% elif task.assigned_users %}
{{ task.assigned_user.name|hyphenate_de }} {{ task.assignee_label|hyphenate_de }}
{% else %} {% else %}
{{ 'Ohne Zuweisung'|hyphenate_de }} {{ 'Ohne Zuweisung'|hyphenate_de }}
{% endif %} {% endif %}
@@ -108,8 +108,8 @@
<small class="calendar-task__person" lang="de"> <small class="calendar-task__person" lang="de">
{% if task.completed_by_user %} {% if task.completed_by_user %}
{{ task.completed_by_user.name|hyphenate_de }} {{ task.completed_by_user.name|hyphenate_de }}
{% elif task.assigned_user %} {% elif task.assigned_users %}
{{ task.assigned_user.name|hyphenate_de }} {{ task.assignee_label|hyphenate_de }}
{% else %} {% else %}
{{ 'Ohne Zuweisung'|hyphenate_de }} {{ 'Ohne Zuweisung'|hyphenate_de }}
{% endif %} {% endif %}
+29 -24
View File
@@ -18,14 +18,20 @@
</article> </article>
<article class="panel highlight-panel"> <article class="panel highlight-panel">
<p class="eyebrow">Schnellzugriff</p> <p class="eyebrow">Schnellzugriff</p>
<a class="button button--wide" href="{{ url_for('tasks.create') }}"> <div class="quick-actions">
{{ nav_icon('plus') }} <a class="button button--wide" href="{{ url_for('tasks.create') }}">
<span>Neue Aufgabe anlegen</span> {{ nav_icon('plus') }}
</a> <span>Neue Aufgabe anlegen</span>
<a class="button button--secondary button--wide" href="{{ url_for('scoreboard.index') }}"> </a>
{{ nav_icon('trophy') }} <a class="button button--ghost button--wide" href="{{ url_for('tasks.archive_view') }}">
<span>Zum aktuellen Highscore</span> {{ nav_icon('check-double') }}
</a> <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> </article>
</section> </section>
@@ -43,6 +49,20 @@
</div> </div>
</section> </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"> <section class="stack">
<div class="section-heading"> <div class="section-heading">
<h2>Bald fällig</h2> <h2>Bald fällig</h2>
@@ -52,7 +72,7 @@
{% for task in sections.soon %} {% for task in sections.soon %}
{{ task_card(task, current_user) }} {{ task_card(task, current_user) }}
{% else %} {% 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 %} {% endfor %}
</div> </div>
</section> </section>
@@ -70,19 +90,4 @@
{% endfor %} {% endfor %}
</div> </div>
</section> </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 %} {% endblock %}
+36 -2
View File
@@ -7,6 +7,9 @@
<h2>{% if mode == 'edit' %}Änderungen für {{ task.title }}{% else %}Neue Aufgabe anlegen{% endif %}</h2> <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 method="post" class="form-grid form-grid--two">
{{ form.hidden_tag() }} {{ form.hidden_tag() }}
{% if mode == 'edit' %}
<input type="hidden" name="next" value="{{ next_url }}">
{% endif %}
<div class="field field--full"> <div class="field field--full">
{{ form.title.label }} {{ form.title.label }}
@@ -27,11 +30,32 @@
</div> </div>
<div class="field"> <div class="field">
{{ form.assigned_user_id.label }} {% if mode == 'edit' %}
<label for="{{ form.assigned_user_id.id }}">Zuständig für diese Aufgabe</label>
{% else %}
{{ form.assigned_user_id.label }}
{% endif %}
{{ form.assigned_user_id() }} {{ form.assigned_user_id() }}
{% if mode == 'edit' %}
<small class="muted">Wiederholungen bleiben bei der ursprünglich vorgesehenen Person.</small>
{% endif %}
{% for error in form.assigned_user_id.errors %}<small class="error">{{ error }}</small>{% endfor %} {% for error in form.assigned_user_id.errors %}<small class="error">{{ error }}</small>{% endfor %}
</div> </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>
{% if mode == 'edit' %}
<label class="checkbox field--full">
{{ form.apply_assignment_to_template() }}
<span>Diese Zuständigkeit auch für zukünftige Wiederholungen übernehmen</span>
</label>
{% endif %}
<div class="field"> <div class="field">
{{ form.due_date.label }} {{ form.due_date.label }}
{{ form.due_date() }} {{ form.due_date() }}
@@ -57,8 +81,18 @@
<div class="form-actions field--full"> <div class="form-actions field--full">
{{ form.submit(class_='button') }} {{ form.submit(class_='button') }}
<a class="button button--ghost" href="{{ url_for('tasks.all_tasks') }}">Abbrechen</a> <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> </div>
</form> </form>
</section> </section>
{% endblock %} {% endblock %}
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 151 KiB