10 Commits

25 changed files with 1509 additions and 212 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.5.1", "version": "0.6.5",
"manifestVersion": 2, "manifestVersion": 2,
"healthCheckPath": "/healthz", "healthCheckPath": "/healthz",
"httpPort": 8000, "httpPort": 8000,
+35 -5
View File
@@ -7,7 +7,7 @@ Putzliga ist eine moderne, leichte Haushaltsaufgaben-Web-App mit spielerischem C
- Mehrere Nutzer mit Login, Admin-Nutzermanagement und Profil-/Avatar-Einstellungen - Mehrere Nutzer mit Login, Admin-Nutzermanagement und Profil-/Avatar-Einstellungen
- Trennung zwischen `TaskTemplate` und `TaskInstance` - Trennung zwischen `TaskTemplate` und `TaskInstance`
- Aufgaben anlegen, bearbeiten, zuweisen und erledigen - Aufgaben anlegen, bearbeiten, zuweisen und erledigen
- globale Schnellaufgabe per Plus-Button mit Titel + Aufwand und automatisch passender Punktezahl - globale Quick-Wins per Plus-Button mit gemeinsamen Vorlagen und freiem „Sonstiges“-Fallback
- Wiederholungen für einmalig, alle X Tage, alle X Wochen und alle X Monate - Wiederholungen für einmalig, alle X Tage, alle X Wochen und alle X Monate
- Saubere Erledigungslogik für fremd zugewiesene Aufgaben mit Auswahl, wer wirklich erledigt hat - Saubere Erledigungslogik für fremd zugewiesene Aufgaben mit Auswahl, wer wirklich erledigt hat
- Statuslogik für offen, bald fällig, überfällig und erledigt - Statuslogik für offen, bald fällig, überfällig und erledigt
@@ -129,11 +129,13 @@ Admins können Nutzer zusätzlich direkt in der App unter `Optionen -> Profil &
Badges werden dauerhaft pro Nutzer gespeichert und automatisch freigeschaltet. Die Badge-Regeln werden für Admins auf einer eigenen Seite unter `Optionen -> Badges` gepflegt. Badges werden dauerhaft pro Nutzer gespeichert und automatisch freigeschaltet. Die Badge-Regeln werden für Admins auf einer eigenen Seite unter `Optionen -> Badges` gepflegt.
## Schnellaufgabe ## Quick-Wins
Über den global sichtbaren Plus-Button rechts unten kannst du auf jeder eingeloggten Seite eine Schnellaufgabe anlegen. Über den global sichtbaren Plus-Button rechts unten kannst du auf jeder eingeloggten Seite Quick-Wins nutzen.
- nur `Titel` und `Aufwand` - gemeinsame Quick-Wins sind für alle Nutzer sichtbar und direkt klickbar
- alle Nutzer können im separaten Optionen-Tab neue Quick-Wins anlegen
- für `Sonstiges (bitte auch nutzen)` lassen sich Titel und Aufwand frei wählen
- die Aufgabe wird automatisch dem gerade eingeloggten Nutzer zugewiesen - die Aufgabe wird automatisch dem gerade eingeloggten Nutzer zugewiesen
- Fälligkeit ist direkt `heute` - Fälligkeit ist direkt `heute`
- die Punkte hängen vom Aufwand ab - die Punkte hängen vom Aufwand ab
@@ -144,8 +146,9 @@ Die Aufwand-Stufen sind:
- Normal - Normal
- Dauert etwas - Dauert etwas
- Aufwendig - Aufwendig
- Super aufwendig
Admins können die Punkte je Aufwand unter `Optionen -> Profil & Team` anpassen. Admins können die Punkte je Aufwand unter `Optionen -> Quick-Wins` anpassen.
### 5. Entwicklungsserver starten ### 5. Entwicklungsserver starten
@@ -343,6 +346,33 @@ Der ausgegebene `VAPID_PRIVATE_KEY` ist bereits `.env`-freundlich mit escaped Ne
## Release Notes ## Release Notes
### 0.6.5
- Quick-Wins als gemeinsames Team-Feature ausgebaut
- neuer Optionen-Tab zum Anlegen und Verwalten gemeinsamer Quick-Wins
- bestehende Quick-Wins in den Optionen direkt bearbeitbar gemacht
- Quick-Win-Reihenfolge per Drag & Drop ergänzt und Speichern der Sortierung in den Optionen stabilisiert
- Plus-Dialog von Einzelkarten auf kompakte auswählbare Quick-Win-Chips umgestellt
- mehrere Quick-Wins lassen sich gesammelt als erledigt speichern
- „Sonstiges“ blendet Titel und Aufwand jetzt nur bei Auswahl ein
- neue Aufwand-Stufe `super aufwendig`
- Quick-Win-Popup visuell mit übernommenem Sparkles-Icon aus `heinz.marketing` aufgewertet
- gemeinsame Aufgaben für zwei Personen mit halbierten Punkten pro Person ergänzt
- Aufgabenstatus in `morgen fällig`, `übermorgen fällig` und `bald fällig` feiner aufgeteilt
- Aufgaben können jetzt von allen Nutzern direkt gelöscht werden
- Kalenderdarstellung für lange deutsche Begriffe und Namen bei der Worttrennung nachgeschärft
- deutsche Silbentrennung serverseitig vorbereitet, mit optionalem `pyphen`-Fallback ohne Startfehler im lokalen Dev-Setup
- mobile Layouts für Aufgabenkarten, Bearbeiten-Ansicht und Quick-Win-Verwaltung weiter verdichtet und ausgerichtet
- Footer auf Versionslink, Herkunftshinweis und `hnz.io`-Verweis umgebaut
- Cloudron-Version auf `0.6.5` angehoben
### 0.6.0
- Persönlicher read-only ICS-Feed pro Nutzer für externe Kalender ergänzt
- Export enthält nur eigene zugewiesene Aufgaben mit Titel, Beschreibung und Fälligkeitsdatum
- Kalender-Link in den Optionen sichtbar gemacht und Token-Neugenerierung ergänzt
- Cloudron-Version auf `0.6.0` angehoben
### 0.5.1 ### 0.5.1
- Footer mit automatischer Versionsanzeige und Links zu Releases und hnz.io - Footer mit automatischer Versionsanzeige und Links zu Releases und hnz.io
+46
View File
@@ -4,12 +4,18 @@ import json
from pathlib import Path from pathlib import Path
from flask import Flask from flask import Flask
from markupsafe import escape
try:
import pyphen
except ModuleNotFoundError: # pragma: no cover - optional dependency in local dev
pyphen = None
from config import Config from config import Config
from .cli import register_cli, seed_badges from .cli import register_cli, seed_badges
from .extensions import csrf, db, login_manager from .extensions import csrf, db, login_manager
from .forms import QuickTaskForm from .forms import QuickTaskForm
from .models import QuickWin
from .routes import auth, main, scoreboard, settings, tasks from .routes import auth, main, scoreboard, settings, tasks
from .routes.main import load_icon_svg from .routes.main import load_icon_svg
from .services.app_settings import get_quick_task_config from .services.app_settings import get_quick_task_config
@@ -18,6 +24,12 @@ from .services.bootstrap import ensure_schema_and_admins
from .services.dates import MONTH_NAMES, local_now from .services.dates import MONTH_NAMES, local_now
from .services.monthly import archive_months_missing_up_to_previous from .services.monthly import archive_months_missing_up_to_previous
DE_HYPHENATOR = pyphen.Pyphen(lang="de_DE") if pyphen else None
def _fallback_soft_hyphenate(word: str) -> str:
return word
def create_app(config_class: type[Config] = Config) -> Flask: def create_app(config_class: type[Config] = Config) -> Flask:
app = Flask(__name__, static_folder="static", template_folder="templates") app = Flask(__name__, static_folder="static", template_folder="templates")
@@ -64,6 +76,7 @@ def create_app(config_class: type[Config] = Config) -> Flask:
(key, values["label"]) (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.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:
@@ -94,6 +107,7 @@ def create_app(config_class: type[Config] = Config) -> Flask:
"now_local": local_now(), "now_local": local_now(),
"quick_task_form": quick_task_form, "quick_task_form": quick_task_form,
"quick_task_config": quick_task_config, "quick_task_config": quick_task_config,
"quick_wins": quick_wins,
} }
@app.template_filter("date_de") @app.template_filter("date_de")
@@ -108,4 +122,36 @@ def create_app(config_class: type[Config] = Config) -> Flask:
def month_name(value): def month_name(value):
return MONTH_NAMES[value] return MONTH_NAMES[value]
@app.template_filter("hyphenate_de")
def hyphenate_de(value):
if not value:
return ""
text = str(value)
parts: list[str] = []
current = []
def flush_word():
if not current:
return
word = "".join(current)
if len(word) >= 6:
if DE_HYPHENATOR:
parts.append(DE_HYPHENATOR.inserted(word, "\u00AD"))
else:
parts.append(_fallback_soft_hyphenate(word))
else:
parts.append(word)
current.clear()
for char in text:
if char.isalpha() or char in "ÄÖÜäöüß":
current.append(char)
else:
flush_word()
parts.append(char)
flush_word()
return escape("".join(parts))
return app return app
+19 -3
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",
@@ -74,6 +75,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
@@ -115,13 +119,13 @@ class AdminUserForm(FlaskForm):
class QuickTaskForm(FlaskForm): class QuickTaskForm(FlaskForm):
title = StringField("Titel", validators=[DataRequired(), Length(min=2, max=160)]) title = StringField("Titel", validators=[Optional(), Length(min=2, max=160)])
effort = SelectField( effort = SelectField(
"Aufwand", "Aufwand",
choices=[(key, key) for key, _, _ in QUICK_TASK_EFFORTS], choices=[(key, key) for key, _, _ in QUICK_TASK_EFFORTS],
validators=[DataRequired()], validators=[Optional()],
) )
submit = SubmitField("Schnellaufgabe speichern") submit = SubmitField("Quick-Win speichern")
class QuickTaskConfigForm(FlaskForm): class QuickTaskConfigForm(FlaskForm):
@@ -133,4 +137,16 @@ class QuickTaskConfigForm(FlaskForm):
medium_points = IntegerField("Dauert etwas", validators=[DataRequired(), NumberRange(min=1, max=500)]) medium_points = IntegerField("Dauert etwas", validators=[DataRequired(), NumberRange(min=1, max=500)])
heavy_label = StringField("Bezeichnung", validators=[DataRequired(), Length(min=2, max=60)]) heavy_label = StringField("Bezeichnung", validators=[DataRequired(), Length(min=2, max=60)])
heavy_points = IntegerField("Aufwendig", validators=[DataRequired(), NumberRange(min=1, max=500)]) heavy_points = IntegerField("Aufwendig", validators=[DataRequired(), NumberRange(min=1, max=500)])
super_heavy_label = StringField("Bezeichnung", validators=[DataRequired(), Length(min=2, max=60)])
super_heavy_points = IntegerField("Super aufwendig", validators=[DataRequired(), NumberRange(min=1, max=500)])
submit = SubmitField("Aufwand speichern") submit = SubmitField("Aufwand speichern")
class QuickWinForm(FlaskForm):
title = StringField("Quick-Win", validators=[DataRequired(), Length(min=2, max=160)])
effort = SelectField(
"Aufwand",
choices=[(key, key) for key, _, _ in QUICK_TASK_EFFORTS],
validators=[DataRequired()],
)
submit = SubmitField("Quick-Win speichern")
+63 -3
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import secrets
from datetime import UTC, date, datetime, timedelta from datetime import UTC, date, datetime, timedelta
from flask_login import UserMixin from flask_login import UserMixin
@@ -24,6 +25,7 @@ class User(UserMixin, TimestampMixin, db.Model):
password_hash = db.Column(db.String(255), nullable=False) password_hash = db.Column(db.String(255), nullable=False)
is_admin = db.Column(db.Boolean, nullable=False, default=False) is_admin = db.Column(db.Boolean, nullable=False, default=False)
avatar_path = db.Column(db.String(255), nullable=True) avatar_path = db.Column(db.String(255), nullable=True)
calendar_feed_token = db.Column(db.String(255), nullable=True, unique=True, index=True)
notification_task_due_enabled = db.Column(db.Boolean, nullable=False, default=True) notification_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)
@@ -33,12 +35,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",
@@ -46,6 +60,7 @@ class User(UserMixin, TimestampMixin, db.Model):
lazy=True, lazy=True,
) )
subscriptions = db.relationship("PushSubscription", backref="user", lazy=True, cascade="all, delete-orphan") subscriptions = db.relationship("PushSubscription", backref="user", lazy=True, cascade="all, delete-orphan")
created_quick_wins = db.relationship("QuickWin", backref="created_by_user", lazy=True)
awarded_badges = db.relationship( awarded_badges = db.relationship(
"UserBadge", "UserBadge",
backref="user", backref="user",
@@ -60,6 +75,11 @@ class User(UserMixin, TimestampMixin, db.Model):
def check_password(self, password: str) -> bool: def check_password(self, password: str) -> bool:
return check_password_hash(self.password_hash, password) return check_password_hash(self.password_hash, password)
def ensure_calendar_feed_token(self) -> str:
if not self.calendar_feed_token:
self.calendar_feed_token = secrets.token_urlsafe(32)
return self.calendar_feed_token
@property @property
def display_avatar(self) -> str: def display_avatar(self) -> str:
return self.avatar_path or "images/avatars/default.svg" return self.avatar_path or "images/avatars/default.svg"
@@ -79,6 +99,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)
@@ -99,12 +120,22 @@ class TaskTemplate(TimestampMixin, db.Model):
return f"Alle {self.recurrence_interval_value} {unit_label}" return f"Alle {self.recurrence_interval_value} {unit_label}"
class QuickWin(TimestampMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(160), nullable=False, index=True)
effort = db.Column(db.String(40), nullable=False, index=True)
active = db.Column(db.Boolean, nullable=False, default=True)
created_by_user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False, index=True)
sort_order = db.Column(db.Integer, nullable=False, default=0, index=True)
class TaskInstance(TimestampMixin, db.Model): class TaskInstance(TimestampMixin, db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
task_template_id = db.Column(db.Integer, db.ForeignKey("task_template.id"), nullable=False, index=True) task_template_id = db.Column(db.Integer, db.ForeignKey("task_template.id"), nullable=False, index=True)
title = db.Column(db.String(160), nullable=False) 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)
@@ -115,21 +146,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": "Bald fällig",
"due_tomorrow": "Morgen fällig",
"due_day_after_tomorrow": "Übermorgen fällig",
"overdue": "Überfällig", "overdue": "Überfällig",
"completed": "Erledigt", "completed": "Erledigt",
} }
+13 -1
View File
@@ -3,9 +3,11 @@ from __future__ import annotations
from functools import lru_cache from functools import lru_cache
from pathlib import Path from pathlib import Path
from flask import Blueprint, current_app, redirect, send_from_directory, url_for from flask import Blueprint, Response, current_app, redirect, send_from_directory, url_for
from flask_login import current_user from flask_login import current_user
from ..models import User
from ..services.calendar_feeds import build_calendar_feed
bp = Blueprint("main", __name__) bp = Blueprint("main", __name__)
@@ -39,6 +41,16 @@ def uploads(filename: str):
return send_from_directory(current_app.config["UPLOAD_FOLDER"], filename) return send_from_directory(current_app.config["UPLOAD_FOLDER"], filename)
@bp.route("/calendar-feed/<token>.ics")
def calendar_feed(token: str):
user = User.query.filter_by(calendar_feed_token=token).first_or_404()
body = build_calendar_feed(user, url_for("tasks.my_tasks", _external=True))
response = Response(body, content_type="text/calendar; charset=utf-8")
response.headers["Content-Disposition"] = 'inline; filename="putzliga.ics"'
response.headers["Cache-Control"] = "private, max-age=300"
return response
@lru_cache(maxsize=64) @lru_cache(maxsize=64)
def load_icon_svg(name: str, static_folder: str) -> str: def load_icon_svg(name: str, static_folder: str) -> str:
path = Path(static_folder) / "icons" / f"{name}.svg" path = Path(static_folder) / "icons" / f"{name}.svg"
+155 -18
View File
@@ -5,11 +5,12 @@ from uuid import uuid4
from flask import Blueprint, current_app, flash, jsonify, redirect, render_template, request, url_for from flask 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
from ..forms import AdminUserForm, QuickTaskConfigForm, SettingsProfileForm from ..forms import AdminUserForm, QuickTaskConfigForm, QuickWinForm, SettingsProfileForm
from ..models import BadgeDefinition, MonthlyScoreSnapshot, NotificationLog, PushSubscription, TaskInstance, TaskTemplate, User from ..models import BadgeDefinition, MonthlyScoreSnapshot, NotificationLog, PushSubscription, QuickWin, TaskInstance, TaskTemplate, User
from ..services.app_settings import get_quick_task_config, set_setting_int, set_setting_str from ..services.app_settings import get_quick_task_config, set_setting_int, set_setting_str
from ..services.badges import earned_badges_for_user from ..services.badges import earned_badges_for_user
from ..services.notifications import push_enabled from ..services.notifications import push_enabled
@@ -26,7 +27,10 @@ def _require_admin():
def _settings_tabs(): def _settings_tabs():
tabs = [("settings.index", "Profil & Team", "gear")] tabs = [
("settings.index", "Profil & Team", "gear"),
("settings.quick_wins", "Quick-Wins", "plus"),
]
if current_user.is_admin: if current_user.is_admin:
tabs.append(("settings.badges", "Badges", "award")) tabs.append(("settings.badges", "Badges", "award"))
return tabs return tabs
@@ -45,19 +49,9 @@ def _save_avatar(file_storage) -> str:
@bp.route("", methods=["GET", "POST"]) @bp.route("", methods=["GET", "POST"])
@login_required @login_required
def index(): def index():
current_user.ensure_calendar_feed_token()
form = SettingsProfileForm(original_email=current_user.email, obj=current_user) form = SettingsProfileForm(original_email=current_user.email, obj=current_user)
admin_form = AdminUserForm(prefix="admin") admin_form = AdminUserForm(prefix="admin")
quick_task_config_form = QuickTaskConfigForm(prefix="quickconfig")
quick_task_config = get_quick_task_config()
if request.method == "GET":
quick_task_config_form.fast_label.data = quick_task_config["fast"]["label"]
quick_task_config_form.fast_points.data = quick_task_config["fast"]["points"]
quick_task_config_form.normal_label.data = quick_task_config["normal"]["label"]
quick_task_config_form.normal_points.data = quick_task_config["normal"]["points"]
quick_task_config_form.medium_label.data = quick_task_config["medium"]["label"]
quick_task_config_form.medium_points.data = quick_task_config["medium"]["points"]
quick_task_config_form.heavy_label.data = quick_task_config["heavy"]["label"]
quick_task_config_form.heavy_points.data = quick_task_config["heavy"]["points"]
if form.validate_on_submit(): if form.validate_on_submit():
current_user.name = form.name.data.strip() current_user.name = form.name.data.strip()
current_user.email = form.email.data.lower().strip() current_user.email = form.email.data.lower().strip()
@@ -76,10 +70,9 @@ def index():
"settings/index.html", "settings/index.html",
form=form, form=form,
admin_form=admin_form, admin_form=admin_form,
quick_task_config_form=quick_task_config_form,
quick_task_config=quick_task_config,
users=User.query.order_by(User.is_admin.desc(), User.name.asc()).all(), users=User.query.order_by(User.is_admin.desc(), User.name.asc()).all(),
earned_badges=earned_badges_for_user(current_user.id), earned_badges=earned_badges_for_user(current_user.id),
calendar_feed_url=url_for("main.calendar_feed", token=current_user.calendar_feed_token, _external=True),
push_ready=push_enabled(), push_ready=push_enabled(),
has_subscription=bool(subscriptions), has_subscription=bool(subscriptions),
settings_tabs=_settings_tabs(), settings_tabs=_settings_tabs(),
@@ -87,6 +80,64 @@ def index():
) )
@bp.route("/quick-wins", methods=["GET", "POST"])
@login_required
def quick_wins():
quick_win_form = QuickWinForm(prefix="quickwin")
quick_task_config_form = QuickTaskConfigForm(prefix="quickconfig")
quick_task_config = get_quick_task_config()
quick_win_form.effort.choices = [(key, values["label"]) for key, values in quick_task_config.items()]
quick_task_config_form.fast_label.data = quick_task_config["fast"]["label"]
quick_task_config_form.fast_points.data = quick_task_config["fast"]["points"]
quick_task_config_form.normal_label.data = quick_task_config["normal"]["label"]
quick_task_config_form.normal_points.data = quick_task_config["normal"]["points"]
quick_task_config_form.medium_label.data = quick_task_config["medium"]["label"]
quick_task_config_form.medium_points.data = quick_task_config["medium"]["points"]
quick_task_config_form.heavy_label.data = quick_task_config["heavy"]["label"]
quick_task_config_form.heavy_points.data = quick_task_config["heavy"]["points"]
quick_task_config_form.super_heavy_label.data = quick_task_config["super_heavy"]["label"]
quick_task_config_form.super_heavy_points.data = quick_task_config["super_heavy"]["points"]
if quick_win_form.validate_on_submit():
existing_quick_win = QuickWin.query.filter_by(title=quick_win_form.title.data.strip(), active=True).first()
if existing_quick_win:
flash("Diesen Quick-Win gibt es bereits.", "error")
return redirect(url_for("settings.quick_wins"))
quick_win = QuickWin(
title=quick_win_form.title.data.strip(),
effort=quick_win_form.effort.data,
active=True,
created_by_user_id=current_user.id,
sort_order=(db.session.query(func.max(QuickWin.sort_order)).scalar() or -1) + 1,
)
db.session.add(quick_win)
db.session.commit()
flash(f"Quick-Win „{quick_win.title}“ wurde gespeichert.", "success")
return redirect(url_for("settings.quick_wins"))
return render_template(
"settings/quick_wins.html",
quick_win_form=quick_win_form,
quick_task_config_form=quick_task_config_form,
quick_task_config=quick_task_config,
quick_wins=QuickWin.query.filter_by(active=True).order_by(QuickWin.sort_order.asc(), QuickWin.id.asc()).all(),
settings_tabs=_settings_tabs(),
active_settings_tab="settings.quick_wins",
)
@bp.route("/calendar-feed/regenerate", methods=["POST"])
@login_required
def regenerate_calendar_feed():
current_user.calendar_feed_token = None
current_user.ensure_calendar_feed_token()
db.session.commit()
flash("Dein persönlicher Kalender-Link wurde neu erzeugt.", "success")
return redirect(url_for("settings.index"))
@bp.route("/badges") @bp.route("/badges")
@login_required @login_required
def badges(): def badges():
@@ -162,9 +213,93 @@ def update_quick_task_config():
set_setting_int("quick_task_points_medium", form.medium_points.data) set_setting_int("quick_task_points_medium", form.medium_points.data)
set_setting_str("quick_task_label_heavy", form.heavy_label.data) set_setting_str("quick_task_label_heavy", form.heavy_label.data)
set_setting_int("quick_task_points_heavy", form.heavy_points.data) set_setting_int("quick_task_points_heavy", form.heavy_points.data)
set_setting_str("quick_task_label_super_heavy", form.super_heavy_label.data)
set_setting_int("quick_task_points_super_heavy", form.super_heavy_points.data)
db.session.commit() db.session.commit()
flash("Schnellaufgaben-Aufwand und Punkte wurden aktualisiert.", "success") flash("Quick-Win-Aufwand und Punkte wurden aktualisiert.", "success")
return redirect(url_for("settings.index")) return redirect(url_for("settings.quick_wins"))
@bp.route("/quick-wins/<int:quick_win_id>/delete", methods=["POST"])
@login_required
def delete_quick_win(quick_win_id: int):
quick_win = QuickWin.query.get_or_404(quick_win_id)
if quick_win.created_by_user_id != current_user.id and not current_user.is_admin:
flash("Diesen Quick-Win kannst du nicht entfernen.", "error")
return redirect(url_for("settings.quick_wins"))
quick_win.active = False
db.session.commit()
flash("Quick-Win wurde ausgeblendet.", "success")
return redirect(url_for("settings.quick_wins"))
@bp.route("/quick-wins/<int:quick_win_id>/update", methods=["POST"])
@login_required
def update_quick_win(quick_win_id: int):
quick_win = QuickWin.query.get_or_404(quick_win_id)
quick_task_config = get_quick_task_config()
title = (request.form.get("title") or "").strip()
effort = request.form.get("effort") or ""
if len(title) < 2:
flash("Quick-Wins brauchen einen Titel mit mindestens 2 Zeichen.", "error")
return redirect(url_for("settings.quick_wins"))
if len(title) > 160:
flash("Quick-Win-Titel dürfen maximal 160 Zeichen lang sein.", "error")
return redirect(url_for("settings.quick_wins"))
if effort not in quick_task_config:
flash("Bitte wähle einen gültigen Aufwand.", "error")
return redirect(url_for("settings.quick_wins"))
duplicate = (
QuickWin.query.filter(QuickWin.id != quick_win.id, QuickWin.title == title, QuickWin.active.is_(True))
.first()
)
if duplicate:
flash("Diesen Quick-Win gibt es bereits.", "error")
return redirect(url_for("settings.quick_wins"))
quick_win.title = title
quick_win.effort = effort
db.session.commit()
flash(f"Quick-Win „{quick_win.title}“ wurde aktualisiert.", "success")
return redirect(url_for("settings.quick_wins"))
@bp.route("/quick-wins/reorder", methods=["POST"])
@login_required
@csrf.exempt
def reorder_quick_wins():
payload = request.get_json(silent=True)
if payload is not None:
raw_ids = payload.get("ids", [])
else:
raw_ids = request.form.get("ids", "").split(",")
ordered_ids = [int(item) for item in raw_ids if str(item).isdigit()]
quick_wins = QuickWin.query.filter_by(active=True).all()
quick_wins_by_id = {quick_win.id: quick_win for quick_win in quick_wins}
for position, quick_win_id in enumerate(ordered_ids):
quick_win = quick_wins_by_id.get(quick_win_id)
if quick_win:
quick_win.sort_order = position
used_ids = set(ordered_ids)
remaining = [quick_win for quick_win in quick_wins if quick_win.id not in used_ids]
for offset, quick_win in enumerate(sorted(remaining, key=lambda item: (item.sort_order, item.id)), start=len(ordered_ids)):
quick_win.sort_order = offset
db.session.commit()
if payload is not None:
return jsonify({"ok": True})
flash("Die Quick-Win-Reihenfolge wurde gespeichert.", "success")
return redirect(url_for("settings.quick_wins"))
@bp.route("/users/<int:user_id>/toggle-admin", methods=["POST"]) @bp.route("/users/<int:user_id>/toggle-admin", methods=["POST"])
@@ -207,7 +342,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()
+125 -24
View File
@@ -6,15 +6,17 @@ from datetime import date
from flask import Blueprint, flash, redirect, render_template, request, url_for from flask 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 TaskInstance, User from ..models import QuickWin, TaskInstance, User
from ..services.app_settings import get_quick_task_config from ..services.app_settings import get_quick_task_config
from ..services.dates import month_label, today_local from ..services.dates import month_label, today_local
from ..services.tasks import ( 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,27 +29,64 @@ def _user_choices() -> list[tuple[int, str]]:
return [(user.id, user.name) for user in User.query.order_by(User.name.asc()).all()] return [(user.id, user.name) for user in User.query.order_by(User.name.asc()).all()]
def _secondary_user_choices() -> list[tuple[int, str]]:
return [(0, "Keine zweite Person")] + _user_choices()
def _my_tasks_soon_priority(task: TaskInstance) -> int:
order = {
"due_tomorrow": 0,
"due_day_after_tomorrow": 1,
"due_today": 2,
}
return order.get(task.status, 99)
@bp.route("/my-tasks") @bp.route("/my-tasks")
@login_required @login_required
def my_tasks(): def my_tasks():
tasks = ( tasks = (
TaskInstance.query.filter_by(assigned_user_id=current_user.id) TaskInstance.query.filter(
or_(
TaskInstance.assigned_user_id == current_user.id,
TaskInstance.assigned_user_secondary_id == current_user.id,
)
)
.order_by(TaskInstance.completed_at.is_(None).desc(), TaskInstance.due_date.asc()) .order_by(TaskInstance.completed_at.is_(None).desc(), TaskInstance.due_date.asc())
.all() .all()
) )
refresh_task_statuses(tasks) refresh_task_statuses(tasks)
sections = {"open": [], "soon": [], "overdue": [], "completed": []} sections = {
"open": [],
"due_today": [],
"due_tomorrow": [],
"due_day_after_tomorrow": [],
"overdue": [],
"completed": [],
}
for task in tasks: for task in tasks:
sections[task.status].append(task) sections[task.status].append(task)
soon_tasks = sorted(
sections["due_tomorrow"] + sections["due_day_after_tomorrow"] + sections["due_today"],
key=lambda task: (_my_tasks_soon_priority(task), task.due_date, task.title.lower()),
)
completed_count = len(sections["completed"]) completed_count = len(sections["completed"])
active_count = len(sections["open"]) + len(sections["soon"]) + len(sections["overdue"]) active_count = (
len(sections["open"])
+ len(sections["due_today"])
+ len(sections["due_tomorrow"])
+ len(sections["due_day_after_tomorrow"])
+ len(sections["overdue"])
)
completion_ratio = 0 if completed_count + active_count == 0 else round(completed_count / (completed_count + active_count) * 100) completion_ratio = 0 if completed_count + active_count == 0 else round(completed_count / (completed_count + active_count) * 100)
return render_template( return render_template(
"tasks/my_tasks.html", "tasks/my_tasks.html",
sections=sections, sections=sections,
soon_tasks=soon_tasks,
completion_ratio=completion_ratio, completion_ratio=completion_ratio,
today=today_local(), today=today_local(),
) )
@@ -63,9 +102,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,7 +127,17 @@ 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":
tasks = [task for task in tasks if task.status in {"due_today", "due_tomorrow", "due_day_after_tomorrow"}]
else:
status_map = {
"completed": "completed",
"overdue": "overdue",
"open": "open",
"today": "due_today",
"tomorrow": "due_tomorrow",
"day_after_tomorrow": "due_day_after_tomorrow",
}
selected = status_map.get(status) selected = status_map.get(status)
if selected: if selected:
tasks = [task for task in tasks if task.status == selected] tasks = [task for task in tasks if task.status == selected]
@@ -96,6 +155,7 @@ def all_tasks():
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()
@@ -109,26 +169,45 @@ def create():
@bp.route("/tasks/quick", methods=["POST"]) @bp.route("/tasks/quick", methods=["POST"])
@login_required @login_required
def quick_create(): def quick_create():
form = QuickTaskForm(prefix="quick")
config = get_quick_task_config() config = get_quick_task_config()
form.effort.choices = [ created_titles: list[str] = []
(key, values["label"])
for key, values in config.items()
]
if not form.validate_on_submit(): selected_ids = request.form.getlist("quick_win_ids")
if selected_ids:
quick_wins = QuickWin.query.filter(QuickWin.id.in_(selected_ids), QuickWin.active.is_(True)).order_by(QuickWin.id.asc()).all()
for quick_win in quick_wins:
task = create_quick_task(quick_win.title, quick_win.effort, current_user, description="Quick-Win")
complete_task(task, current_user.id)
created_titles.append(task.title)
if request.form.get("include_custom") == "1":
form = QuickTaskForm(prefix="quick")
form.effort.choices = [(key, values["label"]) for key, values in config.items()]
custom_title = (form.title.data or "").strip()
extra_errors: list[str] = []
if not custom_title:
extra_errors.append("Bitte gib für „Sonstiges“ einen Titel ein.")
if not form.effort.data or form.effort.data not in config:
extra_errors.append("Bitte wähle für „Sonstiges“ einen Aufwand aus.")
if not form.validate_on_submit() or extra_errors:
for field_errors in form.errors.values(): for field_errors in form.errors.values():
for error in field_errors: for error in field_errors:
flash(error, "error") flash(error, "error")
for error in extra_errors:
flash(error, "error")
return redirect(request.referrer or url_for("tasks.my_tasks"))
task = create_quick_task(custom_title, form.effort.data, current_user, description="Quick-Win")
complete_task(task, current_user.id)
created_titles.append(task.title)
if not created_titles:
flash("Bitte wähle mindestens einen Quick-Win aus.", "error")
return redirect(request.referrer or url_for("tasks.my_tasks")) return redirect(request.referrer or url_for("tasks.my_tasks"))
quick_action = request.form.get("quick_action", "save") if len(created_titles) == 1:
task = create_quick_task(form.title.data, form.effort.data, current_user) flash(f"Quick-Win „{created_titles[0]}“ wurde als erledigt gespeichert.", "success")
if quick_action == "complete":
complete_task(task, current_user.id)
flash(f"Schnellaufgabe „{task.title}“ wurde direkt als erledigt gespeichert.", "success")
else: else:
flash(f"Schnellaufgabe „{task.title}“ wurde für dich angelegt.", "success") flash(f"{len(created_titles)} Quick-Wins wurden als erledigt gespeichert.", "success")
return redirect(request.referrer or url_for("tasks.my_tasks")) return redirect(request.referrer or url_for("tasks.my_tasks"))
@@ -138,12 +217,15 @@ def edit(task_id: int):
task = TaskInstance.query.get_or_404(task_id) 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
@@ -152,9 +234,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"])
@@ -167,8 +261,15 @@ def complete(task_id: int):
return redirect(request.referrer or url_for("tasks.my_tasks")) return redirect(request.referrer or 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
complete_task(task, completed_by_id) complete_task(task, completed_by_id)
flash("Punkte verbucht. Gute Arbeit.", "success") flash("Punkte verbucht. Gute Arbeit.", "success")
+3
View File
@@ -9,10 +9,12 @@ QUICK_TASK_DEFAULTS = {
"quick_task_label_normal": "Normal", "quick_task_label_normal": "Normal",
"quick_task_label_medium": "Dauert etwas", "quick_task_label_medium": "Dauert etwas",
"quick_task_label_heavy": "Aufwendig", "quick_task_label_heavy": "Aufwendig",
"quick_task_label_super_heavy": "Super aufwendig",
"quick_task_points_fast": 4, "quick_task_points_fast": 4,
"quick_task_points_normal": 8, "quick_task_points_normal": 8,
"quick_task_points_medium": 12, "quick_task_points_medium": 12,
"quick_task_points_heavy": 18, "quick_task_points_heavy": 18,
"quick_task_points_super_heavy": 28,
} }
QUICK_TASK_EFFORTS = [ QUICK_TASK_EFFORTS = [
@@ -20,6 +22,7 @@ QUICK_TASK_EFFORTS = [
("normal", "quick_task_label_normal", "quick_task_points_normal"), ("normal", "quick_task_label_normal", "quick_task_points_normal"),
("medium", "quick_task_label_medium", "quick_task_points_medium"), ("medium", "quick_task_label_medium", "quick_task_points_medium"),
("heavy", "quick_task_label_heavy", "quick_task_points_heavy"), ("heavy", "quick_task_label_heavy", "quick_task_points_heavy"),
("super_heavy", "quick_task_label_super_heavy", "quick_task_points_super_heavy"),
] ]
+5 -2
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
@@ -92,7 +92,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)
@@ -127,7 +127,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(
or_(
TaskInstance.assigned_user_id == user_id, 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()
+77 -6
View File
@@ -5,7 +5,7 @@ import os
from sqlalchemy import inspect, text from sqlalchemy import inspect, text
from ..extensions import db from ..extensions import db
from ..models import User from ..models import QuickWin, User
from .app_settings import ensure_app_settings from .app_settings import ensure_app_settings
@@ -17,20 +17,91 @@ def ensure_schema_and_admins() -> None:
db.session.execute(text("ALTER TABLE user ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT 0")) db.session.execute(text("ALTER TABLE user ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT 0"))
db.session.commit() db.session.commit()
if "calendar_feed_token" not in column_names:
db.session.execute(text("ALTER TABLE user ADD COLUMN calendar_feed_token VARCHAR(255)"))
db.session.commit()
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()
if users_without_feed:
for user in users_without_feed:
user.ensure_calendar_feed_token()
db.session.commit()
admin_exists = User.query.filter_by(is_admin=True).first() admin_exists = User.query.filter_by(is_admin=True).first()
if admin_exists: default_quick_win_user = admin_exists
return
preferred_admin_email = os.getenv("DEFAULT_ADMIN_EMAIL", "mail@hnz.io").lower().strip() preferred_admin_email = os.getenv("DEFAULT_ADMIN_EMAIL", "mail@hnz.io").lower().strip()
preferred_user = User.query.filter(User.email.ilike(preferred_admin_email)).first() preferred_user = User.query.filter(User.email.ilike(preferred_admin_email)).first()
if preferred_user: if preferred_user and not admin_exists:
preferred_user.is_admin = True preferred_user.is_admin = True
db.session.commit() db.session.commit()
return default_quick_win_user = preferred_user
first_user = User.query.order_by(User.id.asc()).first() first_user = User.query.order_by(User.id.asc()).first()
if first_user: if first_user and not User.query.filter_by(is_admin=True).first():
first_user.is_admin = True first_user.is_admin = True
db.session.commit() db.session.commit()
default_quick_win_user = first_user
_ensure_default_quick_wins(default_quick_win_user or User.query.order_by(User.id.asc()).first())
_ensure_quick_win_ordering()
def _ensure_default_quick_wins(default_user: User | None) -> None:
if not default_user:
return
defaults = [
("Schnell Aufräumen", "fast"),
("Spülmaschine ausräumen", "normal"),
("Bett machen", "normal"),
("Lüften", "fast"),
("Wäsche zusammenlegen", "medium"),
]
existing_titles = {quick_win.title for quick_win in QuickWin.query.all()}
created = False
next_sort_order = QuickWin.query.count()
for title, effort in defaults:
if title not in existing_titles:
db.session.add(
QuickWin(
title=title,
effort=effort,
active=True,
created_by_user_id=default_user.id,
sort_order=next_sort_order,
)
)
next_sort_order += 1
created = True
if created:
db.session.commit()
def _ensure_quick_win_ordering() -> None:
quick_wins = QuickWin.query.order_by(QuickWin.sort_order.asc(), QuickWin.id.asc()).all()
dirty = False
for index, quick_win in enumerate(quick_wins):
if quick_win.sort_order != index:
quick_win.sort_order = index
dirty = True
if dirty:
db.session.commit()
+67
View File
@@ -0,0 +1,67 @@
from __future__ import annotations
from datetime import UTC, datetime, time, timedelta
from sqlalchemy import or_
from ..models import TaskInstance, User
def _ics_escape(value: str | None) -> str:
text = (value or "").replace("\\", "\\\\")
text = text.replace(";", "\\;").replace(",", "\\,")
text = text.replace("\r\n", "\\n").replace("\n", "\\n").replace("\r", "\\n")
return text
def _format_date(value) -> str:
return value.strftime("%Y%m%d")
def _format_timestamp(value: datetime | None) -> str:
timestamp = value or datetime.now(UTC).replace(tzinfo=None)
return timestamp.strftime("%Y%m%dT%H%M%SZ")
def build_calendar_feed(user: User, base_url: str) -> str:
tasks = (
TaskInstance.query.filter(
or_(
TaskInstance.assigned_user_id == user.id,
TaskInstance.assigned_user_secondary_id == user.id,
)
)
.order_by(TaskInstance.due_date.asc(), TaskInstance.id.asc())
.all()
)
lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//hnz.io//Putzliga//DE",
"CALSCALE:GREGORIAN",
"METHOD:PUBLISH",
f"X-WR-CALNAME:{_ics_escape(f'Putzliga - {user.name}')}",
f"X-WR-CALDESC:{_ics_escape('Persoenlicher Aufgabenfeed aus Putzliga')}",
]
for task in tasks:
due_end = task.due_date + timedelta(days=1)
description = _ics_escape(task.description)
lines.extend(
[
"BEGIN:VEVENT",
f"UID:taskinstance-{task.id}@putzliga",
f"DTSTAMP:{_format_timestamp(task.updated_at)}",
f"LAST-MODIFIED:{_format_timestamp(task.updated_at)}",
f"SUMMARY:{_ics_escape(task.title)}",
f"DESCRIPTION:{description}",
f"DTSTART;VALUE=DATE:{_format_date(task.due_date)}",
f"DTEND;VALUE=DATE:{_format_date(due_end)}",
f"URL:{_ics_escape(base_url)}",
"END:VEVENT",
]
)
lines.append("END:VCALENDAR")
return "\r\n".join(lines) + "\r\n"
+35 -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)
@@ -59,6 +67,7 @@ def update_template_and_instance(task: TaskInstance, form) -> TaskInstance:
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.default_assigned_user_id = form.assigned_user_id.data
template.default_assigned_user_secondary_id = form.assigned_user_secondary_id.data or None
template.recurrence_interval_unit = form.recurrence_interval_unit.data template.recurrence_interval_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
@@ -68,7 +77,8 @@ def update_template_and_instance(task: TaskInstance, form) -> TaskInstance:
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 = template.default_assigned_user_id
task.points_awarded = template.default_points task.assigned_user_secondary_id = template.default_assigned_user_secondary_id
task.points_awarded = effective_points(template.default_points, template.default_assigned_user_secondary_id)
task.due_date = form.due_date.data 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 +118,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())
@@ -129,12 +143,12 @@ def complete_task(task: TaskInstance, completed_by_user_id: int) -> TaskInstance
return task return task
def create_quick_task(title: str, effort: str, creator: User) -> TaskInstance: def create_quick_task(title: str, effort: str, creator: User, description: str = "Quick-Win") -> TaskInstance:
config = get_quick_task_config() config = get_quick_task_config()
effort_config = config[effort] effort_config = config[effort]
template = TaskTemplate( template = TaskTemplate(
title=title.strip(), title=title.strip(),
description="Schnellaufgabe", description=description,
default_points=effort_config["points"], default_points=effort_config["points"],
default_assigned_user_id=creator.id, default_assigned_user_id=creator.id,
recurrence_interval_value=None, recurrence_interval_value=None,
@@ -147,8 +161,9 @@ def create_quick_task(title: str, effort: str, creator: User) -> TaskInstance:
task = TaskInstance( task = TaskInstance(
task_template_id=template.id, task_template_id=template.id,
title=template.title, title=template.title,
description="Schnellaufgabe", 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 +172,17 @@ def create_quick_task(title: str, effort: str, creator: User) -> TaskInstance:
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()
+360 -12
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 {
@@ -188,19 +195,32 @@ p {
.app-footer { .app-footer {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: space-between;
gap: 10px; gap: 14px;
flex-wrap: wrap;
padding: 18px 0 8px; padding: 18px 0 8px;
color: var(--muted); color: var(--muted);
font-size: 0.88rem; font-size: 0.88rem;
text-align: center; text-align: left;
}
.app-footer__left,
.app-footer__right {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.app-footer__right {
margin-left: auto;
} }
.app-footer a { .app-footer a {
color: inherit; color: inherit;
} }
.app-footer span { .app-footer__left span {
opacity: 0.7; opacity: 0.7;
} }
@@ -374,6 +394,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,
@@ -410,8 +433,37 @@ p {
padding: 20px; padding: 20px;
} }
.task-card__top {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: flex-start;
gap: 12px;
}
.task-card__title-block {
display: grid;
gap: 10px;
}
.task-card__top > div:first-child,
.task-card__title-block {
min-width: 0;
}
.task-card__title-block .chip-row {
gap: 8px;
}
.task-card__top .icon-button {
align-self: flex-start;
margin-top: 2px;
}
.task-card h3 { .task-card h3 {
font-size: 1.35rem; font-size: 1.35rem;
line-height: 1.08;
overflow-wrap: break-word;
hyphens: auto;
} }
.task-card--compact { .task-card--compact {
@@ -460,6 +512,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;
@@ -501,6 +556,16 @@ p {
color: var(--muted); color: var(--muted);
} }
.task-assignee__avatars {
display: inline-flex;
align-items: center;
}
.task-assignee__avatars .avatar + .avatar {
margin-left: -10px;
border: 2px solid var(--surface-strong);
}
.avatar { .avatar {
width: 34px; width: 34px;
height: 34px; height: 34px;
@@ -565,6 +630,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%;
} }
@@ -857,32 +927,37 @@ p {
} }
.calendar-task__title { .calendar-task__title {
display: -webkit-box; display: block;
overflow: hidden; overflow: visible;
min-width: 0; min-width: 0;
font-size: 0.96rem; font-size: 0.96rem;
line-height: 1.15; line-height: 1.15;
font-weight: 600; font-weight: 600;
color: var(--text); color: var(--text);
word-break: break-word; word-break: normal;
-webkit-line-clamp: 2; overflow-wrap: break-word;
-webkit-box-orient: vertical; hyphens: manual;
} }
.calendar-task__person { .calendar-task__person {
display: block; display: block;
overflow: hidden; overflow: visible;
min-width: 0; min-width: 0;
font-size: 0.74rem; font-size: 0.74rem;
line-height: 1.2; line-height: 1.2;
white-space: nowrap; white-space: normal;
text-overflow: ellipsis; word-break: normal;
overflow-wrap: break-word;
hyphens: manual;
} }
.calendar-task--open { .calendar-task--open {
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;
} }
@@ -1030,6 +1105,272 @@ p {
margin: 0; margin: 0;
} }
.quick-win-list {
display: grid;
gap: 12px;
}
.quick-win-list__toolbar {
display: flex;
justify-content: flex-end;
margin-bottom: 14px;
}
.quick-win-manage-card {
display: grid;
gap: 12px;
padding: 18px;
border-radius: var(--radius-md);
background: var(--surface-soft);
border: 1px solid rgba(132, 152, 190, 0.22);
}
.quick-win-manage-card {
align-items: stretch;
cursor: move;
}
.quick-win-manage-card.is-dragging {
opacity: 0.72;
}
.quick-win-manage-form {
display: grid;
gap: 12px;
padding-top: 4px;
}
.quick-win-manage-form[hidden] {
display: none !important;
}
.quick-win-manage-card__summary {
display: grid;
gap: 10px;
}
.quick-win-manage-card__title strong {
display: block;
font-size: 1.12rem;
line-height: 1.25;
}
.quick-win-manage-card__drag {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--muted);
font-size: 0.9rem;
}
.quick-win-manage-card__actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.quick-win-grid {
display: grid;
gap: 12px;
}
.quick-win-dialog-header {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 14px;
align-items: start;
}
.quick-win-dialog-header__badge {
width: 52px;
height: 52px;
border-radius: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, rgba(37, 99, 235, 0.18), rgba(52, 211, 153, 0.24));
border: 1px solid rgba(132, 152, 190, 0.22);
color: var(--primary-strong);
box-shadow: 0 16px 30px rgba(37, 99, 235, 0.16);
}
.quick-win-dialog-header__badge svg {
width: 24px;
height: 24px;
}
.quick-win-manage-card p {
color: var(--muted);
}
.quick-win-tag-grid {
display: flex;
flex-wrap: wrap;
gap: 12px;
width: 100%;
min-width: 0;
overflow-x: hidden;
}
.quick-win-tag {
position: relative;
min-width: 0;
flex: 0 0 auto;
max-width: 100%;
}
.quick-win-tag input {
position: absolute;
inset: 0;
opacity: 0;
pointer-events: none;
}
.quick-win-tag span {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 0;
max-width: 100%;
padding: 8px 13px;
border-radius: 999px;
border: 1px solid rgba(132, 152, 190, 0.22);
background: var(--surface-soft);
color: var(--text);
font-weight: 700;
font-size: 0.9rem;
text-align: center;
line-height: 1.1;
white-space: nowrap;
transition: transform 0.18s ease, border-color 0.18s ease, background 0.18s ease, box-shadow 0.18s ease, color 0.18s ease;
cursor: pointer;
}
.quick-win-tag input:checked + span {
background: linear-gradient(135deg, rgba(37, 99, 235, 0.18), rgba(52, 211, 153, 0.16));
border-color: rgba(37, 99, 235, 0.34);
color: var(--primary-strong);
box-shadow: 0 16px 28px rgba(37, 99, 235, 0.16);
transform: translateY(-1px);
}
.quick-win-tag--custom span {
border-style: dashed;
}
.quick-win-tag--custom {
flex-basis: 100%;
}
.quick-win-tag--custom span {
width: fit-content;
}
.quick-win-custom-fields {
display: grid;
gap: 14px;
}
.quick-win-custom-fields[hidden] {
display: none !important;
}
.dialog-actions--stack {
display: grid;
gap: 12px;
}
@media (max-width: 640px) {
.task-card {
gap: 14px;
padding: 18px;
}
.task-card__top {
gap: 10px;
}
.task-card__title-block {
gap: 8px;
}
.task-card__title-block .chip-row {
gap: 6px;
}
.task-card__top .icon-button {
width: 44px;
min-width: 44px;
height: 44px;
border-radius: 14px;
margin-top: 0;
}
.task-card h3 {
font-size: 1.15rem;
}
.form-panel h2 {
font-size: 1.85rem;
line-height: 1.08;
}
.complete-dialog__surface {
width: min(100vw - 16px, 410px);
max-width: calc(100vw - 16px);
padding: 18px 16px;
gap: 14px;
overflow-x: hidden;
}
.quick-win-dialog-header {
grid-template-columns: 1fr;
gap: 10px;
}
.quick-win-dialog-header__badge {
width: 42px;
height: 42px;
border-radius: 14px;
}
.quick-win-dialog-header__badge svg {
width: 19px;
height: 19px;
}
.quick-win-tag-grid {
gap: 8px;
}
.quick-win-tag {
max-width: 100%;
flex: 0 1 auto;
}
.quick-win-tag span {
width: auto;
max-width: 100%;
padding: 6px 11px;
font-size: 0.84rem;
white-space: nowrap;
text-align: center;
justify-content: center;
}
.quick-win-tag--custom span {
width: auto;
}
.quick-win-list__toolbar {
justify-content: stretch;
}
.quick-win-list__toolbar .button {
width: 100%;
}
}
.push-box__state { .push-box__state {
align-items: flex-start; align-items: flex-start;
padding: 16px; padding: 16px;
@@ -1135,6 +1476,10 @@ p {
.complete-dialog__surface--task { .complete-dialog__surface--task {
width: min(520px, calc(100vw - 24px)); width: min(520px, calc(100vw - 24px));
max-width: calc(100vw - 24px);
overflow-x: hidden;
overscroll-behavior-x: contain;
touch-action: pan-y;
} }
.choice-grid { .choice-grid {
@@ -1239,6 +1584,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;
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path opacity=".4" fill="currentColor" d="M376 512L448 544L480 616L512 544L584 512L512 480L480 408L448 480L376 512zM408 128L480 160L512 232L544 160L616 128L544 96L512 24L480 96L408 128z"/><path fill="currentColor" d="M160 256L224 112L288 256L432 320L288 384L224 528L160 384L16 320L160 256z"/></svg>

After

Width:  |  Height:  |  Size: 528 B

+177 -7
View File
@@ -3,28 +3,70 @@
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");
const quickTaskClose = document.getElementById("quickTaskClose"); const quickTaskClose = document.getElementById("quickTaskClose");
const quickWinsSubmit = document.getElementById("quickWinsSubmit");
const quickWinInputs = document.querySelectorAll("[data-quick-win-input]");
const quickWinCustomToggle = document.querySelector("[data-quick-win-custom-toggle]");
const quickWinCustomFields = document.getElementById("quickWinCustomFields");
const quickWinTitle = document.getElementById("quick-title");
const quickWinEffort = document.getElementById("quick-effort");
const currentUserId = document.body.dataset.currentUserId;
const currentUserName = document.body.dataset.currentUserName;
const quickWinSortList = document.querySelector("[data-quick-win-sort-list]");
const quickWinSortIds = document.getElementById("quickWinSortIds");
const quickWinSortSave = document.getElementById("quickWinSortSave");
const quickWinToggleButtons = document.querySelectorAll("[data-quick-win-toggle]");
let draggedQuickWin = null;
let quickWinSortDirty = false;
function buildCompletionOptions(button) {
const options = [];
const assignedPairs = [
[button.dataset.assignedPrimaryId, button.dataset.assignedPrimaryName],
[button.dataset.assignedSecondaryId, button.dataset.assignedSecondaryName],
];
assignedPairs.forEach(([id, label]) => {
if (id && label && !options.some((option) => option.value === id)) {
options.push({ value: id, label });
}
});
if (currentUserId && currentUserName && !options.some((option) => option.value === currentUserId)) {
options.push({ value: currentUserId, label: "Ich" });
}
return options;
}
document.querySelectorAll("[data-complete-action]").forEach((button) => { 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";
choiceButton.className = index === 0 ? "button button--secondary" : "button";
choiceButton.dataset.completeChoice = option.value;
choiceButton.textContent = option.label;
choiceButton.addEventListener("click", () => {
dialogChoice.value = option.value;
dialog.close(); dialog.close();
dialogForm.submit(); dialogForm.submit();
}); });
dialogChoices.appendChild(choiceButton);
});
dialog.showModal();
});
}); });
if (closeButton && dialog) { if (closeButton && dialog) {
@@ -39,6 +81,134 @@
quickTaskClose.addEventListener("click", () => quickTaskDialog.close()); quickTaskClose.addEventListener("click", () => quickTaskDialog.close());
} }
function updateQuickWinsState() {
const selectedPresetCount = [...quickWinInputs].filter((input) => input.checked).length;
const customSelected = quickWinCustomToggle?.checked === true;
const totalCount = selectedPresetCount + (customSelected ? 1 : 0);
if (quickWinCustomFields) {
quickWinCustomFields.hidden = !customSelected;
}
if (quickWinTitle) {
quickWinTitle.disabled = !customSelected;
quickWinTitle.required = customSelected;
}
if (quickWinEffort) {
quickWinEffort.disabled = !customSelected;
quickWinEffort.required = customSelected;
}
if (quickWinsSubmit) {
quickWinsSubmit.disabled = totalCount === 0;
quickWinsSubmit.textContent = totalCount <= 1 ? "Quick-Win sichern" : "Quick Wins sichern";
}
}
quickWinInputs.forEach((input) => input.addEventListener("change", updateQuickWinsState));
if (quickWinCustomToggle) {
quickWinCustomToggle.addEventListener("change", updateQuickWinsState);
}
updateQuickWinsState();
if (quickTaskDialog) {
quickTaskDialog.addEventListener("close", () => {
const quickWinsForm = document.getElementById("quickWinsForm");
if (!quickWinsForm) {
return;
}
quickWinsForm.reset();
updateQuickWinsState();
});
}
function syncQuickWinSortIds() {
if (!quickWinSortList || !quickWinSortIds) {
return;
}
const ids = [...quickWinSortList.querySelectorAll("[data-quick-win-sort-item]")]
.map((item) => item.dataset.quickWinSortItem)
.filter(Boolean);
quickWinSortIds.value = ids.join(",");
}
function setQuickWinSortDirty(isDirty) {
quickWinSortDirty = isDirty;
if (quickWinSortSave) {
quickWinSortSave.disabled = !isDirty;
}
}
if (quickWinSortList) {
syncQuickWinSortIds();
setQuickWinSortDirty(false);
quickWinSortList.querySelectorAll("[data-quick-win-sort-item]").forEach((item) => {
item.addEventListener("dragstart", () => {
draggedQuickWin = item;
item.classList.add("is-dragging");
});
item.addEventListener("dragend", () => {
item.classList.remove("is-dragging");
draggedQuickWin = null;
});
item.addEventListener("dragover", (event) => {
event.preventDefault();
});
item.addEventListener("drop", async (event) => {
event.preventDefault();
if (!draggedQuickWin || draggedQuickWin === item) {
return;
}
const items = [...quickWinSortList.querySelectorAll("[data-quick-win-sort-item]")];
const draggedIndex = items.indexOf(draggedQuickWin);
const targetIndex = items.indexOf(item);
if (draggedIndex < targetIndex) {
item.after(draggedQuickWin);
} else {
item.before(draggedQuickWin);
}
syncQuickWinSortIds();
setQuickWinSortDirty(true);
});
});
}
quickWinToggleButtons.forEach((button) => {
button.addEventListener("click", () => {
const targetId = button.dataset.target;
if (!targetId) {
return;
}
const target = document.getElementById(targetId);
if (!target) {
return;
}
const willOpen = target.hidden;
document.querySelectorAll("[data-quick-win-edit]").forEach((editForm) => {
editForm.hidden = true;
});
quickWinToggleButtons.forEach((toggle) => {
toggle.setAttribute("aria-expanded", "false");
toggle.textContent = "Bearbeiten";
});
if (willOpen) {
target.hidden = false;
button.setAttribute("aria-expanded", "true");
button.textContent = "Schließen";
}
});
});
const pushButton = document.getElementById("pushToggle"); const 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;
+41 -17
View File
@@ -16,7 +16,11 @@
<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 '' }}"
>
{% 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 %}
@@ -88,9 +92,14 @@
</main> </main>
<footer class="app-footer"> <footer class="app-footer">
<div class="app-footer__left">
<a href="https://git.hnz.io/hnzio/putzliga/releases" target="_blank" rel="noreferrer">Version {{ app_version }}</a> <a href="https://git.hnz.io/hnzio/putzliga/releases" target="_blank" rel="noreferrer">Version {{ app_version }}</a>
<span>·</span> <span>·</span>
<a href="https://hnz.io" target="_blank" rel="noreferrer">(c) 2026 @hnz.io</a> <span>Made with ❤️ in Göttingen.</span>
</div>
<div class="app-footer__right">
<a href="https://hnz.io" target="_blank" rel="noreferrer">© 2026 @ hnz.io</a>
</div>
</footer> </footer>
</div> </div>
</div> </div>
@@ -105,7 +114,7 @@
{% endfor %} {% endfor %}
</nav> </nav>
<button type="button" class="fab-quick-task" id="quickTaskOpen" aria-label="Schnellaufgabe anlegen"> <button type="button" class="fab-quick-task" id="quickTaskOpen" aria-label="Quick-Wins öffnen">
{{ nav_icon('plus') }} {{ nav_icon('plus') }}
</button> </button>
@@ -114,32 +123,47 @@
<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>
<dialog class="complete-dialog" id="quickTaskDialog"> <dialog class="complete-dialog" id="quickTaskDialog">
<form method="post" action="{{ url_for('tasks.quick_create') }}" class="complete-dialog__surface complete-dialog__surface--task"> <form method="post" action="{{ url_for('tasks.quick_create') }}" class="complete-dialog__surface complete-dialog__surface--task" id="quickWinsForm">
{{ quick_task_form.hidden_tag() }} {{ quick_task_form.hidden_tag() }}
<p class="eyebrow">Schnellaufgabe</p> <div class="quick-win-dialog-header">
<h2>Direkt etwas für dich anlegen</h2> <span class="quick-win-dialog-header__badge" aria-hidden="true">{{ icon_svg('quick-wins-sparkles')|safe }}</span>
<p class="muted">Titel und Aufwand reichen. Die Aufgabe wird automatisch dir zugewiesen und auf heute gesetzt.</p> <div>
<p class="eyebrow">Quick-Wins</p>
<h2>Schnell Punkte abstauben</h2>
<p class="muted">Alle Quick-Wins sind für das ganze Team sichtbar. Für „Sonstiges“ kannst du Titel und Aufwand frei wählen.</p>
</div>
</div>
<div class="quick-win-tag-grid">
{% for quick_win in quick_wins %}
<label class="quick-win-tag" data-quick-win-tag>
<input type="checkbox" name="quick_win_ids" value="{{ quick_win.id }}" data-quick-win-input>
<span>{{ quick_win.title }}</span>
</label>
{% endfor %}
<label class="quick-win-tag quick-win-tag--custom" data-quick-win-tag>
<input type="checkbox" name="include_custom" value="1" data-quick-win-custom-toggle>
<span>Sonstiges</span>
</label>
</div>
<div class="quick-win-custom-fields" id="quickWinCustomFields" hidden>
<div class="field"> <div class="field">
{{ quick_task_form.title.label }} {{ quick_task_form.title.label }}
{{ quick_task_form.title(placeholder="Zum Beispiel: Küche kurz aufräumen") }} {{ quick_task_form.title(placeholder="Zum Beispiel: Flur kurz aufräumen", required=False) }}
</div> </div>
<div class="field"> <div class="field">
{{ quick_task_form.effort.label }} {{ quick_task_form.effort.label }}
{{ quick_task_form.effort() }} {{ quick_task_form.effort(required=False) }}
</div> </div>
<div class="dialog-actions"> </div>
<button type="submit" class="button" name="quick_action" value="save">Aufgabe speichern</button> <div class="dialog-actions dialog-actions--stack">
<button type="submit" class="button button--secondary" name="quick_action" value="complete">Aufgabe als erledigt speichern</button> <button type="submit" class="button button--wide" id="quickWinsSubmit" disabled>Quick-Win sichern</button>
<button type="button" class="button button--ghost" id="quickTaskClose">Abbrechen</button> <button type="button" class="button button--ghost button--wide" id="quickTaskClose">Abbrechen</button>
</div> </div>
</form> </form>
</dialog> </dialog>
+15 -7
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>
@@ -68,7 +68,7 @@
</div> </div>
<div> <div>
<dt>Zuständig</dt> <dt>Zuständig</dt>
<dd>{{ task.assigned_user.name if task.assigned_user else 'Unverteilt' }}</dd> <dd>{{ task.assignee_label }}</dd>
</div> </div>
<div> <div>
<dt>Rhythmus</dt> <dt>Rhythmus</dt>
@@ -84,18 +84,26 @@
<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>
+19 -68
View File
@@ -95,6 +95,25 @@
{% endif %} {% endif %}
</section> </section>
<section class="panel">
<p class="eyebrow">Kalender-Abo</p>
<h2>Persönlicher ICS-Feed</h2>
<p class="muted">Dieser Read-only-Link zeigt in externen Kalendern nur Aufgaben an, die dir zugewiesen sind. Exportiert werden nur Titel, Beschreibung und Fälligkeitsdatum.</p>
<div class="push-box">
<div class="field">
<label for="calendarFeedUrl">Persönliche Kalender-URL</label>
<input id="calendarFeedUrl" type="text" value="{{ calendar_feed_url }}" readonly>
</div>
<div class="form-actions">
<a class="button button--secondary" href="{{ calendar_feed_url }}" target="_blank" rel="noreferrer">ICS öffnen</a>
<form method="post" action="{{ url_for('settings.regenerate_calendar_feed') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="button button--ghost">Link neu erzeugen</button>
</form>
</div>
</div>
</section>
{% if current_user.is_admin %} {% if current_user.is_admin %}
<section class="panel"> <section class="panel">
<p class="eyebrow">Admin</p> <p class="eyebrow">Admin</p>
@@ -163,73 +182,5 @@
{% endfor %} {% endfor %}
</div> </div>
</section> </section>
<section class="panel">
<p class="eyebrow">Admin</p>
<h2>Schnellaufgabe-Aufwand</h2>
<p class="muted">Hier definierst du die sichtbaren Aufwand-Stufen und die dazugehörigen Punkte. Im Schnellaufgaben-Dialog wird nur die Bezeichnung angezeigt.</p>
<form method="post" action="{{ url_for('settings.update_quick_task_config') }}" class="badge-settings">
{{ quick_task_config_form.hidden_tag() }}
<div class="badge-setting-card">
<div>
<strong>Slot 1</strong>
<p class="muted">Kleine Sache für zwischendurch.</p>
</div>
<div class="field">
{{ quick_task_config_form.fast_label.label }}
{{ quick_task_config_form.fast_label() }}
</div>
<div class="field">
<label for="{{ quick_task_config_form.fast_points.id }}">Punkte</label>
{{ quick_task_config_form.fast_points() }}
</div>
</div>
<div class="badge-setting-card">
<div>
<strong>Slot 2</strong>
<p class="muted">Typische Alltagssache.</p>
</div>
<div class="field">
{{ quick_task_config_form.normal_label.label }}
{{ quick_task_config_form.normal_label() }}
</div>
<div class="field">
<label for="{{ quick_task_config_form.normal_points.id }}">Punkte</label>
{{ quick_task_config_form.normal_points() }}
</div>
</div>
<div class="badge-setting-card">
<div>
<strong>Slot 3</strong>
<p class="muted">Braucht etwas mehr Zeit oder Konzentration.</p>
</div>
<div class="field">
{{ quick_task_config_form.medium_label.label }}
{{ quick_task_config_form.medium_label() }}
</div>
<div class="field">
<label for="{{ quick_task_config_form.medium_points.id }}">Punkte</label>
{{ quick_task_config_form.medium_points() }}
</div>
</div>
<div class="badge-setting-card">
<div>
<strong>Slot 4</strong>
<p class="muted">Spürbarer Aufwand mit mehr Punkten.</p>
</div>
<div class="field">
{{ quick_task_config_form.heavy_label.label }}
{{ quick_task_config_form.heavy_label() }}
</div>
<div class="field">
<label for="{{ quick_task_config_form.heavy_points.id }}">Punkte</label>
{{ quick_task_config_form.heavy_points() }}
</div>
</div>
<div class="field field--full">
{{ quick_task_config_form.submit(class_='button button--secondary') }}
</div>
</form>
</section>
{% endif %} {% endif %}
{% endblock %} {% endblock %}
+198
View File
@@ -0,0 +1,198 @@
{% extends "base.html" %}
{% from "partials/macros.html" import nav_icon %}
{% block title %}Quick-Wins · Putzliga{% endblock %}
{% block page_title %}Quick-Wins{% endblock %}
{% block content %}
<section class="settings-tabs">
{% for endpoint, label, icon in settings_tabs %}
<a href="{{ url_for(endpoint) }}" class="settings-tab {% if active_settings_tab == endpoint %}is-active{% endif %}">
{{ nav_icon(icon) }}
<span>{{ label }}</span>
</a>
{% endfor %}
</section>
<section class="two-column">
<article class="panel">
<p class="eyebrow">Gemeinsame Vorlagen</p>
<h2>Quick-Win anlegen</h2>
<p class="muted">Alle hier angelegten Quick-Wins sind direkt für das ganze Team im Plus-Menü verfügbar.</p>
<form method="post" class="form-grid">
{{ quick_win_form.hidden_tag() }}
<div class="field">
{{ quick_win_form.title.label }}
{{ quick_win_form.title(placeholder="Zum Beispiel: Müllbeutel wechseln") }}
{% for error in quick_win_form.title.errors %}<small class="error">{{ error }}</small>{% endfor %}
</div>
<div class="field">
{{ quick_win_form.effort.label }}
{{ quick_win_form.effort() }}
{% for error in quick_win_form.effort.errors %}<small class="error">{{ error }}</small>{% endfor %}
</div>
{{ quick_win_form.submit(class_='button') }}
</form>
</article>
<article class="panel">
<p class="eyebrow">Direkt sichtbar</p>
<h2>Aktive Quick-Wins bearbeiten</h2>
<p class="muted">Per Drag & Drop kannst du die Reihenfolge festlegen, die später auch bei den Quick-Win-Chips erscheint.</p>
<form method="post" action="{{ url_for('settings.reorder_quick_wins') }}" id="quickWinSortForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="ids" id="quickWinSortIds" value="{{ quick_wins|map(attribute='id')|join(',') }}">
</form>
<div class="quick-win-list__toolbar">
<button type="submit" class="button button--secondary" id="quickWinSortSave" form="quickWinSortForm" disabled>Reihenfolge speichern</button>
</div>
<div class="quick-win-list" data-quick-win-sort-list>
{% for quick_win in quick_wins %}
<article class="quick-win-manage-card" draggable="true" data-quick-win-sort-item="{{ quick_win.id }}">
<div class="quick-win-manage-card__summary">
<div class="quick-win-manage-card__drag">
{{ nav_icon('list') }}
<span>Ziehen zum Sortieren</span>
</div>
<div class="quick-win-manage-card__title">
<strong>{{ quick_win.title }}</strong>
</div>
<div class="quick-win-manage-card__actions">
<button
type="button"
class="button button--ghost"
data-quick-win-toggle
data-target="quick-win-edit-{{ quick_win.id }}"
aria-expanded="false"
aria-controls="quick-win-edit-{{ quick_win.id }}"
>
Bearbeiten
</button>
</div>
</div>
<form
method="post"
action="{{ url_for('settings.update_quick_win', quick_win_id=quick_win.id) }}"
class="quick-win-manage-form"
id="quick-win-edit-{{ quick_win.id }}"
data-quick-win-edit
hidden
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="field">
<label for="quick-win-title-{{ quick_win.id }}">Titel</label>
<input id="quick-win-title-{{ quick_win.id }}" type="text" name="title" value="{{ quick_win.title }}" minlength="2" maxlength="160" required>
</div>
<div class="field">
<label for="quick-win-effort-{{ quick_win.id }}">Aufwand</label>
<select id="quick-win-effort-{{ quick_win.id }}" name="effort" required>
{% for effort_key, effort_values in quick_task_config.items() %}
<option value="{{ effort_key }}" {% if quick_win.effort == effort_key %}selected{% endif %}>{{ effort_values.label }}</option>
{% endfor %}
</select>
</div>
<p class="muted">Von {{ quick_win.created_by_user.name }}</p>
<div class="quick-win-manage-card__actions">
<button type="submit" class="button button--secondary">Speichern</button>
{% if quick_win.created_by_user_id == current_user.id or current_user.is_admin %}
<button
type="submit"
class="button button--ghost"
formaction="{{ url_for('settings.delete_quick_win', quick_win_id=quick_win.id) }}"
formmethod="post"
>
Entfernen
</button>
{% endif %}
</div>
</form>
</article>
{% else %}
<div class="empty-state">Noch keine Quick-Wins angelegt. Der erste steht gleich oben bereit.</div>
{% endfor %}
</div>
</article>
</section>
{% if current_user.is_admin %}
<section class="panel">
<p class="eyebrow">Admin</p>
<h2>Quick-Win-Aufwand</h2>
<p class="muted">Hier definierst du die sichtbaren Aufwand-Stufen und die dazugehörigen Punkte. Im Quick-Wins-Dialog wird nur die Bezeichnung angezeigt.</p>
<form method="post" action="{{ url_for('settings.update_quick_task_config') }}" class="badge-settings">
{{ quick_task_config_form.hidden_tag() }}
<div class="badge-setting-card">
<div>
<strong>Slot 1</strong>
<p class="muted">Kleine Sache für zwischendurch.</p>
</div>
<div class="field">
{{ quick_task_config_form.fast_label.label }}
{{ quick_task_config_form.fast_label() }}
</div>
<div class="field">
<label for="{{ quick_task_config_form.fast_points.id }}">Punkte</label>
{{ quick_task_config_form.fast_points() }}
</div>
</div>
<div class="badge-setting-card">
<div>
<strong>Slot 2</strong>
<p class="muted">Typische Alltagssache.</p>
</div>
<div class="field">
{{ quick_task_config_form.normal_label.label }}
{{ quick_task_config_form.normal_label() }}
</div>
<div class="field">
<label for="{{ quick_task_config_form.normal_points.id }}">Punkte</label>
{{ quick_task_config_form.normal_points() }}
</div>
</div>
<div class="badge-setting-card">
<div>
<strong>Slot 3</strong>
<p class="muted">Braucht etwas mehr Zeit oder Konzentration.</p>
</div>
<div class="field">
{{ quick_task_config_form.medium_label.label }}
{{ quick_task_config_form.medium_label() }}
</div>
<div class="field">
<label for="{{ quick_task_config_form.medium_points.id }}">Punkte</label>
{{ quick_task_config_form.medium_points() }}
</div>
</div>
<div class="badge-setting-card">
<div>
<strong>Slot 4</strong>
<p class="muted">Spürbarer Aufwand mit mehr Punkten.</p>
</div>
<div class="field">
{{ quick_task_config_form.heavy_label.label }}
{{ quick_task_config_form.heavy_label() }}
</div>
<div class="field">
<label for="{{ quick_task_config_form.heavy_points.id }}">Punkte</label>
{{ quick_task_config_form.heavy_points() }}
</div>
</div>
<div class="badge-setting-card">
<div>
<strong>Slot 5</strong>
<p class="muted">Extra große Aufgabe für echte Kraftakte.</p>
</div>
<div class="field">
{{ quick_task_config_form.super_heavy_label.label }}
{{ quick_task_config_form.super_heavy_label() }}
</div>
<div class="field">
<label for="{{ quick_task_config_form.super_heavy_points.id }}">Punkte</label>
{{ quick_task_config_form.super_heavy_points() }}
</div>
</div>
<div class="field field--full">
{{ quick_task_config_form.submit(class_='button button--secondary') }}
</div>
</form>
</section>
{% endif %}
{% endblock %}
+3 -1
View File
@@ -11,6 +11,9 @@
<option value="all" {% if filters.status == 'all' %}selected{% endif %}>Alle</option> <option value="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> <option value="completed" {% if filters.status == 'completed' %}selected{% endif %}>Erledigt</option>
</select> </select>
@@ -48,4 +51,3 @@
{% endfor %} {% endfor %}
</section> </section>
{% endblock %} {% endblock %}
+12 -12
View File
@@ -71,14 +71,14 @@
<div class="calendar-mobile-day__tasks"> <div class="calendar-mobile-day__tasks">
{% for task in group.tasks %} {% for task in group.tasks %}
<a href="{{ url_for('tasks.edit', task_id=task.id) }}" class="calendar-task calendar-task--{{ task.status }}"> <a href="{{ url_for('tasks.edit', task_id=task.id) }}" class="calendar-task calendar-task--{{ task.status }}">
<strong class="calendar-task__title">{{ task.title }}</strong> <strong class="calendar-task__title" lang="de">{{ task.title|hyphenate_de }}</strong>
<small class="calendar-task__person"> <small class="calendar-task__person" lang="de">
{% if task.completed_by_user %} {% if task.completed_by_user %}
{{ task.completed_by_user.name }} {{ task.completed_by_user.name|hyphenate_de }}
{% elif task.assigned_user %} {% elif task.assigned_users %}
{{ task.assigned_user.name }} {{ task.assignee_label|hyphenate_de }}
{% else %} {% else %}
Ohne Zuweisung {{ 'Ohne Zuweisung'|hyphenate_de }}
{% endif %} {% endif %}
</small> </small>
</a> </a>
@@ -104,14 +104,14 @@
<div class="calendar-day__tasks"> <div class="calendar-day__tasks">
{% for task in tasks_by_day.get(day, []) %} {% for task in tasks_by_day.get(day, []) %}
<a href="{{ url_for('tasks.edit', task_id=task.id) }}" class="calendar-task calendar-task--{{ task.status }}"> <a href="{{ url_for('tasks.edit', task_id=task.id) }}" class="calendar-task calendar-task--{{ task.status }}">
<strong class="calendar-task__title">{{ task.title }}</strong> <strong class="calendar-task__title" lang="de">{{ task.title|hyphenate_de }}</strong>
<small class="calendar-task__person"> <small class="calendar-task__person" lang="de">
{% if task.completed_by_user %} {% if task.completed_by_user %}
{{ task.completed_by_user.name }} {{ task.completed_by_user.name|hyphenate_de }}
{% elif task.assigned_user %} {% elif task.assigned_users %}
{{ task.assigned_user.name }} {{ task.assignee_label|hyphenate_de }}
{% else %} {% else %}
Ohne Zuweisung {{ 'Ohne Zuweisung'|hyphenate_de }}
{% endif %} {% endif %}
</small> </small>
</a> </a>
+3 -4
View File
@@ -46,13 +46,13 @@
<section class="stack"> <section class="stack">
<div class="section-heading"> <div class="section-heading">
<h2>Bald fällig</h2> <h2>Bald fällig</h2>
<span class="section-heading__count">{{ sections.soon|length }}</span> <span class="section-heading__count">{{ soon_tasks|length }}</span>
</div> </div>
<div class="task-grid"> <div class="task-grid">
{% for task in sections.soon %} {% for task in soon_tasks %}
{{ 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 bald fällig. Sehr stark.</div>
{% endfor %} {% endfor %}
</div> </div>
</section> </section>
@@ -85,4 +85,3 @@
</div> </div>
</section> </section>
{% endblock %} {% endblock %}
+21 -1
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 }}
@@ -32,6 +35,13 @@
{% 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>
<div class="field"> <div class="field">
{{ form.due_date.label }} {{ form.due_date.label }}
{{ form.due_date() }} {{ form.due_date() }}
@@ -57,8 +67,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 %}
+1
View File
@@ -4,5 +4,6 @@ Flask-SQLAlchemy==3.1.1
Flask-WTF==1.2.2 Flask-WTF==1.2.2
email-validator==2.2.0 email-validator==2.2.0
gunicorn==23.0.0 gunicorn==23.0.0
pyphen==0.17.2
pywebpush==2.0.3 pywebpush==2.0.3
python-dotenv==1.0.1 python-dotenv==1.0.1