From 67d362f1d99382476b5b6290c104f9841569dd64 Mon Sep 17 00:00:00 2001 From: Florian Heinz Date: Thu, 16 Apr 2026 13:41:22 +0200 Subject: [PATCH] feat: release 0.7.0 celebration flow --- CloudronManifest.json | 2 +- README.md | 11 +++ app/routes/tasks.py | 28 +++++++- app/static/css/style.css | 151 ++++++++++++++++++++++++++++++++++++++- app/static/js/app.js | 151 ++++++++++++++++++++++++++++++++++++++- app/templates/base.html | 3 + 6 files changed, 340 insertions(+), 6 deletions(-) diff --git a/CloudronManifest.json b/CloudronManifest.json index 6f80ace..5413093 100644 --- a/CloudronManifest.json +++ b/CloudronManifest.json @@ -4,7 +4,7 @@ "author": "hnzio ", "description": "Spielerische Haushalts-App mit Aufgaben, Punkten, Monats-Highscore, Kalender und PWA-Push.", "tagline": "Haushalt mit Liga-Gefühl", - "version": "0.6.5", + "version": "0.7.0", "manifestVersion": 2, "healthCheckPath": "/healthz", "httpPort": 8000, diff --git a/README.md b/README.md index 07f79fa..a86dfa2 100644 --- a/README.md +++ b/README.md @@ -360,6 +360,17 @@ Der ausgegebene `VAPID_PRIVATE_KEY` ist bereits `.env`-freundlich mit escaped Ne ## Release Notes +### 0.7.0 + +- Aufgaben und Quick-Wins lösen jetzt eine kurze, subtile Punkte-Animation mit Glas-Look und Firework-Effekt aus +- Celebration-Zahl für Mobilgeräte deutlich vergrößert und direkt auf transparent schimmernde Ziffern umgestellt +- Quick-Wins-Dialog technisch auf robusteres natives Dialog-Verhalten zurückgeführt +- Quick-Wins lassen sich jetzt wieder zuverlässig schließen, auch mobil +- Tap auf den Dialog-Backdrop schließt Abschluss- und Quick-Win-Dialog jetzt ebenfalls sauber +- Scrollposition bleibt beim Erledigen von Aufgaben und Quick-Wins erhalten, statt nach oben zu springen +- Redirect nach Abschluss übergibt die verbuchten Punkte gezielt an die UI, ohne die URL dauerhaft zu verschmutzen +- Cloudron-Version auf `0.7.0` angehoben + ### 0.6.5 - Quick-Wins als gemeinsames Team-Feature ausgebaut diff --git a/app/routes/tasks.py b/app/routes/tasks.py index 4129189..c8102a3 100644 --- a/app/routes/tasks.py +++ b/app/routes/tasks.py @@ -3,6 +3,7 @@ from __future__ import annotations import calendar from collections import defaultdict from datetime import date, timedelta +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit from flask import Blueprint, flash, redirect, render_template, request, url_for from flask_login import current_user, login_required @@ -99,6 +100,19 @@ def _archive_day_label(day: date, today: date) -> str: return day.strftime("%d.%m.%Y") +def _redirect_with_celebration(target_url: str, points: int | None = None): + if not points or points <= 0: + return redirect(target_url) + + parts = urlsplit(target_url) + query = dict(parse_qsl(parts.query, keep_blank_values=True)) + query["celebrate_points"] = str(points) + redirect_url = urlunsplit( + (parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment) + ) + return redirect(redirect_url) + + @bp.route("/my-tasks") @login_required def my_tasks(): @@ -253,6 +267,7 @@ def create(): def quick_create(): config = get_quick_task_config() created_titles: list[str] = [] + total_points = 0 selected_ids = request.form.getlist("quick_win_ids") if selected_ids: @@ -261,6 +276,7 @@ def quick_create(): task = create_quick_task(quick_win.title, quick_win.effort, current_user, description="Quick-Win") complete_task(task, current_user.id) created_titles.append(task.title) + total_points += task.points_awarded if request.form.get("include_custom") == "1": form = QuickTaskForm(prefix="quick") @@ -281,6 +297,7 @@ def quick_create(): task = create_quick_task(custom_title, form.effort.data, current_user, description="Quick-Win") complete_task(task, current_user.id) created_titles.append(task.title) + total_points += task.points_awarded if not created_titles: flash("Bitte wähle mindestens einen Quick-Win aus.", "error") @@ -290,7 +307,10 @@ def quick_create(): flash(f"Quick-Win „{created_titles[0]}“ wurde als erledigt gespeichert.", "success") else: flash(f"{len(created_titles)} Quick-Wins wurden als erledigt gespeichert.", "success") - return redirect(request.referrer or url_for("tasks.my_tasks")) + return _redirect_with_celebration( + request.referrer or url_for("tasks.my_tasks"), + total_points, + ) @bp.route("/tasks//edit", methods=["GET", "POST"]) @@ -353,9 +373,13 @@ def complete(task_id: int): if selected_user_id in allowed_ids: completed_by_id = selected_user_id + awarded_points = task.points_awarded complete_task(task, completed_by_id) flash("Punkte verbucht. Gute Arbeit.", "success") - return redirect(request.referrer or url_for("tasks.my_tasks")) + return _redirect_with_celebration( + request.referrer or url_for("tasks.my_tasks"), + awarded_points, + ) @bp.route("/calendar") diff --git a/app/static/css/style.css b/app/static/css/style.css index 23167f4..9519939 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -1505,6 +1505,15 @@ p { align-items: stretch; } + .celebration-score { + width: 80vw; + font-size: clamp(5.5rem, 28vw, 8.5rem); + } + + .celebration-glow { + width: min(78vw, 240px); + } + .form-actions .button, .form-actions a.button { width: 100%; @@ -1606,14 +1615,23 @@ p { width: 100vw; max-width: 100vw; min-height: 100vh; + min-height: 100dvh; margin: 0; padding: 12px; border: 0; background: transparent; + overflow: visible; + box-sizing: border-box; +} + +.complete-dialog:not([open]) { + display: none; +} + +.complete-dialog[open] { display: flex; align-items: center; justify-content: center; - overflow: visible; } .complete-dialog::backdrop { @@ -1641,6 +1659,70 @@ p { touch-action: pan-y; } +.celebration-layer { + position: fixed; + inset: 0; + pointer-events: none; + z-index: 90; + overflow: hidden; +} + +.celebration-score, +.celebration-glow, +.celebration-particle { + position: absolute; + left: 50%; + top: 50%; +} + +.celebration-score { + width: min(80vw, 520px); + padding: 0; + background: + linear-gradient( + 135deg, + rgba(255, 255, 255, 0.98) 0%, + rgba(214, 234, 255, 0.92) 18%, + rgba(94, 168, 255, 0.72) 42%, + rgba(52, 211, 153, 0.74) 70%, + rgba(255, 255, 255, 0.92) 100% + ); + color: transparent; + -webkit-text-fill-color: transparent; + -webkit-background-clip: text; + background-clip: text; + -webkit-text-stroke: 1px rgba(255, 255, 255, 0.22); + font-size: clamp(6.5rem, 28vw, 12rem); + font-weight: 900; + line-height: 0.9; + letter-spacing: -0.08em; + text-align: center; + transform: translate(-50%, -50%); + filter: + drop-shadow(0 10px 24px rgba(94, 168, 255, 0.18)) + drop-shadow(0 0 18px rgba(255, 255, 255, 0.18)); + animation: celebration-score-in 1.15s cubic-bezier(0.18, 0.84, 0.24, 1) forwards; +} + +.celebration-glow { + width: min(56vw, 280px); + aspect-ratio: 1; + border-radius: 50%; + background: + radial-gradient(circle, rgba(94, 168, 255, 0.26) 0%, rgba(52, 211, 153, 0.2) 42%, rgba(94, 168, 255, 0) 74%); + transform: translate(-50%, -50%); + filter: blur(10px); + animation: celebration-glow 0.95s ease-out forwards; +} + +.celebration-particle { + width: var(--size, 10px); + height: var(--size, 10px); + border-radius: 999px; + transform: translate(-50%, -50%) rotate(var(--angle)) translateY(-4px); + animation: celebration-particle 0.82s ease-out var(--delay, 0s) forwards; +} + .choice-grid { display: grid; gap: 12px; @@ -1681,6 +1763,61 @@ p { height: 24px; } +@keyframes celebration-score-in { + 0% { + opacity: 0; + transform: translate(-50%, -42%) scale(0.74); + filter: blur(16px); + } + 12% { + opacity: 1; + transform: translate(-50%, -50%) scale(1); + filter: blur(0); + } + 58% { + opacity: 1; + transform: translate(-50%, -53%) scale(1.03); + filter: blur(0); + } + 100% { + opacity: 0; + transform: translate(-50%, -76%) scale(1.08); + filter: blur(20px); + } +} + +@keyframes celebration-glow { + 0% { + opacity: 0; + transform: translate(-50%, -50%) scale(0.32); + } + 22% { + opacity: 1; + transform: translate(-50%, -50%) scale(1); + } + 100% { + opacity: 0; + transform: translate(-50%, -50%) scale(1.42); + } +} + +@keyframes celebration-particle { + 0% { + opacity: 0; + transform: translate(-50%, -50%) rotate(var(--angle)) translateY(0) scale(0.4); + filter: blur(4px); + } + 18% { + opacity: 1; + filter: blur(0); + } + 100% { + opacity: 0; + transform: translate(-50%, -50%) rotate(var(--angle)) translateY(calc(var(--distance) * -1)) scale(0.9); + filter: blur(6px); + } +} + @media (max-width: 759px) { .calendar-toolbar-mobile__header { align-items: flex-start; @@ -1884,6 +2021,18 @@ p { } } +@media (prefers-reduced-motion: reduce) { + .celebration-score { + animation-duration: 0.46s; + } + + .celebration-glow, + .celebration-particle { + animation: none; + opacity: 0; + } +} + @media (min-width: 1100px) { .app-shell { display: grid; diff --git a/app/static/js/app.js b/app/static/js/app.js index a5764d3..f3e5263 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -20,9 +20,50 @@ const quickWinSortIds = document.getElementById("quickWinSortIds"); const quickWinSortSave = document.getElementById("quickWinSortSave"); const quickWinToggleButtons = document.querySelectorAll("[data-quick-win-toggle]"); + const celebrationLayer = document.getElementById("celebrationLayer"); + const celebratePoints = Number.parseInt(document.body.dataset.celebratePoints || "", 10); + const scrollRestoreKey = "putzliga:scroll-restore"; let draggedQuickWin = null; let quickWinSortDirty = false; + function rememberScrollPosition() { + try { + window.sessionStorage.setItem( + scrollRestoreKey, + JSON.stringify({ + path: window.location.pathname, + y: window.scrollY, + }), + ); + } catch (_) { + // Ignore storage errors and continue normally. + } + } + + function restoreScrollPosition() { + try { + const rawValue = window.sessionStorage.getItem(scrollRestoreKey); + if (!rawValue) { + return; + } + + const saved = JSON.parse(rawValue); + window.sessionStorage.removeItem(scrollRestoreKey); + + if (!saved || saved.path !== window.location.pathname || typeof saved.y !== "number") { + return; + } + + window.requestAnimationFrame(() => { + window.requestAnimationFrame(() => { + window.scrollTo({ top: saved.y, behavior: "auto" }); + }); + }); + } catch (_) { + // Ignore malformed storage data. + } + } + function buildCompletionOptions(button) { const options = []; const assignedPairs = [ @@ -60,6 +101,7 @@ choiceButton.textContent = option.label; choiceButton.addEventListener("click", () => { dialogChoice.value = option.value; + rememberScrollPosition(); dialog.close(); dialogForm.submit(); }); @@ -74,13 +116,33 @@ } if (quickTaskOpen && quickTaskDialog) { - quickTaskOpen.addEventListener("click", () => quickTaskDialog.showModal()); + quickTaskOpen.addEventListener("click", () => { + if (!quickTaskDialog.open) { + quickTaskDialog.showModal(); + } + }); } if (quickTaskClose && quickTaskDialog) { - quickTaskClose.addEventListener("click", () => quickTaskDialog.close()); + quickTaskClose.addEventListener("click", () => { + if (quickTaskDialog.open) { + quickTaskDialog.close(); + } + }); } + [dialog, quickTaskDialog].forEach((activeDialog) => { + if (!activeDialog) { + return; + } + + activeDialog.addEventListener("click", (event) => { + if (event.target === activeDialog && activeDialog.open) { + activeDialog.close(); + } + }); + }); + function updateQuickWinsState() { const selectedPresetCount = [...quickWinInputs].filter((input) => input.checked).length; const customSelected = quickWinCustomToggle?.checked === true; @@ -123,6 +185,25 @@ }); } + if (dialogForm) { + dialogForm.addEventListener("submit", () => { + rememberScrollPosition(); + }); + } + + document.querySelectorAll('form[action*="/complete"]').forEach((form) => { + form.addEventListener("submit", () => { + rememberScrollPosition(); + }); + }); + + const quickWinsForm = document.getElementById("quickWinsForm"); + if (quickWinsForm) { + quickWinsForm.addEventListener("submit", () => { + rememberScrollPosition(); + }); + } + function syncQuickWinSortIds() { if (!quickWinSortList || !quickWinSortIds) { return; @@ -140,6 +221,65 @@ } } + function clearCelebrationQuery() { + if (!window.history.replaceState) { + return; + } + const url = new URL(window.location.href); + if (!url.searchParams.has("celebrate_points")) { + return; + } + url.searchParams.delete("celebrate_points"); + const nextUrl = `${url.pathname}${url.search}${url.hash}`; + window.history.replaceState({}, document.title, nextUrl); + } + + function celebrateCompletion(points) { + if (!celebrationLayer || !Number.isFinite(points) || points <= 0) { + return; + } + + const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + celebrationLayer.hidden = false; + celebrationLayer.setAttribute("aria-hidden", "false"); + celebrationLayer.innerHTML = ""; + + const score = document.createElement("div"); + score.className = "celebration-score"; + score.textContent = points; + celebrationLayer.appendChild(score); + + const glow = document.createElement("div"); + glow.className = "celebration-glow"; + celebrationLayer.appendChild(glow); + + if (!prefersReducedMotion) { + const colors = [ + "rgba(94, 168, 255, 0.96)", + "rgba(52, 211, 153, 0.95)", + "rgba(250, 204, 21, 0.92)", + "rgba(191, 219, 254, 0.96)", + ]; + + Array.from({ length: 14 }).forEach((_, index) => { + const particle = document.createElement("span"); + particle.className = "celebration-particle"; + particle.style.setProperty("--angle", `${Math.round((360 / 14) * index + Math.random() * 18)}deg`); + particle.style.setProperty("--distance", `${72 + Math.round(Math.random() * 44)}px`); + particle.style.setProperty("--delay", `${(Math.random() * 0.08).toFixed(2)}s`); + particle.style.setProperty("--size", `${7 + Math.round(Math.random() * 7)}px`); + particle.style.background = colors[index % colors.length]; + celebrationLayer.appendChild(particle); + }); + } + + window.setTimeout(() => { + celebrationLayer.hidden = true; + celebrationLayer.setAttribute("aria-hidden", "true"); + celebrationLayer.innerHTML = ""; + }, prefersReducedMotion ? 520 : 1500); + } + if (quickWinSortList) { syncQuickWinSortIds(); setQuickWinSortDirty(false); @@ -282,4 +422,11 @@ togglePush().catch((error) => console.error("Push toggle failed", error)); }); } + + if (Number.isFinite(celebratePoints) && celebratePoints > 0) { + celebrateCompletion(celebratePoints); + clearCelebrationQuery(); + } + + restoreScrollPosition(); })(); diff --git a/app/templates/base.html b/app/templates/base.html index b2f3c79..ad1fc03 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -21,6 +21,7 @@ data-push-key="{{ config['VAPID_PUBLIC_KEY'] if current_user.is_authenticated else '' }}" data-current-user-id="{{ current_user.id if current_user.is_authenticated else '' }}" data-current-user-name="{{ current_user.name if current_user.is_authenticated else '' }}" + data-celebrate-points="{{ request.args.get('celebrate_points', '') }}" > {% from "partials/macros.html" import nav_icon %}
@@ -110,6 +111,8 @@
{% if current_user.is_authenticated %} + +