4 Commits

22 changed files with 959 additions and 155 deletions
+12
View File
@@ -5,7 +5,19 @@ DATABASE_PATH=./data/putzliga.db
UPLOAD_FOLDER=./data/uploads UPLOAD_FOLDER=./data/uploads
APP_BASE_URL=http://localhost:8000 APP_BASE_URL=http://localhost:8000
APP_TIMEZONE=Europe/Berlin APP_TIMEZONE=Europe/Berlin
# Web Push
# Lokal: APP_BASE_URL auf deine lokale URL setzen
# Cloudron: APP_BASE_URL leer lassen und automatisch CLOUDRON_APP_ORIGIN nutzen
VAPID_PUBLIC_KEY= VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY= VAPID_PRIVATE_KEY=
VAPID_CLAIMS_SUBJECT=mailto:admin@example.com VAPID_CLAIMS_SUBJECT=mailto:admin@example.com
# Beispiel für Cloudron:
# SECRET_KEY=ein-langer-zufallswert
# APP_TIMEZONE=Europe/Berlin
# VAPID_PUBLIC_KEY=...
# VAPID_PRIVATE_KEY=...
# VAPID_CLAIMS_SUBJECT=mailto:mail@deine-domain.tld
GUNICORN_WORKERS=2 GUNICORN_WORKERS=2
+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": "1.0.0", "version": "0.6.5",
"manifestVersion": 2, "manifestVersion": 2,
"healthCheckPath": "/healthz", "healthCheckPath": "/healthz",
"httpPort": 8000, "httpPort": 8000,
+82 -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
@@ -296,6 +299,35 @@ https://docs.cloudron.io/docker/
- `APP_BASE_URL` kann auf Cloudron über `CLOUDRON_APP_ORIGIN` gesetzt oder daraus abgeleitet werden - `APP_BASE_URL` kann auf Cloudron über `CLOUDRON_APP_ORIGIN` gesetzt oder daraus abgeleitet werden
- Lokale Testdaten aus `data/` werden weder committed noch in das Docker-Image gepackt - Lokale Testdaten aus `data/` werden weder committed noch in das Docker-Image gepackt
### Cloudron-ENV für Push
Für Cloudron brauchst du mindestens diese Variablen:
```dotenv
SECRET_KEY=hier-einen-langen-zufallswert-eintragen
APP_TIMEZONE=Europe/Berlin
VAPID_PUBLIC_KEY=dein_public_key
VAPID_PRIVATE_KEY=dein_private_key
VAPID_CLAIMS_SUBJECT=mailto:mail@deine-domain.tld
```
Hinweise:
- `APP_BASE_URL` musst du auf Cloudron normalerweise nicht setzen, weil `config.py` automatisch `CLOUDRON_APP_ORIGIN` verwendet
- `DATA_DIR`, `DATABASE_PATH` und `UPLOAD_FOLDER` können auf den Standardwerten bleiben, solange du die App normal auf Cloudron betreibst
- `VAPID_PRIVATE_KEY` darf als einzeiliger Wert mit escaped `\\n` gespeichert werden; Putzliga wandelt das beim Start automatisch zurück
Wenn du die Werte lokal in einer `.env` testen willst, kann das zum Beispiel so aussehen:
```dotenv
SECRET_KEY=lokal-irgendein-langer-zufallswert
APP_BASE_URL=http://127.0.0.1:5000
APP_TIMEZONE=Europe/Berlin
VAPID_PUBLIC_KEY=dein_public_key
VAPID_PRIVATE_KEY=dein_private_key
VAPID_CLAIMS_SUBJECT=mailto:mail@deine-domain.tld
```
### Beispielstart in Cloudron-/Container-Umgebungen ### Beispielstart in Cloudron-/Container-Umgebungen
```bash ```bash
@@ -312,6 +344,51 @@ python scripts/generate_vapid_keys.py
Der ausgegebene `VAPID_PRIVATE_KEY` ist bereits `.env`-freundlich mit escaped Newlines formatiert. `config.py` wandelt `\\n` beim Start automatisch in echte Zeilenumbrüche zurück. Der ausgegebene `VAPID_PRIVATE_KEY` ist bereits `.env`-freundlich mit escaped Newlines formatiert. `config.py` wandelt `\\n` beim Start automatisch in echte Zeilenumbrüche zurück.
## 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
- 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
- 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
- Footer auf Versionslink, Herkunftshinweis und `hnz.io`-Verweis umgebaut
- Cloudron-Version auf `0.6.5` angehoben
### 0.6.0
- Persönlicher read-only ICS-Feed pro Nutzer für externe Kalender ergänzt
- Export enthält nur eigene zugewiesene Aufgaben mit Titel, Beschreibung und Fälligkeitsdatum
- Kalender-Link in den Optionen sichtbar gemacht und Token-Neugenerierung ergänzt
- Cloudron-Version auf `0.6.0` angehoben
### 0.5.1
- Footer mit automatischer Versionsanzeige und Links zu Releases und hnz.io
- Login-Seite bereinigt und Demo-Login-Hinweis entfernt
- Mobile Abstände rund um die feste Bottom-Navigation weiter nachgeschärft
- Cloudron-Version auf `0.5.1` angehoben
### 0.5.0
- Mehrnutzer-Haushalts-App mit Login, Admin-Nutzermanagement und deaktivierter offener Registrierung
- Aufgaben mit sauberer Trennung aus `TaskTemplate` und `TaskInstance`
- Wiederkehrende Aufgaben für Tage, Wochen und Monate
- Monats-Highscore mit Badge-Boni, Balkenanzeige und Monatsarchiv
- Persistentes Badge-System mit eigener Admin-Seite
- Schnellaufgabe per globalem Plus-Button mit admin-konfigurierbarem Aufwand
- Kalender- und Listenansicht, inklusive mobiler Agenda-Ansicht
- Systemabhängiges Light-/Dark-Design
- PWA-Grundlage mit Manifest, Service Worker und App-Icons
- Echte Web-Push-Architektur mit VAPID und Cloudron-tauglicher ENV-Konfiguration
- Cloudron-kompatibler Start mit persistentem Storage, ohne lokale Entwicklungsdaten ins Deployment zu übernehmen
Icons neu generieren: Icons neu generieren:
```bash ```bash
+54
View File
@@ -1,14 +1,21 @@
from __future__ import annotations from __future__ import annotations
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
@@ -17,11 +24,23 @@ 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")
app.config.from_object(config_class) app.config.from_object(config_class)
manifest_path = Path(app.root_path).parent / "CloudronManifest.json"
try:
app.config["APP_VERSION"] = json.loads(manifest_path.read_text(encoding="utf-8")).get("version", "0.0.0")
except FileNotFoundError:
app.config["APP_VERSION"] = "0.0.0"
app.config["DATA_DIR"].mkdir(parents=True, exist_ok=True) app.config["DATA_DIR"].mkdir(parents=True, exist_ok=True)
app.config["UPLOAD_FOLDER"].mkdir(parents=True, exist_ok=True) app.config["UPLOAD_FOLDER"].mkdir(parents=True, exist_ok=True)
@@ -57,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.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:
@@ -66,6 +86,7 @@ def create_app(config_class: type[Config] = Config) -> Flask:
return { return {
"app_name": app.config["APP_NAME"], "app_name": app.config["APP_NAME"],
"app_version": app.config["APP_VERSION"],
"nav_items": [ "nav_items": [
("tasks.my_tasks", "Meine Aufgaben", "house"), ("tasks.my_tasks", "Meine Aufgaben", "house"),
("tasks.all_tasks", "Alle", "list"), ("tasks.all_tasks", "Alle", "list"),
@@ -86,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")
@@ -100,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
+15 -3
View File
@@ -115,13 +115,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 +133,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")
+16
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)
@@ -46,6 +48,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 +63,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"
@@ -99,6 +107,14 @@ 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)
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)
+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"
+119 -18
View File
@@ -8,8 +8,8 @@ from flask_login import current_user, login_required
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 +26,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 +48,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 +69,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 +79,63 @@ 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,
)
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.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 +211,61 @@ 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("/users/<int:user_id>/toggle-admin", methods=["POST"]) @bp.route("/users/<int:user_id>/toggle-admin", methods=["POST"])
+32 -13
View File
@@ -8,7 +8,7 @@ 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 ..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 (
@@ -109,26 +109,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"))
+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"),
] ]
+47 -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,61 @@ 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()
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())
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
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,
)
)
created = True
if created:
db.session.commit()
+60
View File
@@ -0,0 +1,60 @@
from __future__ import annotations
from datetime import UTC, datetime, time, timedelta
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_by(assigned_user_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"
+3 -3
View File
@@ -129,12 +129,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,7 +147,7 @@ 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,
due_date=today_local(), due_date=today_local(),
points_awarded=template.default_points, points_awarded=template.default_points,
+215 -9
View File
@@ -70,6 +70,7 @@
--font-body: "InterLocal", system-ui, sans-serif; --font-body: "InterLocal", system-ui, sans-serif;
--font-heading: "SpaceGroteskLocal", "InterLocal", sans-serif; --font-heading: "SpaceGroteskLocal", "InterLocal", sans-serif;
--safe-bottom: max(24px, env(safe-area-inset-bottom)); --safe-bottom: max(24px, env(safe-area-inset-bottom));
--mobile-nav-space: 138px;
--body-radial-a: rgba(181, 210, 255, 0.85); --body-radial-a: rgba(181, 210, 255, 0.85);
--body-radial-b: rgba(255, 221, 196, 0.48); --body-radial-b: rgba(255, 221, 196, 0.48);
--body-linear-start: #f8fbff; --body-linear-start: #f8fbff;
@@ -147,7 +148,9 @@ p {
.page-shell { .page-shell {
min-height: 100vh; min-height: 100vh;
padding: 24px 18px calc(100px + var(--safe-bottom)); display: flex;
flex-direction: column;
padding: 24px 18px calc(var(--mobile-nav-space) + var(--safe-bottom));
} }
.topbar { .topbar {
@@ -179,6 +182,39 @@ p {
display: grid; display: grid;
gap: 24px; gap: 24px;
min-width: 0; min-width: 0;
flex: 1 0 auto;
}
.app-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
flex-wrap: wrap;
padding: 18px 0 8px;
color: var(--muted);
font-size: 0.88rem;
text-align: left;
}
.app-footer__left,
.app-footer__right {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.app-footer__right {
margin-left: auto;
}
.app-footer a {
color: inherit;
}
.app-footer__left span {
opacity: 0.7;
} }
.panel, .panel,
@@ -834,26 +870,28 @@ 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 {
@@ -1007,6 +1045,174 @@ p {
margin: 0; margin: 0;
} }
.quick-win-list {
display: grid;
gap: 12px;
}
.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;
}
.quick-win-manage-form {
display: grid;
gap: 12px;
}
.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;
}
.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: 10px 16px;
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.97rem;
text-align: center;
line-height: 1.2;
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) {
.quick-win-dialog-header {
grid-template-columns: 1fr;
}
.quick-win-dialog-header__badge {
width: 48px;
height: 48px;
}
.quick-win-tag-grid {
gap: 10px;
}
.quick-win-tag {
max-width: 100%;
}
.quick-win-tag span {
width: auto;
max-width: 100%;
white-space: normal;
text-align: left;
justify-content: flex-start;
}
.quick-win-tag--custom span {
width: auto;
}
}
.push-box__state { .push-box__state {
align-items: flex-start; align-items: flex-start;
padding: 16px; padding: 16px;
+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

+48
View File
@@ -7,6 +7,12 @@
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");
document.querySelectorAll("[data-complete-action]").forEach((button) => { document.querySelectorAll("[data-complete-action]").forEach((button) => {
button.addEventListener("click", () => { button.addEventListener("click", () => {
@@ -39,6 +45,48 @@
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();
});
}
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;
-1
View File
@@ -40,7 +40,6 @@
<label class="checkbox">{{ form.remember_me() }} <span>Angemeldet bleiben</span></label> <label class="checkbox">{{ form.remember_me() }} <span>Angemeldet bleiben</span></label>
{{ form.submit(class_="button button--wide") }} {{ form.submit(class_="button button--wide") }}
</form> </form>
<p class="inline-note">Demo-Logins nach dem Seeden: `anna@putzliga.local` / `putzliga123` und `ben@putzliga.local` / `putzliga123`.</p>
{% if registration_open %} {% if registration_open %}
<p class="inline-note">Es gibt noch keinen Nutzer. <a href="{{ url_for('auth.register') }}">Ersten Account anlegen</a></p> <p class="inline-note">Es gibt noch keinen Nutzer. <a href="{{ url_for('auth.register') }}">Ersten Account anlegen</a></p>
{% else %} {% else %}
+40 -11
View File
@@ -86,6 +86,17 @@
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
<footer class="app-footer">
<div class="app-footer__left">
<a href="https://git.hnz.io/hnzio/putzliga/releases" target="_blank" rel="noreferrer">Version {{ app_version }}</a>
<span>·</span>
<span>Made with ❤️ in Göttingen.</span>
</div>
<div class="app-footer__right">
<a href="https://hnz.io" target="_blank" rel="noreferrer">© 2026 @ hnz.io</a>
</div>
</footer>
</div> </div>
</div> </div>
@@ -99,7 +110,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>
@@ -117,23 +128,41 @@
</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>
+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 %}
+162
View File
@@ -0,0 +1,162 @@
{% 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>
<div class="quick-win-list">
{% for quick_win in quick_wins %}
<article class="quick-win-manage-card">
<form method="post" action="{{ url_for('settings.update_quick_win', quick_win_id=quick_win.id) }}" class="quick-win-manage-form">
<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 %}
+10 -10
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_user %}
{{ task.assigned_user.name }} {{ task.assigned_user.name|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_user %}
{{ task.assigned_user.name }} {{ task.assigned_user.name|hyphenate_de }}
{% else %} {% else %}
Ohne Zuweisung {{ 'Ohne Zuweisung'|hyphenate_de }}
{% endif %} {% endif %}
</small> </small>
</a> </a>
+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