11 Commits

30 changed files with 1003 additions and 209 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
"author": "hnzio <mail@example.com>", "author": "hnzio <mail@example.com>",
"description": "Spielerische Haushalts-App mit Aufgaben, Punkten, Monats-Highscore, Kalender und PWA-Push.", "description": "Spielerische Haushalts-App mit Aufgaben, Punkten, Monats-Highscore, Kalender und PWA-Push.",
"tagline": "Haushalt mit Liga-Gefühl", "tagline": "Haushalt mit Liga-Gefühl",
"version": "0.6.5", "version": "0.7.0",
"manifestVersion": 2, "manifestVersion": 2,
"healthCheckPath": "/healthz", "healthCheckPath": "/healthz",
"httpPort": 8000, "httpPort": 8000,
+26 -1
View File
@@ -224,11 +224,13 @@ Putzliga nutzt echte Web-Push-Benachrichtigungen mit Service Worker und VAPID.
```bash ```bash
flask --app app.py notify-due flask --app app.py notify-due
flask --app app.py notify-monthly-winner flask --app app.py notify-monthly-winner
flask --app app.py notify-all
``` ```
`notify-due`: `notify-due`:
- prüft offene Aufgaben, die heute oder morgen fällig sind - sendet ab 09:00 Uhr genau einen Sammel-Push pro Nutzer und Tag
- berücksichtigt nur offene Aufgaben, die heute fällig sind
- berücksichtigt die Nutzeroption `notification_task_due_enabled` - berücksichtigt die Nutzeroption `notification_task_due_enabled`
`notify-monthly-winner`: `notify-monthly-winner`:
@@ -237,12 +239,24 @@ flask --app app.py notify-monthly-winner
- verweist auf das Scoreboard/Archiv des letzten Monats - verweist auf das Scoreboard/Archiv des letzten Monats
- berücksichtigt `notification_monthly_winner_enabled` - berücksichtigt `notification_monthly_winner_enabled`
Badge-Pushes:
- werden direkt beim Freischalten eines neuen Badges verschickt
- berücksichtigen `notification_badge_enabled`
- funktionieren für Aufgaben-Badges und Monats-Badges
Für iPhone/iPad muss zusätzlich sichergestellt sein, dass der Server ausgehend Apple Web Push erreichen kann. Laut WebKit sollten dafür Verbindungen zu `*.push.apple.com` möglich sein. Für iPhone/iPad muss zusätzlich sichergestellt sein, dass der Server ausgehend Apple Web Push erreichen kann. Laut WebKit sollten dafür Verbindungen zu `*.push.apple.com` möglich sein.
### Produktiver Betrieb ### Produktiver Betrieb
Auf Cloudron oder einem anderen Server solltest du diese Kommandos regelmäßig per Cronjob oder Task ausführen, zum Beispiel: Auf Cloudron oder einem anderen Server solltest du diese Kommandos regelmäßig per Cronjob oder Task ausführen, zum Beispiel:
```bash
flask --app /app/app.py notify-all
```
Wenn du lieber getrennt schedulen willst, funktionieren auch weiterhin:
```bash ```bash
flask --app /app/app.py notify-due flask --app /app/app.py notify-due
flask --app /app/app.py notify-monthly-winner flask --app /app/app.py notify-monthly-winner
@@ -346,6 +360,17 @@ Der ausgegebene `VAPID_PRIVATE_KEY` ist bereits `.env`-freundlich mit escaped Ne
## Release Notes ## Release Notes
### 0.7.0
- Aufgaben und Quick-Wins lösen jetzt eine kurze, subtile Punkte-Animation mit Glas-Look und Firework-Effekt aus
- Celebration-Zahl für Mobilgeräte deutlich vergrößert und direkt auf transparent schimmernde Ziffern umgestellt
- Quick-Wins-Dialog technisch auf robusteres natives Dialog-Verhalten zurückgeführt
- Quick-Wins lassen sich jetzt wieder zuverlässig schließen, auch mobil
- Tap auf den Dialog-Backdrop schließt Abschluss- und Quick-Win-Dialog jetzt ebenfalls sauber
- Scrollposition bleibt beim Erledigen von Aufgaben und Quick-Wins erhalten, statt nach oben zu springen
- Redirect nach Abschluss übergibt die verbuchten Punkte gezielt an die UI, ohne die URL dauerhaft zu verschmutzen
- Cloudron-Version auf `0.7.0` angehoben
### 0.6.5 ### 0.6.5
- Quick-Wins als gemeinsames Team-Feature ausgebaut - Quick-Wins als gemeinsames Team-Feature ausgebaut
+2
View File
@@ -90,6 +90,7 @@ def create_app(config_class: type[Config] = Config) -> Flask:
"nav_items": [ "nav_items": [
("tasks.my_tasks", "Meine Aufgaben", "house"), ("tasks.my_tasks", "Meine Aufgaben", "house"),
("tasks.all_tasks", "Alle", "list"), ("tasks.all_tasks", "Alle", "list"),
("tasks.archive_view", "Archiv", "check-double"),
("tasks.create", "Neu", "plus"), ("tasks.create", "Neu", "plus"),
("tasks.calendar_view", "Kalender", "calendar"), ("tasks.calendar_view", "Kalender", "calendar"),
("scoreboard.index", "Highscore", "trophy"), ("scoreboard.index", "Highscore", "trophy"),
@@ -98,6 +99,7 @@ def create_app(config_class: type[Config] = Config) -> Flask:
"mobile_nav_items": [ "mobile_nav_items": [
("tasks.my_tasks", "Meine Aufgaben", "house"), ("tasks.my_tasks", "Meine Aufgaben", "house"),
("tasks.all_tasks", "Alle Aufgaben", "list"), ("tasks.all_tasks", "Alle Aufgaben", "list"),
("tasks.archive_view", "Archiv", "check-double"),
("tasks.calendar_view", "Kalender", "calendar"), ("tasks.calendar_view", "Kalender", "calendar"),
("scoreboard.index", "Highscore", "trophy"), ("scoreboard.index", "Highscore", "trophy"),
("settings.index", "Optionen", "gear"), ("settings.index", "Optionen", "gear"),
+12 -1
View File
@@ -5,7 +5,7 @@ import click
from .extensions import db from .extensions import db
from .models import BadgeDefinition, User from .models import BadgeDefinition, User
from .services.monthly import archive_months_missing_up_to_previous from .services.monthly import archive_months_missing_up_to_previous
from .services.notifications import send_due_notifications, send_monthly_winner_notifications from .services.notifications import run_scheduled_notifications, send_due_notifications, send_monthly_winner_notifications
DEFAULT_BADGES = [ DEFAULT_BADGES = [
@@ -175,3 +175,14 @@ def register_cli(app) -> None:
def notify_monthly_winner_command(): def notify_monthly_winner_command():
result = send_monthly_winner_notifications() result = send_monthly_winner_notifications()
click.echo(f"Winner-Push: sent={result.sent} skipped={result.skipped} failed={result.failed}") click.echo(f"Winner-Push: sent={result.sent} skipped={result.skipped} failed={result.failed}")
@app.cli.command("notify-all")
def notify_all_command():
results = run_scheduled_notifications()
daily = results["daily_due"]
monthly = results["monthly_winner"]
click.echo(
"Scheduled-Pushes: "
f"daily(sent={daily.sent}, skipped={daily.skipped}, failed={daily.failed}) "
f"monthly(sent={monthly.sent}, skipped={monthly.skipped}, failed={monthly.failed})"
)
+2 -1
View File
@@ -89,8 +89,9 @@ class SettingsProfileForm(FlaskForm):
"Avatar", "Avatar",
validators=[Optional(), FileAllowed(["png", "jpg", "jpeg", "gif", "webp", "svg"], "Bitte ein Bild hochladen.")], validators=[Optional(), FileAllowed(["png", "jpg", "jpeg", "gif", "webp", "svg"], "Bitte ein Bild hochladen.")],
) )
notification_task_due_enabled = BooleanField("Push bei bald fälligen Aufgaben") notification_task_due_enabled = BooleanField("Push bei heutigen offenen Aufgaben")
notification_monthly_winner_enabled = BooleanField("Push zum Monatssieger") notification_monthly_winner_enabled = BooleanField("Push zum Monatssieger")
notification_badge_enabled = BooleanField("Push bei neuen Badges")
submit = SubmitField("Einstellungen speichern") submit = SubmitField("Einstellungen speichern")
def __init__(self, original_email: str | None = None, *args, **kwargs): def __init__(self, original_email: str | None = None, *args, **kwargs):
+2 -1
View File
@@ -28,6 +28,7 @@ class User(UserMixin, TimestampMixin, db.Model):
calendar_feed_token = db.Column(db.String(255), nullable=True, unique=True, index=True) calendar_feed_token = db.Column(db.String(255), nullable=True, unique=True, index=True)
notification_task_due_enabled = db.Column(db.Boolean, nullable=False, default=True) notification_task_due_enabled = db.Column(db.Boolean, nullable=False, default=True)
notification_monthly_winner_enabled = db.Column(db.Boolean, nullable=False, default=True) notification_monthly_winner_enabled = db.Column(db.Boolean, nullable=False, default=True)
notification_badge_enabled = db.Column(db.Boolean, nullable=False, default=True)
assigned_task_templates = db.relationship( assigned_task_templates = db.relationship(
"TaskTemplate", "TaskTemplate",
@@ -187,7 +188,7 @@ class TaskInstance(TimestampMixin, db.Model):
def status_label(self) -> str: def status_label(self) -> str:
labels = { labels = {
"open": "Offen", "open": "Offen",
"due_today": "Bald fällig", "due_today": "Heute fällig",
"due_tomorrow": "Morgen fällig", "due_tomorrow": "Morgen fällig",
"due_day_after_tomorrow": "Übermorgen fällig", "due_day_after_tomorrow": "Übermorgen fällig",
"overdue": "Überfällig", "overdue": "Überfällig",
+1
View File
@@ -57,6 +57,7 @@ def index():
current_user.email = form.email.data.lower().strip() current_user.email = form.email.data.lower().strip()
current_user.notification_task_due_enabled = form.notification_task_due_enabled.data current_user.notification_task_due_enabled = form.notification_task_due_enabled.data
current_user.notification_monthly_winner_enabled = form.notification_monthly_winner_enabled.data current_user.notification_monthly_winner_enabled = form.notification_monthly_winner_enabled.data
current_user.notification_badge_enabled = form.notification_badge_enabled.data
if form.password.data: if form.password.data:
current_user.set_password(form.password.data) current_user.set_password(form.password.data)
if form.avatar.data: if form.avatar.data:
+145 -24
View File
@@ -2,7 +2,8 @@ from __future__ import annotations
import calendar import calendar
from collections import defaultdict from collections import defaultdict
from datetime import date from datetime import date, timedelta
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
from flask import Blueprint, flash, redirect, render_template, request, url_for from flask import Blueprint, flash, redirect, render_template, request, url_for
from flask_login import current_user, login_required from flask_login import current_user, login_required
@@ -33,10 +34,89 @@ def _secondary_user_choices() -> list[tuple[int, str]]:
return [(0, "Keine zweite Person")] + _user_choices() return [(0, "Keine zweite Person")] + _user_choices()
def _my_tasks_soon_priority(task: TaskInstance) -> int:
order = {
"due_tomorrow": 0,
"due_day_after_tomorrow": 1,
"open": 2,
}
return order.get(task.status, 99)
def _task_sort_key(task: TaskInstance, sort: str) -> tuple:
if sort == "points":
return (-task.points_awarded, task.due_date, task.title.lower())
if sort == "user":
return (task.assignee_label.lower(), task.due_date, task.title.lower())
return (task.due_date, task.title.lower())
def _group_active_tasks(tasks: list[TaskInstance], *, sort: str = "due") -> dict[str, list[TaskInstance]]:
sections = {
"overdue": [],
"today": [],
"soon": [],
"open": [],
}
for task in tasks:
if task.is_completed:
continue
if task.status == "overdue":
sections["overdue"].append(task)
elif task.status == "due_today":
sections["today"].append(task)
elif task.status in {"due_tomorrow", "due_day_after_tomorrow"}:
sections["soon"].append(task)
else:
sections["open"].append(task)
sections["overdue"].sort(key=lambda task: _task_sort_key(task, sort))
sections["today"].sort(key=lambda task: _task_sort_key(task, sort))
sections["soon"].sort(
key=lambda task: (_my_tasks_soon_priority(task),) + _task_sort_key(task, sort)
)
sections["open"].sort(key=lambda task: _task_sort_key(task, sort))
return sections
def _archive_day_priority(day: date, today: date) -> tuple[int, int]:
if day == today:
return (0, 0)
if day == today - timedelta(days=1):
return (1, 0)
if day == today - timedelta(days=2):
return (2, 0)
return (3, -day.toordinal())
def _archive_day_label(day: date, today: date) -> str:
if day == today:
return "Heute"
if day == today - timedelta(days=1):
return "Gestern"
if day == today - timedelta(days=2):
return "Vorgestern"
return day.strftime("%d.%m.%Y")
def _redirect_with_celebration(target_url: str, points: int | None = None):
if not points or points <= 0:
return redirect(target_url)
parts = urlsplit(target_url)
query = dict(parse_qsl(parts.query, keep_blank_values=True))
query["celebrate_points"] = str(points)
redirect_url = urlunsplit(
(parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment)
)
return redirect(redirect_url)
@bp.route("/my-tasks") @bp.route("/my-tasks")
@login_required @login_required
def my_tasks(): def my_tasks():
tasks = ( all_tasks = (
TaskInstance.query.filter( TaskInstance.query.filter(
or_( or_(
TaskInstance.assigned_user_id == current_user.id, TaskInstance.assigned_user_id == current_user.id,
@@ -46,26 +126,15 @@ def my_tasks():
.order_by(TaskInstance.completed_at.is_(None).desc(), TaskInstance.due_date.asc()) .order_by(TaskInstance.completed_at.is_(None).desc(), TaskInstance.due_date.asc())
.all() .all()
) )
refresh_task_statuses(tasks) refresh_task_statuses(all_tasks)
sections = _group_active_tasks(all_tasks)
sections = { completed_count = len([task for task in all_tasks if task.is_completed])
"open": [],
"due_today": [],
"due_tomorrow": [],
"due_day_after_tomorrow": [],
"overdue": [],
"completed": [],
}
for task in tasks:
sections[task.status].append(task)
completed_count = len(sections["completed"])
active_count = ( active_count = (
len(sections["open"]) len(sections["overdue"])
+ len(sections["due_today"]) + len(sections["today"])
+ len(sections["due_tomorrow"]) + len(sections["soon"])
+ len(sections["due_day_after_tomorrow"]) + len(sections["open"])
+ len(sections["overdue"])
) )
completion_ratio = 0 if completed_count + active_count == 0 else round(completed_count / (completed_count + active_count) * 100) completion_ratio = 0 if completed_count + active_count == 0 else round(completed_count / (completed_count + active_count) * 100)
@@ -113,10 +182,9 @@ def all_tasks():
if status != "all": if status != "all":
if status == "soon": if status == "soon":
tasks = [task for task in tasks if task.status in {"due_today", "due_tomorrow", "due_day_after_tomorrow"}] tasks = [task for task in tasks if task.status in {"due_tomorrow", "due_day_after_tomorrow"}]
else: else:
status_map = { status_map = {
"completed": "completed",
"overdue": "overdue", "overdue": "overdue",
"open": "open", "open": "open",
"today": "due_today", "today": "due_today",
@@ -126,15 +194,58 @@ def all_tasks():
selected = status_map.get(status) selected = status_map.get(status)
if selected: if selected:
tasks = [task for task in tasks if task.status == selected] tasks = [task for task in tasks if task.status == selected]
tasks = [task for task in tasks if not task.is_completed]
else:
tasks = [task for task in tasks if not task.is_completed]
sections = _group_active_tasks(tasks, sort=sort)
return render_template( return render_template(
"tasks/all_tasks.html", "tasks/all_tasks.html",
tasks=tasks, tasks=tasks,
sections=sections,
users=User.query.order_by(User.name.asc()).all(), users=User.query.order_by(User.name.asc()).all(),
filters={"status": status, "mine": mine, "user_id": user_filter, "sort": sort}, filters={"status": status, "mine": mine, "user_id": user_filter, "sort": sort},
) )
@bp.route("/archive")
@login_required
def archive_view():
selected_user_id = request.args.get("user_id", type=int) or current_user.id
archive_users = User.query.order_by(User.name.asc()).all()
selected_user = next((user for user in archive_users if user.id == selected_user_id), current_user)
ordered_users = sorted(archive_users, key=lambda user: (user.id != current_user.id, user.name.lower()))
completed_tasks = (
TaskInstance.query.filter_by(completed_by_user_id=selected_user.id)
.filter(TaskInstance.completed_at.isnot(None))
.order_by(TaskInstance.completed_at.desc(), TaskInstance.updated_at.desc())
.all()
)
today = today_local()
grouped: dict[date, list[TaskInstance]] = defaultdict(list)
for task in completed_tasks:
grouped[task.completed_at.date()].append(task)
archive_sections = [
{
"label": _archive_day_label(day, today),
"day": day,
"tasks": grouped[day],
}
for day in sorted(grouped.keys(), key=lambda value: _archive_day_priority(value, today))
]
return render_template(
"tasks/archive.html",
archive_users=ordered_users,
selected_user=selected_user,
archive_sections=archive_sections,
)
@bp.route("/tasks/new", methods=["GET", "POST"]) @bp.route("/tasks/new", methods=["GET", "POST"])
@login_required @login_required
def create(): def create():
@@ -156,6 +267,7 @@ def create():
def quick_create(): def quick_create():
config = get_quick_task_config() config = get_quick_task_config()
created_titles: list[str] = [] created_titles: list[str] = []
total_points = 0
selected_ids = request.form.getlist("quick_win_ids") selected_ids = request.form.getlist("quick_win_ids")
if selected_ids: if selected_ids:
@@ -164,6 +276,7 @@ def quick_create():
task = create_quick_task(quick_win.title, quick_win.effort, current_user, description="Quick-Win") task = create_quick_task(quick_win.title, quick_win.effort, current_user, description="Quick-Win")
complete_task(task, current_user.id) complete_task(task, current_user.id)
created_titles.append(task.title) created_titles.append(task.title)
total_points += task.points_awarded
if request.form.get("include_custom") == "1": if request.form.get("include_custom") == "1":
form = QuickTaskForm(prefix="quick") form = QuickTaskForm(prefix="quick")
@@ -184,6 +297,7 @@ def quick_create():
task = create_quick_task(custom_title, form.effort.data, current_user, description="Quick-Win") task = create_quick_task(custom_title, form.effort.data, current_user, description="Quick-Win")
complete_task(task, current_user.id) complete_task(task, current_user.id)
created_titles.append(task.title) created_titles.append(task.title)
total_points += task.points_awarded
if not created_titles: if not created_titles:
flash("Bitte wähle mindestens einen Quick-Win aus.", "error") flash("Bitte wähle mindestens einen Quick-Win aus.", "error")
@@ -193,7 +307,10 @@ def quick_create():
flash(f"Quick-Win „{created_titles[0]}“ wurde als erledigt gespeichert.", "success") flash(f"Quick-Win „{created_titles[0]}“ wurde als erledigt gespeichert.", "success")
else: else:
flash(f"{len(created_titles)} Quick-Wins wurden als erledigt gespeichert.", "success") flash(f"{len(created_titles)} Quick-Wins wurden als erledigt gespeichert.", "success")
return redirect(request.referrer or url_for("tasks.my_tasks")) return _redirect_with_celebration(
request.referrer or url_for("tasks.my_tasks"),
total_points,
)
@bp.route("/tasks/<int:task_id>/edit", methods=["GET", "POST"]) @bp.route("/tasks/<int:task_id>/edit", methods=["GET", "POST"])
@@ -256,9 +373,13 @@ def complete(task_id: int):
if selected_user_id in allowed_ids: if selected_user_id in allowed_ids:
completed_by_id = selected_user_id completed_by_id = selected_user_id
awarded_points = task.points_awarded
complete_task(task, completed_by_id) complete_task(task, completed_by_id)
flash("Punkte verbucht. Gute Arbeit.", "success") flash("Punkte verbucht. Gute Arbeit.", "success")
return redirect(request.referrer or url_for("tasks.my_tasks")) return _redirect_with_celebration(
request.referrer or url_for("tasks.my_tasks"),
awarded_points,
)
@bp.route("/calendar") @bp.route("/calendar")
+41 -21
View File
@@ -25,24 +25,30 @@ def _max_day_streak(days: set[date]) -> int:
return best return best
def award_badge(user: User, badge_key: str, *, awarded_at: datetime | None = None, context: dict | None = None) -> bool: def award_badge(
user: User,
badge_key: str,
*,
awarded_at: datetime | None = None,
context: dict | None = None,
) -> UserBadge | None:
definition = BadgeDefinition.query.filter_by(key=badge_key, active=True).first() definition = BadgeDefinition.query.filter_by(key=badge_key, active=True).first()
if not definition: if not definition:
return False return None
existing = UserBadge.query.filter_by(user_id=user.id, badge_definition_id=definition.id).first() existing = UserBadge.query.filter_by(user_id=user.id, badge_definition_id=definition.id).first()
if existing: if existing:
return False return None
db.session.add( award = UserBadge(
UserBadge(
user_id=user.id, user_id=user.id,
badge_definition_id=definition.id, badge_definition_id=definition.id,
awarded_at=awarded_at or datetime.utcnow(), awarded_at=awarded_at or datetime.utcnow(),
context=json.dumps(context, sort_keys=True) if context else None, context=json.dumps(context, sort_keys=True) if context else None,
) )
) db.session.add(award)
return True db.session.flush()
return award
def badge_awards_for_month(user_id: int, year: int, month: int) -> list[UserBadge]: def badge_awards_for_month(user_id: int, year: int, month: int) -> list[UserBadge]:
@@ -101,20 +107,26 @@ def _completion_metrics(user: User) -> dict[str, int]:
return metrics return metrics
def evaluate_task_badges(user: User) -> list[str]: def evaluate_task_badges(user: User, *, notify: bool = False) -> list[UserBadge]:
definitions = BadgeDefinition.query.filter_by(active=True).all() definitions = BadgeDefinition.query.filter_by(active=True).all()
metrics = _completion_metrics(user) metrics = _completion_metrics(user)
unlocked: list[str] = [] unlocked: list[UserBadge] = []
for definition in definitions: for definition in definitions:
metric_value = metrics.get(definition.trigger_type) metric_value = metrics.get(definition.trigger_type)
if metric_value is None: if metric_value is None:
continue continue
if metric_value >= definition.threshold and award_badge(user, definition.key): if metric_value >= definition.threshold:
unlocked.append(definition.name) award = award_badge(user, definition.key)
if award:
unlocked.append(award)
if unlocked: if unlocked:
db.session.commit() db.session.commit()
if notify:
from .notifications import send_badge_notifications_for_awards
send_badge_notifications_for_awards(unlocked)
return unlocked return unlocked
@@ -151,31 +163,39 @@ def _winner_user_ids(year: int, month: int) -> set[int]:
return {row.user_id for row in rows} return {row.user_id for row in rows}
def evaluate_monthly_badges(year: int, month: int) -> list[str]: def evaluate_monthly_badges(year: int, month: int, *, notify: bool = False) -> list[UserBadge]:
award_time = _month_end_award_time(year, month) award_time = _month_end_award_time(year, month)
unlocked: list[str] = [] unlocked: list[UserBadge] = []
winners = _winner_user_ids(year, month) winners = _winner_user_ids(year, month)
previous_year, previous_month_value = previous_month(year, month) previous_year, previous_month_value = previous_month(year, month)
previous_winners = _winner_user_ids(previous_year, previous_month_value) previous_winners = _winner_user_ids(previous_year, previous_month_value)
for user in User.query.order_by(User.id.asc()).all(): for user in User.query.order_by(User.id.asc()).all():
if user.id in winners: if user.id in winners:
if award_badge(user, "monthly_champion", awarded_at=award_time, context={"year": year, "month": month}): award = award_badge(user, "monthly_champion", awarded_at=award_time, context={"year": year, "month": month})
unlocked.append(f"{user.name}: Monatssieger") if award:
unlocked.append(award)
if user.id in previous_winners: if user.id in previous_winners:
if award_badge(user, "title_defender", awarded_at=award_time, context={"year": year, "month": month}): award = award_badge(user, "title_defender", awarded_at=award_time, context={"year": year, "month": month})
unlocked.append(f"{user.name}: Titelverteidiger") if award:
unlocked.append(award)
elif MonthlyScoreSnapshot.query.filter_by(year=previous_year, month=previous_month_value, user_id=user.id).first(): elif MonthlyScoreSnapshot.query.filter_by(year=previous_year, month=previous_month_value, user_id=user.id).first():
if award_badge(user, "comeback_kid", awarded_at=award_time, context={"year": year, "month": month}): award = award_badge(user, "comeback_kid", awarded_at=award_time, context={"year": year, "month": month})
unlocked.append(f"{user.name}: Comeback Kid") if award:
unlocked.append(award)
if _user_had_clean_month(user.id, year, month): if _user_had_clean_month(user.id, year, month):
if award_badge(user, "white_glove", awarded_at=award_time, context={"year": year, "month": month}): award = award_badge(user, "white_glove", awarded_at=award_time, context={"year": year, "month": month})
unlocked.append(f"{user.name}: Weiße Weste") if award:
unlocked.append(award)
if unlocked: if unlocked:
db.session.commit() db.session.commit()
if notify:
from .notifications import send_badge_notifications_for_awards
send_badge_notifications_for_awards(unlocked)
return unlocked return unlocked
+4
View File
@@ -21,6 +21,10 @@ def ensure_schema_and_admins() -> None:
db.session.execute(text("ALTER TABLE user ADD COLUMN calendar_feed_token VARCHAR(255)")) db.session.execute(text("ALTER TABLE user ADD COLUMN calendar_feed_token VARCHAR(255)"))
db.session.commit() db.session.commit()
if "notification_badge_enabled" not in column_names:
db.session.execute(text("ALTER TABLE user ADD COLUMN notification_badge_enabled BOOLEAN NOT NULL DEFAULT 1"))
db.session.commit()
task_template_columns = {column["name"] for column in inspector.get_columns("task_template")} task_template_columns = {column["name"] for column in inspector.get_columns("task_template")}
if "default_assigned_user_secondary_id" not in task_template_columns: if "default_assigned_user_secondary_id" not in task_template_columns:
db.session.execute(text("ALTER TABLE task_template ADD COLUMN default_assigned_user_secondary_id INTEGER")) db.session.execute(text("ALTER TABLE task_template ADD COLUMN default_assigned_user_secondary_id INTEGER"))
+1 -1
View File
@@ -97,7 +97,7 @@ def archive_months_missing_up_to_previous() -> None:
) )
) )
db.session.commit() db.session.commit()
evaluate_monthly_badges(year, month) evaluate_monthly_badges(year, month, notify=True)
year, month = next_month(year, month) year, month = next_month(year, month)
+114 -55
View File
@@ -2,14 +2,14 @@ from __future__ import annotations
import json import json
from dataclasses import dataclass from dataclasses import dataclass
from datetime import timedelta
from urllib.parse import urljoin from urllib.parse import urljoin
from flask import current_app from flask import current_app
from pywebpush import WebPushException, webpush from pywebpush import WebPushException, webpush
from sqlalchemy import or_
from ..extensions import db from ..extensions import db
from ..models import NotificationLog, PushSubscription, TaskInstance, User from ..models import NotificationLog, PushSubscription, TaskInstance, User, UserBadge
from .monthly import archive_months_missing_up_to_previous, get_snapshot_rows from .monthly import archive_months_missing_up_to_previous, get_snapshot_rows
from .dates import local_now, previous_month from .dates import local_now, previous_month
@@ -60,44 +60,20 @@ def _send_subscription(subscription: PushSubscription, payload: dict) -> bool:
return False return False
def send_due_notifications() -> NotificationResult: def _subscriptions_for_user(user_id: int) -> list[PushSubscription]:
return PushSubscription.query.filter_by(user_id=user_id).all()
def _send_payload_to_user(user: User, notification_type: str, marker: dict, payload: dict) -> NotificationResult:
result = NotificationResult() result = NotificationResult()
if not push_enabled(): subscriptions = _subscriptions_for_user(user.id)
if not subscriptions:
result.skipped += 1 result.skipped += 1
return result return result
today = local_now().date() if _notification_exists(user.id, notification_type, marker):
relevant_tasks = TaskInstance.query.filter(
TaskInstance.completed_at.is_(None),
TaskInstance.assigned_user_id.isnot(None),
TaskInstance.due_date <= today + timedelta(days=1),
).all()
for task in relevant_tasks:
user = task.assigned_user
if not user or not user.notification_task_due_enabled:
result.skipped += 1 result.skipped += 1
continue return result
payload_marker = {"task_instance_id": task.id, "due_date": task.due_date.isoformat()}
if _notification_exists(user.id, "task_due", payload_marker):
result.skipped += 1
continue
subscriptions = PushSubscription.query.filter_by(user_id=user.id).all()
if not subscriptions:
result.skipped += 1
continue
body = "Heute ist ein guter Tag für Punkte." if task.due_date <= today else "Morgen wird's fällig."
payload = {
"title": f"Putzliga erinnert: {task.title}",
"body": body,
"icon": _absolute_url("/static/images/pwa-icon-192.png"),
"badge": _absolute_url("/static/images/pwa-badge.png"),
"url": _absolute_url("/my-tasks"),
"tag": f"task-{task.id}",
}
sent_any = False sent_any = False
for subscription in subscriptions: for subscription in subscriptions:
@@ -108,8 +84,72 @@ def send_due_notifications() -> NotificationResult:
result.failed += 1 result.failed += 1
if sent_any: if sent_any:
_log_notification(user.id, "task_due", payload_marker) _log_notification(user.id, notification_type, marker)
db.session.commit() db.session.commit()
elif result.failed == 0:
result.skipped += 1
return result
def _merge_results(base: NotificationResult, extra: NotificationResult) -> NotificationResult:
base.sent += extra.sent
base.skipped += extra.skipped
base.failed += extra.failed
return base
def send_due_notifications() -> NotificationResult:
result = NotificationResult()
if not push_enabled():
result.skipped += 1
return result
now = local_now()
if now.hour < 9:
result.skipped += 1
return result
today = now.date()
relevant_tasks = TaskInstance.query.filter(
TaskInstance.completed_at.is_(None),
TaskInstance.due_date == today,
or_(
TaskInstance.assigned_user_id.isnot(None),
TaskInstance.assigned_user_secondary_id.isnot(None),
),
).all()
tasks_by_user: dict[int, list[TaskInstance]] = {}
for task in relevant_tasks:
for assigned_user in task.assigned_users:
tasks_by_user.setdefault(assigned_user.id, []).append(task)
for user in User.query.order_by(User.name.asc()).all():
if not user.notification_task_due_enabled:
result.skipped += 1
continue
personal_tasks = tasks_by_user.get(user.id, [])
if not personal_tasks:
result.skipped += 1
continue
task_count = len(personal_tasks)
payload = {
"title": "Putzliga für heute",
"body": (
"Heute wartet 1 offene Aufgabe auf dich. Zeit zum Punkte sammeln."
if task_count == 1
else f"Heute warten {task_count} offene Aufgaben auf dich. Zeit zum Punkte sammeln."
),
"icon": _absolute_url("/static/images/pwa-icon-192.png"),
"badge": _absolute_url("/static/images/pwa-badge.png"),
"url": _absolute_url("/my-tasks"),
"tag": f"due-{today.isoformat()}-{user.id}",
}
marker = {"date": today.isoformat()}
_merge_results(result, _send_payload_to_user(user, "task_due_daily", marker, payload))
return result return result
@@ -140,14 +180,6 @@ def send_monthly_winner_notifications() -> NotificationResult:
if not user.notification_monthly_winner_enabled: if not user.notification_monthly_winner_enabled:
result.skipped += 1 result.skipped += 1
continue continue
if _notification_exists(user.id, "monthly_winner", marker):
result.skipped += 1
continue
subscriptions = PushSubscription.query.filter_by(user_id=user.id).all()
if not subscriptions:
result.skipped += 1
continue
payload = { payload = {
"title": "Der Haushalts-Champion des letzten Monats steht fest", "title": "Der Haushalts-Champion des letzten Monats steht fest",
@@ -157,16 +189,43 @@ def send_monthly_winner_notifications() -> NotificationResult:
"url": _absolute_url(f"/scoreboard?archive={target_year}-{target_month:02d}"), "url": _absolute_url(f"/scoreboard?archive={target_year}-{target_month:02d}"),
"tag": f"winner-{target_year}-{target_month}", "tag": f"winner-{target_year}-{target_month}",
} }
sent_any = False _merge_results(result, _send_payload_to_user(user, "monthly_winner", marker, payload))
for subscription in subscriptions:
if _send_subscription(subscription, payload):
sent_any = True
result.sent += 1
else:
result.failed += 1
if sent_any:
_log_notification(user.id, "monthly_winner", marker)
db.session.commit()
return result return result
def send_badge_notifications_for_awards(awards: list[UserBadge]) -> NotificationResult:
result = NotificationResult()
if not push_enabled():
result.skipped += len(awards) or 1
return result
for award in awards:
user = award.user
definition = award.badge_definition
if not user or not definition:
result.skipped += 1
continue
if not user.notification_badge_enabled:
result.skipped += 1
continue
marker = {"user_badge_id": award.id}
payload = {
"title": "Neues Badge freigeschaltet",
"body": f"{definition.name}: {definition.description}",
"icon": _absolute_url("/static/images/pwa-icon-192.png"),
"badge": _absolute_url("/static/images/pwa-badge.png"),
"url": _absolute_url("/settings"),
"tag": f"badge-{award.id}",
}
_merge_results(result, _send_payload_to_user(user, "badge_award", marker, payload))
return result
def run_scheduled_notifications() -> dict[str, NotificationResult]:
return {
"daily_due": send_due_notifications(),
"monthly_winner": send_monthly_winner_notifications(),
}
+1 -1
View File
@@ -139,7 +139,7 @@ def complete_task(task: TaskInstance, completed_by_user_id: int) -> TaskInstance
ensure_next_recurring_task(task) ensure_next_recurring_task(task)
db.session.commit() db.session.commit()
if task.completed_by_user: if task.completed_by_user:
evaluate_task_badges(task.completed_by_user) evaluate_task_badges(task.completed_by_user, notify=True)
return task return task
+332 -24
View File
@@ -295,6 +295,22 @@ p {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 14px; gap: 14px;
min-width: 0;
}
.brand__mark {
width: 58px;
height: 58px;
flex: 0 0 58px;
display: inline-flex;
align-items: center;
justify-content: center;
overflow: hidden;
border-radius: 20px;
background: radial-gradient(circle at 50% 42%, rgba(112, 207, 255, 0.38), rgba(37, 99, 235, 0.08));
box-shadow:
0 18px 38px rgba(37, 99, 235, 0.18),
inset 0 0 0 1px rgba(255, 255, 255, 0.24);
} }
.brand strong { .brand strong {
@@ -308,8 +324,37 @@ p {
} }
.brand__logo { .brand__logo {
width: 48px; width: 100%;
height: 48px; height: 100%;
object-fit: cover;
}
.brand--public .brand__mark {
width: 64px;
height: 64px;
flex-basis: 64px;
border-radius: 22px;
}
.hero-card__brand-mark {
width: 108px;
height: 108px;
margin-bottom: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
overflow: hidden;
border-radius: 32px;
background: radial-gradient(circle at 50% 40%, rgba(112, 207, 255, 0.42), rgba(37, 99, 235, 0.12));
box-shadow:
0 22px 48px rgba(37, 99, 235, 0.22),
inset 0 0 0 1px rgba(255, 255, 255, 0.26);
}
.hero-card__brand-mark img {
width: 100%;
height: 100%;
object-fit: cover;
} }
.flash-stack { .flash-stack {
@@ -365,6 +410,11 @@ p {
gap: 14px; gap: 14px;
} }
.form-actions {
flex-wrap: wrap;
min-width: 0;
}
.progress { .progress {
margin-top: 12px; margin-top: 12px;
height: 14px; height: 14px;
@@ -429,8 +479,8 @@ p {
.task-card { .task-card {
display: grid; display: grid;
gap: 16px; gap: 14px;
padding: 20px; padding: 18px;
} }
.task-card__top { .task-card__top {
@@ -460,7 +510,7 @@ p {
} }
.task-card h3 { .task-card h3 {
font-size: 1.35rem; font-size: 1.24rem;
line-height: 1.08; line-height: 1.08;
overflow-wrap: break-word; overflow-wrap: break-word;
hyphens: auto; hyphens: auto;
@@ -532,8 +582,8 @@ p {
.task-meta { .task-meta {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 14px; gap: 10px 14px;
} }
.task-meta dt { .task-meta dt {
@@ -549,11 +599,16 @@ p {
font-weight: 600; font-weight: 600;
} }
.task-card--compact .task-meta {
grid-template-columns: 1fr;
}
.task-assignee { .task-assignee {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
color: var(--muted); color: var(--muted);
min-width: 0;
} }
.task-assignee__avatars { .task-assignee__avatars {
@@ -566,6 +621,10 @@ p {
border: 2px solid var(--surface-strong); border: 2px solid var(--surface-strong);
} }
.task-assignee span:last-child {
overflow-wrap: break-word;
}
.avatar { .avatar {
width: 34px; width: 34px;
height: 34px; height: 34px;
@@ -712,6 +771,46 @@ p {
gap: 14px; gap: 14px;
} }
.quick-actions {
display: grid;
gap: 12px;
}
.archive-user-tabs {
display: flex;
gap: 12px;
overflow-x: auto;
padding-bottom: 4px;
margin-bottom: 8px;
}
.archive-user-tab {
flex: 0 0 auto;
min-width: 108px;
display: grid;
justify-items: center;
gap: 8px;
padding: 14px 16px;
border-radius: 20px;
border: 1px solid var(--border);
background: var(--surface);
box-shadow: var(--shadow);
color: var(--muted);
text-align: center;
}
.archive-user-tab span {
font-size: 0.92rem;
font-weight: 700;
line-height: 1.15;
}
.archive-user-tab.is-active {
color: var(--text);
border-color: rgba(37, 99, 235, 0.22);
background: linear-gradient(135deg, rgba(37, 99, 235, 0.14), rgba(52, 211, 153, 0.1));
}
.panel--toolbar { .panel--toolbar {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -1281,6 +1380,41 @@ p {
} }
@media (max-width: 640px) { @media (max-width: 640px) {
.brand {
gap: 12px;
}
.brand__mark {
width: 50px;
height: 50px;
flex-basis: 50px;
border-radius: 18px;
}
.brand--public .brand__mark,
.hero-card__brand-mark {
width: 88px;
height: 88px;
flex-basis: 88px;
border-radius: 28px;
}
.archive-user-tabs {
gap: 10px;
margin-bottom: 4px;
}
.archive-user-tab {
min-width: 92px;
padding: 12px 12px;
gap: 6px;
border-radius: 18px;
}
.archive-user-tab span {
font-size: 0.82rem;
}
.task-card { .task-card {
gap: 14px; gap: 14px;
padding: 18px; padding: 18px;
@@ -1313,11 +1447,13 @@ p {
.form-panel h2 { .form-panel h2 {
font-size: 1.85rem; font-size: 1.85rem;
line-height: 1.08; line-height: 1.08;
overflow-wrap: break-word;
hyphens: auto;
} }
.complete-dialog__surface { .complete-dialog__surface {
width: min(100vw - 16px, 410px); width: min(410px, 100%);
max-width: calc(100vw - 16px); max-width: 100%;
padding: 18px 16px; padding: 18px 16px;
gap: 14px; gap: 14px;
overflow-x: hidden; overflow-x: hidden;
@@ -1349,11 +1485,12 @@ p {
} }
.quick-win-tag span { .quick-win-tag span {
width: auto; width: 100%;
max-width: 100%; max-width: 100%;
padding: 6px 11px; padding: 6px 11px;
font-size: 0.84rem; font-size: 0.84rem;
white-space: nowrap; white-space: normal;
overflow-wrap: anywhere;
text-align: center; text-align: center;
justify-content: center; justify-content: center;
} }
@@ -1362,6 +1499,26 @@ p {
width: auto; width: auto;
} }
.form-actions {
display: grid;
grid-template-columns: 1fr;
align-items: stretch;
}
.celebration-score {
width: 80vw;
font-size: clamp(5.5rem, 28vw, 8.5rem);
}
.celebration-glow {
width: min(78vw, 240px);
}
.form-actions .button,
.form-actions a.button {
width: 100%;
}
.quick-win-list__toolbar { .quick-win-list__toolbar {
justify-content: stretch; justify-content: stretch;
} }
@@ -1405,8 +1562,8 @@ p {
right: 8px; right: 8px;
bottom: calc(10px + env(safe-area-inset-bottom)); bottom: calc(10px + env(safe-area-inset-bottom));
display: grid; display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr)); grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 4px; gap: 3px;
padding: 8px 8px; padding: 8px 8px;
border-radius: 22px; border-radius: 22px;
background: var(--nav-bg); background: var(--nav-bg);
@@ -1420,13 +1577,13 @@ p {
.nav-link { .nav-link {
display: grid; display: grid;
justify-items: center; justify-items: center;
gap: 5px; gap: 4px;
min-width: 0; min-width: 0;
padding: 10px 4px 9px; padding: 10px 3px 9px;
color: var(--muted); color: var(--muted);
border-radius: 16px; border-radius: 16px;
text-align: center; text-align: center;
font-size: 0.66rem; font-size: 0.61rem;
font-weight: 700; font-weight: 700;
line-height: 1.05; line-height: 1.05;
} }
@@ -1434,7 +1591,7 @@ p {
.bottom-nav__item span { .bottom-nav__item span {
display: block; display: block;
width: 100%; width: 100%;
min-height: 2.1em; min-height: 2.3em;
white-space: normal; white-space: normal;
word-break: keep-all; word-break: keep-all;
overflow-wrap: break-word; overflow-wrap: break-word;
@@ -1448,15 +1605,33 @@ p {
.nav-icon, .nav-icon,
.nav-icon svg { .nav-icon svg {
width: 20px; width: 18px;
height: 20px; height: 18px;
display: inline-block; display: inline-block;
} }
.complete-dialog { .complete-dialog {
inset: 0;
width: 100vw;
max-width: 100vw;
min-height: 100vh;
min-height: 100dvh;
margin: 0;
padding: 12px;
border: 0; border: 0;
padding: 0;
background: transparent; background: transparent;
overflow: visible;
box-sizing: border-box;
}
.complete-dialog:not([open]) {
display: none;
}
.complete-dialog[open] {
display: flex;
align-items: center;
justify-content: center;
} }
.complete-dialog::backdrop { .complete-dialog::backdrop {
@@ -1465,23 +1640,89 @@ p {
} }
.complete-dialog__surface { .complete-dialog__surface {
width: min(460px, calc(100vw - 24px)); width: min(460px, 100%);
max-width: 100%;
padding: 24px; padding: 24px;
border-radius: 28px; border-radius: 28px;
background: var(--surface-strong); background: var(--surface-strong);
box-shadow: var(--shadow); box-shadow: var(--shadow);
display: grid; display: grid;
gap: 18px; gap: 18px;
min-width: 0;
} }
.complete-dialog__surface--task { .complete-dialog__surface--task {
width: min(520px, calc(100vw - 24px)); width: min(520px, 100%);
max-width: calc(100vw - 24px); max-width: 100%;
overflow-x: hidden; overflow-x: hidden;
overscroll-behavior-x: contain; overscroll-behavior-x: contain;
touch-action: pan-y; touch-action: pan-y;
} }
.celebration-layer {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 90;
overflow: hidden;
}
.celebration-score,
.celebration-glow,
.celebration-particle {
position: absolute;
left: 50%;
top: 50%;
}
.celebration-score {
width: min(80vw, 520px);
padding: 0;
background:
linear-gradient(
135deg,
rgba(255, 255, 255, 0.98) 0%,
rgba(214, 234, 255, 0.92) 18%,
rgba(94, 168, 255, 0.72) 42%,
rgba(52, 211, 153, 0.74) 70%,
rgba(255, 255, 255, 0.92) 100%
);
color: transparent;
-webkit-text-fill-color: transparent;
-webkit-background-clip: text;
background-clip: text;
-webkit-text-stroke: 1px rgba(255, 255, 255, 0.22);
font-size: clamp(6.5rem, 28vw, 12rem);
font-weight: 900;
line-height: 0.9;
letter-spacing: -0.08em;
text-align: center;
transform: translate(-50%, -50%);
filter:
drop-shadow(0 10px 24px rgba(94, 168, 255, 0.18))
drop-shadow(0 0 18px rgba(255, 255, 255, 0.18));
animation: celebration-score-in 1.15s cubic-bezier(0.18, 0.84, 0.24, 1) forwards;
}
.celebration-glow {
width: min(56vw, 280px);
aspect-ratio: 1;
border-radius: 50%;
background:
radial-gradient(circle, rgba(94, 168, 255, 0.26) 0%, rgba(52, 211, 153, 0.2) 42%, rgba(94, 168, 255, 0) 74%);
transform: translate(-50%, -50%);
filter: blur(10px);
animation: celebration-glow 0.95s ease-out forwards;
}
.celebration-particle {
width: var(--size, 10px);
height: var(--size, 10px);
border-radius: 999px;
transform: translate(-50%, -50%) rotate(var(--angle)) translateY(-4px);
animation: celebration-particle 0.82s ease-out var(--delay, 0s) forwards;
}
.choice-grid { .choice-grid {
display: grid; display: grid;
gap: 12px; gap: 12px;
@@ -1501,7 +1742,7 @@ p {
.fab-quick-task { .fab-quick-task {
position: fixed; position: fixed;
right: max(16px, env(safe-area-inset-right)); right: max(16px, env(safe-area-inset-right));
bottom: calc(72px + var(--safe-bottom)); bottom: calc(86px + var(--safe-bottom));
width: 62px; width: 62px;
height: 62px; height: 62px;
border: 2px solid rgba(255, 255, 255, 0.55); border: 2px solid rgba(255, 255, 255, 0.55);
@@ -1522,6 +1763,61 @@ p {
height: 24px; height: 24px;
} }
@keyframes celebration-score-in {
0% {
opacity: 0;
transform: translate(-50%, -42%) scale(0.74);
filter: blur(16px);
}
12% {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
filter: blur(0);
}
58% {
opacity: 1;
transform: translate(-50%, -53%) scale(1.03);
filter: blur(0);
}
100% {
opacity: 0;
transform: translate(-50%, -76%) scale(1.08);
filter: blur(20px);
}
}
@keyframes celebration-glow {
0% {
opacity: 0;
transform: translate(-50%, -50%) scale(0.32);
}
22% {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
100% {
opacity: 0;
transform: translate(-50%, -50%) scale(1.42);
}
}
@keyframes celebration-particle {
0% {
opacity: 0;
transform: translate(-50%, -50%) rotate(var(--angle)) translateY(0) scale(0.4);
filter: blur(4px);
}
18% {
opacity: 1;
filter: blur(0);
}
100% {
opacity: 0;
transform: translate(-50%, -50%) rotate(var(--angle)) translateY(calc(var(--distance) * -1)) scale(0.9);
filter: blur(6px);
}
}
@media (max-width: 759px) { @media (max-width: 759px) {
.calendar-toolbar-mobile__header { .calendar-toolbar-mobile__header {
align-items: flex-start; align-items: flex-start;
@@ -1725,6 +2021,18 @@ p {
} }
} }
@media (prefers-reduced-motion: reduce) {
.celebration-score {
animation-duration: 0.46s;
}
.celebration-glow,
.celebration-particle {
animation: none;
opacity: 0;
}
}
@media (min-width: 1100px) { @media (min-width: 1100px) {
.app-shell { .app-shell {
display: grid; display: grid;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 631 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 587 KiB

+149 -2
View File
@@ -20,9 +20,50 @@
const quickWinSortIds = document.getElementById("quickWinSortIds"); const quickWinSortIds = document.getElementById("quickWinSortIds");
const quickWinSortSave = document.getElementById("quickWinSortSave"); const quickWinSortSave = document.getElementById("quickWinSortSave");
const quickWinToggleButtons = document.querySelectorAll("[data-quick-win-toggle]"); 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 draggedQuickWin = null;
let quickWinSortDirty = false; 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) { function buildCompletionOptions(button) {
const options = []; const options = [];
const assignedPairs = [ const assignedPairs = [
@@ -60,6 +101,7 @@
choiceButton.textContent = option.label; choiceButton.textContent = option.label;
choiceButton.addEventListener("click", () => { choiceButton.addEventListener("click", () => {
dialogChoice.value = option.value; dialogChoice.value = option.value;
rememberScrollPosition();
dialog.close(); dialog.close();
dialogForm.submit(); dialogForm.submit();
}); });
@@ -74,12 +116,32 @@
} }
if (quickTaskOpen && quickTaskDialog) { if (quickTaskOpen && quickTaskDialog) {
quickTaskOpen.addEventListener("click", () => quickTaskDialog.showModal()); quickTaskOpen.addEventListener("click", () => {
if (!quickTaskDialog.open) {
quickTaskDialog.showModal();
}
});
} }
if (quickTaskClose && quickTaskDialog) { if (quickTaskClose && quickTaskDialog) {
quickTaskClose.addEventListener("click", () => quickTaskDialog.close()); quickTaskClose.addEventListener("click", () => {
if (quickTaskDialog.open) {
quickTaskDialog.close();
} }
});
}
[dialog, quickTaskDialog].forEach((activeDialog) => {
if (!activeDialog) {
return;
}
activeDialog.addEventListener("click", (event) => {
if (event.target === activeDialog && activeDialog.open) {
activeDialog.close();
}
});
});
function updateQuickWinsState() { function updateQuickWinsState() {
const selectedPresetCount = [...quickWinInputs].filter((input) => input.checked).length; const selectedPresetCount = [...quickWinInputs].filter((input) => input.checked).length;
@@ -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() { function syncQuickWinSortIds() {
if (!quickWinSortList || !quickWinSortIds) { if (!quickWinSortList || !quickWinSortIds) {
return; 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) { if (quickWinSortList) {
syncQuickWinSortIds(); syncQuickWinSortIds();
setQuickWinSortDirty(false); setQuickWinSortDirty(false);
@@ -282,4 +422,11 @@
togglePush().catch((error) => console.error("Push toggle failed", error)); togglePush().catch((error) => console.error("Push toggle failed", error));
}); });
} }
if (Number.isFinite(celebratePoints) && celebratePoints > 0) {
celebrateCompletion(celebratePoints);
clearCelebrationQuery();
}
restoreScrollPosition();
})(); })();
+4 -2
View File
@@ -1,8 +1,10 @@
const CACHE_NAME = "putzliga-shell-v2"; const CACHE_NAME = "putzliga-shell-v4";
const ASSETS = [ const ASSETS = [
"/static/css/style.css", "/static/css/style.css",
"/static/js/app.js", "/static/js/app.js",
"/static/images/logo.svg", "/static/images/favicon.png",
"/static/images/apple-touch-icon.png",
"/static/images/logo-mark.png",
"/static/images/pwa-icon-192.png", "/static/images/pwa-icon-192.png",
"/static/images/pwa-icon-512.png" "/static/images/pwa-icon-512.png"
]; ];
+3
View File
@@ -3,6 +3,9 @@
{% block content %} {% block content %}
<section class="auth-layout"> <section class="auth-layout">
<div class="hero-card hero-card--brand"> <div class="hero-card hero-card--brand">
<div class="hero-card__brand-mark">
<img src="{{ url_for('static', filename='images/logo-mark.png') }}" alt="Putzliga Logo">
</div>
<p class="eyebrow">Leichtgewichtige Haushalts-App</p> <p class="eyebrow">Leichtgewichtige Haushalts-App</p>
<h2>Putzliga bringt Punkte, Rhythmus und ein bisschen Liga-Stimmung in den Alltag.</h2> <h2>Putzliga bringt Punkte, Rhythmus und ein bisschen Liga-Stimmung in den Alltag.</h2>
<p>Mehrere Nutzer, wiederkehrende Aufgaben, Monats-Highscore, Archiv, Kalender und PWA-Pushs in einer bewusst schlanken Flask-App.</p> <p>Mehrere Nutzer, wiederkehrende Aufgaben, Monats-Highscore, Archiv, Kalender und PWA-Pushs in einer bewusst schlanken Flask-App.</p>
+3 -1
View File
@@ -3,6 +3,9 @@
{% block content %} {% block content %}
<section class="auth-layout"> <section class="auth-layout">
<div class="hero-card hero-card--brand"> <div class="hero-card hero-card--brand">
<div class="hero-card__brand-mark">
<img src="{{ url_for('static', filename='images/logo-mark.png') }}" alt="Putzliga Logo">
</div>
<p class="eyebrow">Gemeinsam sauberer</p> <p class="eyebrow">Gemeinsam sauberer</p>
<h2>Erstelle dein Konto und steig direkt in die Liga ein.</h2> <h2>Erstelle dein Konto und steig direkt in die Liga ein.</h2>
<p>Nach dem Login landest du sofort bei „Meine Aufgaben“ und kannst Aufgaben anlegen, verteilen und Punkte sammeln.</p> <p>Nach dem Login landest du sofort bei „Meine Aufgaben“ und kannst Aufgaben anlegen, verteilen und Punkte sammeln.</p>
@@ -39,4 +42,3 @@
</section> </section>
</section> </section>
{% endblock %} {% endblock %}
+11 -3
View File
@@ -12,7 +12,8 @@
<meta name="description" content="Putzliga macht Haushaltsaufgaben leichter, motivierender und fair verteilt."> <meta name="description" content="Putzliga macht Haushaltsaufgaben leichter, motivierender und fair verteilt.">
<title>{% block title %}{{ app_name }}{% endblock %}</title> <title>{% block title %}{{ app_name }}{% endblock %}</title>
<link rel="manifest" href="{{ url_for('main.manifest') }}"> <link rel="manifest" href="{{ url_for('main.manifest') }}">
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='images/favicon.svg') }}"> <link rel="icon" type="image/png" sizes="128x128" href="{{ url_for('static', filename='images/favicon.png') }}">
<link rel="shortcut icon" href="{{ url_for('static', filename='images/favicon.png') }}">
<link rel="apple-touch-icon" href="{{ url_for('static', filename='images/apple-touch-icon.png') }}"> <link rel="apple-touch-icon" href="{{ url_for('static', filename='images/apple-touch-icon.png') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css', v=asset_version('css/style.css')) }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css', v=asset_version('css/style.css')) }}">
</head> </head>
@@ -20,13 +21,16 @@
data-push-key="{{ config['VAPID_PUBLIC_KEY'] if current_user.is_authenticated else '' }}" 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-id="{{ current_user.id if current_user.is_authenticated else '' }}"
data-current-user-name="{{ current_user.name if current_user.is_authenticated else '' }}" data-current-user-name="{{ current_user.name if current_user.is_authenticated else '' }}"
data-celebrate-points="{{ request.args.get('celebrate_points', '') }}"
> >
{% from "partials/macros.html" import nav_icon %} {% from "partials/macros.html" import nav_icon %}
<div class="app-shell {% if not current_user.is_authenticated %}app-shell--auth{% endif %}"> <div class="app-shell {% if not current_user.is_authenticated %}app-shell--auth{% endif %}">
{% if current_user.is_authenticated %} {% if current_user.is_authenticated %}
<aside class="sidebar"> <aside class="sidebar">
<a class="brand" href="{{ url_for('tasks.my_tasks') }}"> <a class="brand" href="{{ url_for('tasks.my_tasks') }}">
<img src="{{ url_for('static', filename='images/logo.svg') }}" alt="Putzliga Logo" class="brand__logo"> <span class="brand__mark">
<img src="{{ url_for('static', filename='images/logo-mark.png') }}" alt="Putzliga Logo" class="brand__logo">
</span>
<div> <div>
<strong>Putzliga</strong> <strong>Putzliga</strong>
<span>Haushalt mit Punktestand</span> <span>Haushalt mit Punktestand</span>
@@ -68,7 +72,9 @@
</a> </a>
{% else %} {% else %}
<a class="brand brand--public" href="{{ url_for('auth.login') }}"> <a class="brand brand--public" href="{{ url_for('auth.login') }}">
<img src="{{ url_for('static', filename='images/logo.svg') }}" alt="Putzliga Logo" class="brand__logo"> <span class="brand__mark">
<img src="{{ url_for('static', filename='images/logo-mark.png') }}" alt="Putzliga Logo" class="brand__logo">
</span>
<div> <div>
<strong>Putzliga</strong> <strong>Putzliga</strong>
<span>Haushaltsaufgaben mit Liga-Gefühl</span> <span>Haushaltsaufgaben mit Liga-Gefühl</span>
@@ -105,6 +111,8 @@
</div> </div>
{% if current_user.is_authenticated %} {% if current_user.is_authenticated %}
<div class="celebration-layer" id="celebrationLayer" hidden aria-hidden="true"></div>
<nav class="bottom-nav" aria-label="Mobile Navigation"> <nav class="bottom-nav" aria-label="Mobile Navigation">
{% for endpoint, label, icon in mobile_nav_items %} {% for endpoint, label, icon in mobile_nav_items %}
<a href="{{ url_for(endpoint) }}" class="bottom-nav__item {% if request.endpoint == endpoint %}is-active{% endif %}"> <a href="{{ url_for(endpoint) }}" class="bottom-nav__item {% if request.endpoint == endpoint %}is-active{% endif %}">
+2 -10
View File
@@ -66,18 +66,10 @@
<dt>Fällig</dt> <dt>Fällig</dt>
<dd>{{ task.due_date|date_de }}</dd> <dd>{{ task.due_date|date_de }}</dd>
</div> </div>
<div>
<dt>Zuständig</dt>
<dd>{{ task.assignee_label }}</dd>
</div>
<div>
<dt>Rhythmus</dt>
<dd>{{ task.task_template.recurrence_label }}</dd>
</div>
{% if task.completed_at %} {% if task.completed_at %}
<div> <div>
<dt>Erledigt von</dt> <dt>Erledigt</dt>
<dd>{{ task.completed_by_user.name if task.completed_by_user else '—' }}</dd> <dd>{{ task.completed_at|datetime_de }}</dd>
</div> </div>
{% endif %} {% endif %}
</dl> </dl>
+5 -1
View File
@@ -40,12 +40,16 @@
</div> </div>
<label class="checkbox"> <label class="checkbox">
{{ form.notification_task_due_enabled() }} {{ form.notification_task_due_enabled() }}
<span>Push für heute oder morgen fällige Aufgaben</span> <span>Täglicher Push um 09:00 Uhr, wenn heute Aufgaben für dich offen sind</span>
</label> </label>
<label class="checkbox"> <label class="checkbox">
{{ form.notification_monthly_winner_enabled() }} {{ form.notification_monthly_winner_enabled() }}
<span>Push zum Monatssieger am 1. um 09:00 Uhr</span> <span>Push zum Monatssieger am 1. um 09:00 Uhr</span>
</label> </label>
<label class="checkbox">
{{ form.notification_badge_enabled() }}
<span>Push, wenn du ein neues Badge freischaltest</span>
</label>
{{ form.submit(class_='button') }} {{ form.submit(class_='button') }}
</form> </form>
</article> </article>
+58 -1
View File
@@ -15,7 +15,6 @@
<option value="tomorrow" {% if filters.status == 'tomorrow' %}selected{% endif %}>Morgen fällig</option> <option value="tomorrow" {% if filters.status == 'tomorrow' %}selected{% endif %}>Morgen fällig</option>
<option value="day_after_tomorrow" {% if filters.status == 'day_after_tomorrow' %}selected{% endif %}>Übermorgen fällig</option> <option value="day_after_tomorrow" {% if filters.status == 'day_after_tomorrow' %}selected{% endif %}>Übermorgen fällig</option>
<option value="overdue" {% if filters.status == 'overdue' %}selected{% endif %}>Überfällig</option> <option value="overdue" {% if filters.status == 'overdue' %}selected{% endif %}>Überfällig</option>
<option value="completed" {% if filters.status == 'completed' %}selected{% endif %}>Erledigt</option>
</select> </select>
</div> </div>
<div class="field field--compact"> <div class="field field--compact">
@@ -43,6 +42,63 @@
</form> </form>
</section> </section>
{% if filters.status == 'all' %}
<section class="stack">
<div class="section-heading">
<h2>Überfällig</h2>
<span class="section-heading__count">{{ sections.overdue|length }}</span>
</div>
<div class="task-grid">
{% for task in sections.overdue %}
{{ task_card(task, current_user) }}
{% else %}
<div class="empty-state">Für diese Auswahl ist nichts überfällig.</div>
{% endfor %}
</div>
</section>
<section class="stack">
<div class="section-heading">
<h2>Heute</h2>
<span class="section-heading__count">{{ sections.today|length }}</span>
</div>
<div class="task-grid">
{% for task in sections.today %}
{{ task_card(task, current_user) }}
{% else %}
<div class="empty-state">Heute ist hier gerade nichts offen.</div>
{% endfor %}
</div>
</section>
<section class="stack">
<div class="section-heading">
<h2>Bald fällig</h2>
<span class="section-heading__count">{{ sections.soon|length }}</span>
</div>
<div class="task-grid">
{% for task in sections.soon %}
{{ task_card(task, current_user) }}
{% else %}
<div class="empty-state">In den nächsten Tagen ist hier gerade nichts fällig.</div>
{% endfor %}
</div>
</section>
<section class="stack">
<div class="section-heading">
<h2>Offen</h2>
<span class="section-heading__count">{{ sections.open|length }}</span>
</div>
<div class="task-grid">
{% for task in sections.open %}
{{ task_card(task, current_user) }}
{% else %}
<div class="empty-state">Keine weiteren offenen Aufgaben für diese Auswahl.</div>
{% endfor %}
</div>
</section>
{% else %}
<section class="task-grid"> <section class="task-grid">
{% for task in tasks %} {% for task in tasks %}
{{ task_card(task, current_user) }} {{ task_card(task, current_user) }}
@@ -50,4 +106,5 @@
<div class="empty-state">Für diese Filter gibt es gerade keine Aufgaben.</div> <div class="empty-state">Für diese Filter gibt es gerade keine Aufgaben.</div>
{% endfor %} {% endfor %}
</section> </section>
{% endif %}
{% endblock %} {% endblock %}
+48
View File
@@ -0,0 +1,48 @@
{% extends "base.html" %}
{% from "partials/macros.html" import avatar, task_card %}
{% block title %}Archiv · Putzliga{% endblock %}
{% block page_title %}Archiv{% endblock %}
{% block content %}
<section class="hero-grid">
<article class="hero-card">
<p class="eyebrow">Erledigte Aufgaben</p>
<h2>Was schon geschafft wurde</h2>
<p>Hier landen alle erledigten Aufgaben. Du kannst pro Person sehen, was heute, gestern, vorgestern und davor erledigt wurde.</p>
</article>
<article class="panel highlight-panel">
<p class="eyebrow">Ansicht</p>
<h2>{{ selected_user.name }}</h2>
<p class="muted">Wechsle über die Tabs direkt zwischen den erledigten Aufgaben der einzelnen Nutzer.</p>
</article>
</section>
<section class="archive-user-tabs" aria-label="Archiv nach Nutzer">
{% for user in archive_users %}
<a
href="{{ url_for('tasks.archive_view', user_id=user.id) }}"
class="archive-user-tab {% if selected_user.id == user.id %}is-active{% endif %}"
>
{{ avatar(user) }}
<span>{{ user.name }}</span>
</a>
{% endfor %}
</section>
{% for section in archive_sections %}
<section class="stack">
<div class="section-heading">
<h2>{{ section.label }}</h2>
<span class="section-heading__count">{{ section.tasks|length }}</span>
</div>
<div class="task-grid">
{% for task in section.tasks %}
{{ task_card(task, current_user, compact=true) }}
{% endfor %}
</div>
</section>
{% else %}
<section class="panel">
<div class="empty-state">Für {{ selected_user.name }} gibt es im Archiv noch keine erledigten Aufgaben.</div>
</section>
{% endfor %}
{% endblock %}
+23 -45
View File
@@ -18,14 +18,20 @@
</article> </article>
<article class="panel highlight-panel"> <article class="panel highlight-panel">
<p class="eyebrow">Schnellzugriff</p> <p class="eyebrow">Schnellzugriff</p>
<div class="quick-actions">
<a class="button button--wide" href="{{ url_for('tasks.create') }}"> <a class="button button--wide" href="{{ url_for('tasks.create') }}">
{{ nav_icon('plus') }} {{ nav_icon('plus') }}
<span>Neue Aufgabe anlegen</span> <span>Neue Aufgabe anlegen</span>
</a> </a>
<a class="button button--ghost button--wide" href="{{ url_for('tasks.archive_view') }}">
{{ nav_icon('check-double') }}
<span>Archiv</span>
</a>
<a class="button button--secondary button--wide" href="{{ url_for('scoreboard.index') }}"> <a class="button button--secondary button--wide" href="{{ url_for('scoreboard.index') }}">
{{ nav_icon('trophy') }} {{ nav_icon('trophy') }}
<span>Zum aktuellen Highscore</span> <span>Zum aktuellen Highscore</span>
</a> </a>
</div>
</article> </article>
</section> </section>
@@ -43,44 +49,30 @@
</div> </div>
</section> </section>
<section class="stack">
<div class="section-heading">
<h2>Heute</h2>
<span class="section-heading__count">{{ sections.today|length }}</span>
</div>
<div class="task-grid">
{% for task in sections.today %}
{{ task_card(task, current_user) }}
{% else %}
<div class="empty-state">Heute ist gerade nichts mehr offen. Sehr stark.</div>
{% endfor %}
</div>
</section>
<section class="stack"> <section class="stack">
<div class="section-heading"> <div class="section-heading">
<h2>Bald fällig</h2> <h2>Bald fällig</h2>
<span class="section-heading__count">{{ sections.due_today|length }}</span> <span class="section-heading__count">{{ sections.soon|length }}</span>
</div> </div>
<div class="task-grid"> <div class="task-grid">
{% for task in sections.due_today %} {% for task in sections.soon %}
{{ task_card(task, current_user) }} {{ task_card(task, current_user) }}
{% else %} {% else %}
<div class="empty-state">Heute ist gerade nichts mehr auf Kante.</div> <div class="empty-state">Gerade ist nichts in den nächsten Tagen fällig.</div>
{% endfor %}
</div>
</section>
<section class="stack">
<div class="section-heading">
<h2>Morgen fällig</h2>
<span class="section-heading__count">{{ sections.due_tomorrow|length }}</span>
</div>
<div class="task-grid">
{% for task in sections.due_tomorrow %}
{{ task_card(task, current_user) }}
{% else %}
<div class="empty-state">Für morgen sieht es gerade entspannt aus.</div>
{% endfor %}
</div>
</section>
<section class="stack">
<div class="section-heading">
<h2>Übermorgen fällig</h2>
<span class="section-heading__count">{{ sections.due_day_after_tomorrow|length }}</span>
</div>
<div class="task-grid">
{% for task in sections.due_day_after_tomorrow %}
{{ task_card(task, current_user) }}
{% else %}
<div class="empty-state">Auch übermorgen ist noch nichts Drängendes drin.</div>
{% endfor %} {% endfor %}
</div> </div>
</section> </section>
@@ -98,18 +90,4 @@
{% endfor %} {% endfor %}
</div> </div>
</section> </section>
<section class="stack">
<div class="section-heading">
<h2>Erledigt</h2>
<span class="section-heading__count">{{ sections.completed|length }}</span>
</div>
<div class="task-grid">
{% for task in sections.completed %}
{{ task_card(task, current_user, compact=true) }}
{% else %}
<div class="empty-state">Noch keine erledigten Aufgaben in deiner Liste.</div>
{% endfor %}
</div>
</section>
{% endblock %} {% endblock %}
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 151 KiB