From 2f2e543a798fae8f00c4ce4f1f31eef0afbe3ee7 Mon Sep 17 00:00:00 2001 From: Florian Heinz Date: Wed, 15 Apr 2026 12:17:58 +0200 Subject: [PATCH] feat: refine quick wins workflow and calendar layout --- README.md | 10 +- app/__init__.py | 43 ++++++ app/forms.py | 4 +- app/routes/settings.py | 36 +++++ app/routes/tasks.py | 47 +++--- app/static/css/style.css | 188 ++++++++++++++++++----- app/static/icons/quick-wins-sparkles.svg | 1 + app/static/js/app.js | 48 ++++++ app/templates/base.html | 94 ++++++------ app/templates/settings/quick_wins.html | 41 +++-- app/templates/tasks/calendar.html | 20 +-- requirements.txt | 1 + 12 files changed, 405 insertions(+), 128 deletions(-) create mode 100644 app/static/icons/quick-wins-sparkles.svg diff --git a/README.md b/README.md index f534ce0..90fe01f 100644 --- a/README.md +++ b/README.md @@ -350,9 +350,15 @@ Der ausgegebene `VAPID_PRIVATE_KEY` ist bereits `.env`-freundlich mit escaped Ne - Quick-Wins als gemeinsames Team-Feature ausgebaut - neuer Optionen-Tab zum Anlegen und Verwalten gemeinsamer Quick-Wins -- Plus-Dialog auf klickbare Quick-Win-Karten umgestellt -- „Sonstiges (bitte auch nutzen)“ mit freiem Titel und Aufwand ergänzt +- 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 diff --git a/app/__init__.py b/app/__init__.py index e16274f..8ffb0a2 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -4,6 +4,11 @@ import json from pathlib import Path from flask import Flask +from markupsafe import escape +try: + import pyphen +except ModuleNotFoundError: # pragma: no cover - optional dependency in local dev + pyphen = None from config import Config @@ -19,6 +24,12 @@ from .services.bootstrap import ensure_schema_and_admins from .services.dates import MONTH_NAMES, local_now from .services.monthly import archive_months_missing_up_to_previous +DE_HYPHENATOR = pyphen.Pyphen(lang="de_DE") if pyphen else None + + +def _fallback_soft_hyphenate(word: str) -> str: + return word + def create_app(config_class: type[Config] = Config) -> Flask: app = Flask(__name__, static_folder="static", template_folder="templates") @@ -111,4 +122,36 @@ def create_app(config_class: type[Config] = Config) -> Flask: def month_name(value): return MONTH_NAMES[value] + @app.template_filter("hyphenate_de") + def hyphenate_de(value): + if not value: + return "" + + text = str(value) + parts: list[str] = [] + current = [] + + def flush_word(): + if not current: + return + word = "".join(current) + if len(word) >= 6: + if DE_HYPHENATOR: + parts.append(DE_HYPHENATOR.inserted(word, "\u00AD")) + else: + parts.append(_fallback_soft_hyphenate(word)) + else: + parts.append(word) + current.clear() + + for char in text: + if char.isalpha() or char in "ÄÖÜäöüß": + current.append(char) + else: + flush_word() + parts.append(char) + + flush_word() + return escape("".join(parts)) + return app diff --git a/app/forms.py b/app/forms.py index faaa6a3..43ff754 100644 --- a/app/forms.py +++ b/app/forms.py @@ -115,11 +115,11 @@ class AdminUserForm(FlaskForm): class QuickTaskForm(FlaskForm): - title = StringField("Titel", validators=[DataRequired(), Length(min=2, max=160)]) + title = StringField("Titel", validators=[Optional(), Length(min=2, max=160)]) effort = SelectField( "Aufwand", choices=[(key, key) for key, _, _ in QUICK_TASK_EFFORTS], - validators=[DataRequired()], + validators=[Optional()], ) submit = SubmitField("Quick-Win speichern") diff --git a/app/routes/settings.py b/app/routes/settings.py index 2844cd1..fa3259f 100644 --- a/app/routes/settings.py +++ b/app/routes/settings.py @@ -232,6 +232,42 @@ def delete_quick_win(quick_win_id: int): return redirect(url_for("settings.quick_wins")) +@bp.route("/quick-wins//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//toggle-admin", methods=["POST"]) @login_required def toggle_admin(user_id: int): diff --git a/app/routes/tasks.py b/app/routes/tasks.py index bcbdb49..f9a7485 100644 --- a/app/routes/tasks.py +++ b/app/routes/tasks.py @@ -110,33 +110,44 @@ def create(): @login_required def quick_create(): config = get_quick_task_config() - quick_action = request.form.get("quick_action", "save") - quick_mode = request.form.get("quick_mode", "preset") + created_titles: list[str] = [] - if quick_mode == "preset": - quick_win = QuickWin.query.filter_by(id=request.form.get("quick_win_id", type=int), active=True).first() - if not quick_win: - flash("Dieser Quick-Win ist nicht mehr verfügbar.", "error") - return redirect(request.referrer or url_for("tasks.my_tasks")) - title = quick_win.title - effort = quick_win.effort - else: + 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()] - if not form.validate_on_submit(): + custom_title = (form.title.data or "").strip() + extra_errors: list[str] = [] + if not custom_title: + extra_errors.append("Bitte gib für „Sonstiges“ einen Titel ein.") + if not form.effort.data or form.effort.data not in config: + extra_errors.append("Bitte wähle für „Sonstiges“ einen Aufwand aus.") + if not form.validate_on_submit() or extra_errors: for field_errors in form.errors.values(): for error in field_errors: flash(error, "error") + for error in extra_errors: + flash(error, "error") return redirect(request.referrer or url_for("tasks.my_tasks")) - title = form.title.data - effort = form.effort.data - - task = create_quick_task(title, effort, current_user, description="Quick-Win") - if quick_action == "complete": + task = create_quick_task(custom_title, form.effort.data, current_user, description="Quick-Win") complete_task(task, current_user.id) - flash(f"Quick-Win „{task.title}“ wurde direkt als erledigt gespeichert.", "success") + 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")) + + if len(created_titles) == 1: + flash(f"Quick-Win „{created_titles[0]}“ wurde als erledigt gespeichert.", "success") else: - flash(f"Quick-Win „{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")) diff --git a/app/static/css/style.css b/app/static/css/style.css index 071317d..5634aab 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -188,19 +188,32 @@ p { .app-footer { display: flex; align-items: center; - justify-content: center; - gap: 10px; + justify-content: space-between; + gap: 14px; + flex-wrap: wrap; padding: 18px 0 8px; color: var(--muted); font-size: 0.88rem; - text-align: center; + text-align: left; +} + +.app-footer__left, +.app-footer__right { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.app-footer__right { + margin-left: auto; } .app-footer a { color: inherit; } -.app-footer span { +.app-footer__left span { opacity: 0.7; } @@ -857,26 +870,28 @@ p { } .calendar-task__title { - display: -webkit-box; - overflow: hidden; + display: block; + overflow: visible; min-width: 0; font-size: 0.96rem; line-height: 1.15; font-weight: 600; color: var(--text); - word-break: break-word; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; + word-break: normal; + overflow-wrap: break-word; + hyphens: manual; } .calendar-task__person { display: block; - overflow: hidden; + overflow: visible; min-width: 0; font-size: 0.74rem; line-height: 1.2; - white-space: nowrap; - text-overflow: ellipsis; + white-space: normal; + word-break: normal; + overflow-wrap: break-word; + hyphens: manual; } .calendar-task--open { @@ -1035,8 +1050,7 @@ p { gap: 12px; } -.quick-win-manage-card, -.quick-win-card { +.quick-win-manage-card { display: grid; gap: 12px; padding: 18px; @@ -1046,8 +1060,18 @@ p { } .quick-win-manage-card { - grid-template-columns: minmax(0, 1fr) auto; - align-items: center; + 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 { @@ -1055,44 +1079,138 @@ p { gap: 12px; } -.quick-win-card__actions, -.quick-win-custom__body { +.quick-win-dialog-header { display: grid; - gap: 10px; + grid-template-columns: auto minmax(0, 1fr); + gap: 14px; + align-items: start; } -.quick-win-card__actions .button, -.quick-win-custom__body .button { - width: 100%; +.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-card p, .quick-win-manage-card p { color: var(--muted); } -.quick-win-card--custom { - cursor: pointer; - grid-template-columns: minmax(0, 1fr) auto; +.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; - list-style: none; + 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-card--custom::-webkit-details-marker { - display: none; +.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-custom { +.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: 10px; + gap: 14px; } -.quick-win-custom[open] .quick-win-card--custom { - border-color: rgba(37, 99, 235, 0.24); +.quick-win-custom-fields[hidden] { + display: none !important; } -.quick-win-custom__body { - padding: 4px 2px 0; +.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 { diff --git a/app/static/icons/quick-wins-sparkles.svg b/app/static/icons/quick-wins-sparkles.svg new file mode 100644 index 0000000..4c4e064 --- /dev/null +++ b/app/static/icons/quick-wins-sparkles.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/static/js/app.js b/app/static/js/app.js index fbe1338..05822ce 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -7,6 +7,12 @@ const quickTaskDialog = document.getElementById("quickTaskDialog"); const quickTaskOpen = document.getElementById("quickTaskOpen"); const quickTaskClose = document.getElementById("quickTaskClose"); + const quickWinsSubmit = document.getElementById("quickWinsSubmit"); + const quickWinInputs = document.querySelectorAll("[data-quick-win-input]"); + const quickWinCustomToggle = document.querySelector("[data-quick-win-custom-toggle]"); + const quickWinCustomFields = document.getElementById("quickWinCustomFields"); + const quickWinTitle = document.getElementById("quick-title"); + const quickWinEffort = document.getElementById("quick-effort"); document.querySelectorAll("[data-complete-action]").forEach((button) => { button.addEventListener("click", () => { @@ -39,6 +45,48 @@ 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 pushHint = document.getElementById("pushHint"); const vapidKey = document.body.dataset.pushKey; diff --git a/app/templates/base.html b/app/templates/base.html index b2037e8..ca8cb5e 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -88,9 +88,14 @@ @@ -123,54 +128,43 @@ -
-

Quick-Wins

-

Schnell etwas abhaken

-

Alle Quick-Wins sind für das ganze Team sichtbar. Für „Sonstiges“ kannst du Titel und Aufwand frei wählen.

-
- {% for quick_win in quick_wins %} -
-
- {{ quick_win.title }} -

{{ quick_task_config[quick_win.effort].label }}

-
-
- - - - - -
-
- {% endfor %} -
- -
- Sonstiges (bitte auch nutzen) -

Eigener Titel und freier Aufwand

-
- {{ nav_icon('plus') }} -
-
- {{ quick_task_form.hidden_tag() }} - -
- {{ quick_task_form.title.label }} - {{ quick_task_form.title(placeholder="Zum Beispiel: Flur kurz aufräumen") }} -
-
- {{ quick_task_form.effort.label }} - {{ quick_task_form.effort() }} -
-
- - -
-
-
+
+ {{ quick_task_form.hidden_tag() }} +
+ +
+

Quick-Wins

+

Schnell Punkte abstauben

+

Alle Quick-Wins sind für das ganze Team sichtbar. Für „Sonstiges“ kannst du Titel und Aufwand frei wählen.

+
- -
+
+ {% for quick_win in quick_wins %} + + {% endfor %} + +
+ +
+ + +
+
diff --git a/app/templates/settings/quick_wins.html b/app/templates/settings/quick_wins.html index eb60af2..1b733f5 100644 --- a/app/templates/settings/quick_wins.html +++ b/app/templates/settings/quick_wins.html @@ -35,20 +35,39 @@

Direkt sichtbar

-

Aktive Quick-Wins

+

Aktive Quick-Wins bearbeiten

{% for quick_win in quick_wins %}
-
- {{ quick_win.title }} -

{{ quick_task_config[quick_win.effort].label }} · von {{ quick_win.created_by_user.name }}

-
- {% if quick_win.created_by_user_id == current_user.id or current_user.is_admin %} - - - - - {% endif %} +
+ +
+ + +
+
+ + +
+

Von {{ quick_win.created_by_user.name }}

+
+ + {% if quick_win.created_by_user_id == current_user.id or current_user.is_admin %} + + {% endif %} +
+
{% else %}
Noch keine Quick-Wins angelegt. Der erste steht gleich oben bereit.
diff --git a/app/templates/tasks/calendar.html b/app/templates/tasks/calendar.html index 45501bc..ce666ce 100644 --- a/app/templates/tasks/calendar.html +++ b/app/templates/tasks/calendar.html @@ -71,14 +71,14 @@