{{ badge.description }}
+diff --git a/README.md b/README.md
index 11274f9..345191f 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@ Putzliga ist eine moderne, leichte Haushaltsaufgaben-Web-App mit spielerischem C
## Features
-- Mehrere Nutzer mit Login, Registrierung und Profil-/Avatar-Einstellungen
+- Mehrere Nutzer mit Login, Admin-Nutzermanagement und Profil-/Avatar-Einstellungen
- Trennung zwischen `TaskTemplate` und `TaskInstance`
- Aufgaben anlegen, bearbeiten, zuweisen und erledigen
- Wiederholungen für einmalig, alle X Tage, alle X Wochen und alle X Monate
@@ -18,6 +18,7 @@ Putzliga ist eine moderne, leichte Haushaltsaufgaben-Web-App mit spielerischem C
- CLI-Kommandos für Archivierung und serverseitig triggerbare Benachrichtigungen
- Cloudron-/Container-tauglicher Start mit `start.sh`, `Dockerfile` und `CloudronManifest.json`
- Keine freie Registrierung nach dem ersten Nutzer; weitere Nutzer lassen sich kontrolliert per CLI anlegen
+- Dauerhaft gespeicherte Badges pro Nutzer mit eigener Admin-Badge-Seite
## Projektstruktur
@@ -106,6 +107,7 @@ python seed.py
Demo-Logins:
+- `mail@hnz.io` / `putzliga123` (Admin)
- `anna@putzliga.local` / `putzliga123`
- `ben@putzliga.local` / `putzliga123`
@@ -120,6 +122,12 @@ Freie Registrierung ist deaktiviert, sobald mindestens ein Nutzer existiert.
flask --app app.py create-user
```
+Admins können Nutzer zusätzlich direkt in der App unter `Optionen -> Profil & Team` verwalten.
+
+## Badges
+
+Badges werden dauerhaft pro Nutzer gespeichert und automatisch freigeschaltet. Die Badge-Regeln werden für Admins auf einer eigenen Seite unter `Optionen -> Badges` gepflegt.
+
### 5. Entwicklungsserver starten
```bash
diff --git a/app/__init__.py b/app/__init__.py
index d4772f4..207dc03 100644
--- a/app/__init__.py
+++ b/app/__init__.py
@@ -8,6 +8,7 @@ from .cli import register_cli, seed_badges
from .extensions import csrf, db, login_manager
from .routes import auth, main, scoreboard, settings, tasks
from .routes.main import load_icon_svg
+from .services.badges import sync_existing_badges
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
@@ -28,6 +29,7 @@ def create_app(config_class: type[Config] = Config) -> Flask:
db.create_all()
ensure_schema_and_admins()
seed_badges()
+ sync_existing_badges()
register_cli(app)
diff --git a/app/cli.py b/app/cli.py
index 0c2bcea..78157ce 100644
--- a/app/cli.py
+++ b/app/cli.py
@@ -10,40 +10,131 @@ from .services.notifications import send_due_notifications, send_monthly_winner_
DEFAULT_BADGES = [
{
- "key": "early_bird",
- "name": "Frühstarter",
- "description": "Erledige 3 Aufgaben vor ihrem Fälligkeitsdatum.",
- "icon_name": "bell",
- "trigger_type": "early_finisher_count",
- "threshold": 3,
+ "key": "first_wipe",
+ "name": "Erster Wisch",
+ "description": "Erledige deine erste Aufgabe.",
+ "icon_name": "sparkles",
+ "trigger_type": "first_task_completed",
+ "threshold": 1,
+ "bonus_points": 5,
+ },
+ {
+ "key": "warmed_up",
+ "name": "Warmgelaufen",
+ "description": "Erledige 10 Aufgaben insgesamt.",
+ "icon_name": "fire",
+ "trigger_type": "total_tasks_completed",
+ "threshold": 10,
"bonus_points": 10,
},
{
- "key": "on_time_streak",
- "name": "Sauberer Lauf",
- "description": "Erledige Aufgaben an 3 Tagen in Folge.",
- "icon_name": "check",
- "trigger_type": "streak_days",
- "threshold": 3,
+ "key": "punctuality_pro",
+ "name": "Pünktlichkeitsprofi",
+ "description": "Erledige 10 Aufgaben pünktlich.",
+ "icon_name": "check-double",
+ "trigger_type": "on_time_tasks_completed",
+ "threshold": 10,
"bonus_points": 15,
},
{
- "key": "task_sprinter",
- "name": "Putz-Sprinter",
- "description": "Schließe 8 Aufgaben in einem Monat ab.",
- "icon_name": "trophy",
- "trigger_type": "monthly_task_count",
- "threshold": 8,
+ "key": "early_bird",
+ "name": "Der frühe Vogel",
+ "description": "Erledige eine Aufgabe mindestens 1 Tag vor der Deadline.",
+ "icon_name": "calendar-day",
+ "trigger_type": "early_tasks_completed",
+ "threshold": 1,
+ "bonus_points": 8,
+ },
+ {
+ "key": "early_starter",
+ "name": "Frühstarter",
+ "description": "Erledige 5 Aufgaben mindestens 1 Tag vor der Deadline.",
+ "icon_name": "rocket-launch",
+ "trigger_type": "early_tasks_completed",
+ "threshold": 5,
+ "bonus_points": 15,
+ },
+ {
+ "key": "weekly_flow",
+ "name": "Wochenflow",
+ "description": "Erledige 7 Tage in Folge mindestens eine Aufgabe.",
+ "icon_name": "flag-checkered",
+ "trigger_type": "streak_days",
+ "threshold": 7,
"bonus_points": 20,
},
+ {
+ "key": "monthly_champion",
+ "name": "Monatssieger",
+ "description": "Gewinne einen Monat mit den meisten Punkten.",
+ "icon_name": "trophy",
+ "trigger_type": "monthly_win_count",
+ "threshold": 1,
+ "bonus_points": 25,
+ },
+ {
+ "key": "title_defender",
+ "name": "Titelverteidiger",
+ "description": "Gewinne 2 Monate in Folge.",
+ "icon_name": "crown",
+ "trigger_type": "consecutive_month_wins",
+ "threshold": 2,
+ "bonus_points": 30,
+ },
+ {
+ "key": "boss_battle",
+ "name": "Bosskampf",
+ "description": "Erledige eine Aufgabe mit besonders hohem Punktwert.",
+ "icon_name": "bolt",
+ "trigger_type": "high_point_task",
+ "threshold": 25,
+ "bonus_points": 12,
+ },
+ {
+ "key": "foreign_savior",
+ "name": "Fremdretter",
+ "description": "Erledige 5 Aufgaben, die ursprünglich jemand anderem zugewiesen waren.",
+ "icon_name": "users-crown",
+ "trigger_type": "foreign_tasks_completed",
+ "threshold": 5,
+ "bonus_points": 18,
+ },
+ {
+ "key": "white_glove",
+ "name": "Weiße Weste",
+ "description": "Bleibe einen ganzen Monat ohne überfällige Aufgabe.",
+ "icon_name": "shield",
+ "trigger_type": "clean_month",
+ "threshold": 1,
+ "bonus_points": 20,
+ },
+ {
+ "key": "comeback_kid",
+ "name": "Comeback Kid",
+ "description": "Gewinne nach einem verlorenen Monat den nächsten Monat.",
+ "icon_name": "award",
+ "trigger_type": "comeback_win",
+ "threshold": 1,
+ "bonus_points": 18,
+ },
]
def seed_badges() -> None:
+ wanted_keys = {payload["key"] for payload in DEFAULT_BADGES}
+ BadgeDefinition.query.filter(~BadgeDefinition.key.in_(wanted_keys)).delete(synchronize_session=False)
for payload in DEFAULT_BADGES:
badge = BadgeDefinition.query.filter_by(key=payload["key"]).first()
if not badge:
db.session.add(BadgeDefinition(**payload))
+ continue
+ badge.name = payload["name"]
+ badge.description = payload["description"]
+ badge.icon_name = payload["icon_name"]
+ badge.trigger_type = payload["trigger_type"]
+ badge.threshold = payload["threshold"]
+ badge.bonus_points = payload["bonus_points"]
+ badge.active = True
db.session.commit()
diff --git a/app/models.py b/app/models.py
index 9f55f00..245f581 100644
--- a/app/models.py
+++ b/app/models.py
@@ -46,6 +46,13 @@ class User(UserMixin, TimestampMixin, db.Model):
lazy=True,
)
subscriptions = db.relationship("PushSubscription", backref="user", lazy=True, cascade="all, delete-orphan")
+ awarded_badges = db.relationship(
+ "UserBadge",
+ backref="user",
+ lazy=True,
+ cascade="all, delete-orphan",
+ order_by="desc(UserBadge.awarded_at)",
+ )
def set_password(self, password: str) -> None:
self.password_hash = generate_password_hash(password)
@@ -165,3 +172,15 @@ class BadgeDefinition(TimestampMixin, db.Model):
threshold = db.Column(db.Integer, nullable=False, default=1)
bonus_points = db.Column(db.Integer, nullable=False, default=0)
active = db.Column(db.Boolean, nullable=False, default=True)
+
+ user_badges = db.relationship("UserBadge", backref="badge_definition", lazy=True, cascade="all, delete-orphan")
+
+
+class UserBadge(db.Model):
+ id = db.Column(db.Integer, primary_key=True)
+ user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False, index=True)
+ badge_definition_id = db.Column(db.Integer, db.ForeignKey("badge_definition.id"), nullable=False, index=True)
+ awarded_at = db.Column(db.DateTime, nullable=False, default=utcnow, index=True)
+ context = db.Column(db.Text, nullable=True)
+
+ __table_args__ = (db.UniqueConstraint("user_id", "badge_definition_id", name="uq_user_badge_once"),)
diff --git a/app/routes/settings.py b/app/routes/settings.py
index 13b1787..2e8e2c1 100644
--- a/app/routes/settings.py
+++ b/app/routes/settings.py
@@ -10,6 +10,7 @@ from werkzeug.utils import secure_filename
from ..extensions import csrf, db
from ..forms import AdminUserForm, SettingsProfileForm
from ..models import BadgeDefinition, MonthlyScoreSnapshot, NotificationLog, PushSubscription, TaskInstance, TaskTemplate, User
+from ..services.badges import earned_badges_for_user
from ..services.notifications import push_enabled
@@ -23,6 +24,13 @@ def _require_admin():
return True
+def _settings_tabs():
+ tabs = [("settings.index", "Profil & Team", "gear")]
+ if current_user.is_admin:
+ tabs.append(("settings.badges", "Badges", "award"))
+ return tabs
+
+
def _save_avatar(file_storage) -> str:
filename = secure_filename(file_storage.filename or "")
ext = Path(filename).suffix.lower() or ".png"
@@ -51,17 +59,32 @@ def index():
flash("Deine Einstellungen wurden gespeichert.", "success")
return redirect(url_for("settings.index"))
- badges = BadgeDefinition.query.order_by(BadgeDefinition.name.asc()).all()
subscriptions = PushSubscription.query.filter_by(user_id=current_user.id).all()
return render_template(
"settings/index.html",
form=form,
admin_form=admin_form,
- badges=badges,
users=User.query.order_by(User.is_admin.desc(), User.name.asc()).all(),
+ earned_badges=earned_badges_for_user(current_user.id),
push_ready=push_enabled(),
- vapid_public_key=current_app.config["VAPID_PUBLIC_KEY"],
has_subscription=bool(subscriptions),
+ settings_tabs=_settings_tabs(),
+ active_settings_tab="settings.index",
+ )
+
+
+@bp.route("/badges")
+@login_required
+def badges():
+ if not _require_admin():
+ return redirect(url_for("settings.index"))
+
+ badges = BadgeDefinition.query.order_by(BadgeDefinition.name.asc()).all()
+ return render_template(
+ "settings/badges.html",
+ badges=badges,
+ settings_tabs=_settings_tabs(),
+ active_settings_tab="settings.badges",
)
@@ -76,7 +99,7 @@ def update_badge(badge_id: int):
badge.active = request.form.get("active") == "on"
db.session.commit()
flash(f"Badge „{badge.name}“ wurde aktualisiert.", "success")
- return redirect(url_for("settings.index"))
+ return redirect(url_for("settings.badges"))
@bp.route("/users", methods=["POST"])
diff --git a/app/services/badges.py b/app/services/badges.py
index 73da661..f57d34c 100644
--- a/app/services/badges.py
+++ b/app/services/badges.py
@@ -1,17 +1,21 @@
from __future__ import annotations
+import json
from collections import defaultdict
-from datetime import date, timedelta
+from datetime import date, datetime, time, timedelta
-from ..models import BadgeDefinition, TaskInstance
+from sqlalchemy import and_
+
+from ..extensions import db
+from ..models import BadgeDefinition, MonthlyScoreSnapshot, TaskInstance, User, UserBadge
+from .dates import month_bounds, previous_month
def _max_day_streak(days: set[date]) -> int:
if not days:
return 0
- streak = 1
- best = 1
ordered = sorted(days)
+ best = streak = 1
for previous, current in zip(ordered, ordered[1:]):
if current == previous + timedelta(days=1):
streak += 1
@@ -21,33 +25,166 @@ def _max_day_streak(days: set[date]) -> int:
return best
-def compute_badge_awards(definitions: list[BadgeDefinition], completed_tasks: list[TaskInstance]) -> list[dict]:
- by_type: dict[str, int] = defaultdict(int)
- completion_days: set[date] = set()
+def award_badge(user: User, badge_key: str, *, awarded_at: datetime | None = None, context: dict | None = None) -> bool:
+ definition = BadgeDefinition.query.filter_by(key=badge_key, active=True).first()
+ if not definition:
+ return False
- for task in completed_tasks:
- if not task.completed_at:
- continue
+ existing = UserBadge.query.filter_by(user_id=user.id, badge_definition_id=definition.id).first()
+ if existing:
+ return False
+
+ db.session.add(
+ UserBadge(
+ user_id=user.id,
+ badge_definition_id=definition.id,
+ awarded_at=awarded_at or datetime.utcnow(),
+ context=json.dumps(context, sort_keys=True) if context else None,
+ )
+ )
+ return True
+
+
+def badge_awards_for_month(user_id: int, year: int, month: int) -> list[UserBadge]:
+ start, end = month_bounds(year, month)
+ return (
+ UserBadge.query.filter(
+ UserBadge.user_id == user_id,
+ UserBadge.awarded_at >= start,
+ UserBadge.awarded_at < end,
+ )
+ .join(BadgeDefinition)
+ .filter(BadgeDefinition.active.is_(True))
+ .order_by(UserBadge.awarded_at.asc())
+ .all()
+ )
+
+
+def earned_badges_for_user(user_id: int) -> list[UserBadge]:
+ return (
+ UserBadge.query.filter_by(user_id=user_id)
+ .join(BadgeDefinition)
+ .order_by(UserBadge.awarded_at.desc())
+ .all()
+ )
+
+
+def _completion_metrics(user: User) -> dict[str, int]:
+ tasks = (
+ TaskInstance.query.filter(
+ TaskInstance.completed_by_user_id == user.id,
+ TaskInstance.completed_at.isnot(None),
+ )
+ .order_by(TaskInstance.completed_at.asc())
+ .all()
+ )
+
+ completion_days: set[date] = set()
+ metrics = defaultdict(int)
+ max_points = 0
+
+ for task in tasks:
completion_day = task.completed_at.date()
completion_days.add(completion_day)
- by_type["monthly_task_count"] += 1
- if task.due_date and completion_day < task.due_date:
- by_type["early_finisher_count"] += 1
- if task.due_date and completion_day <= task.due_date:
- by_type["on_time_count"] += 1
+ metrics["first_task_completed"] += 1
+ metrics["total_tasks_completed"] += 1
+ if completion_day <= task.due_date:
+ metrics["on_time_tasks_completed"] += 1
+ if completion_day <= task.due_date - timedelta(days=1):
+ metrics["early_tasks_completed"] += 1
+ if task.assigned_user_id and task.assigned_user_id != user.id:
+ metrics["foreign_tasks_completed"] += 1
+ max_points = max(max_points, task.points_awarded)
- by_type["streak_days"] = _max_day_streak(completion_days)
+ metrics["streak_days"] = _max_day_streak(completion_days)
+ metrics["high_point_task"] = max_points
+ return metrics
+
+
+def evaluate_task_badges(user: User) -> list[str]:
+ definitions = BadgeDefinition.query.filter_by(active=True).all()
+ metrics = _completion_metrics(user)
+ unlocked: list[str] = []
- awards = []
for definition in definitions:
- metric_value = by_type.get(definition.trigger_type, 0)
- if definition.active and metric_value >= definition.threshold:
- awards.append(
- {
- "definition": definition,
- "metric_value": metric_value,
- "bonus_points": definition.bonus_points,
- }
- )
- return awards
+ metric_value = metrics.get(definition.trigger_type)
+ if metric_value is None:
+ continue
+ if metric_value >= definition.threshold and award_badge(user, definition.key):
+ unlocked.append(definition.name)
+ if unlocked:
+ db.session.commit()
+ return unlocked
+
+
+def _month_end_award_time(year: int, month: int) -> datetime:
+ _, end = month_bounds(year, month)
+ return end - timedelta(seconds=1)
+
+
+def _user_had_clean_month(user_id: int, year: int, month: int) -> bool:
+ start_date = date(year, month, 1)
+ end_date = (month_bounds(year, month)[1] - timedelta(days=1)).date()
+ tasks = TaskInstance.query.filter(
+ TaskInstance.assigned_user_id == user_id,
+ TaskInstance.due_date >= start_date,
+ TaskInstance.due_date <= end_date,
+ ).all()
+
+ if not tasks:
+ return False
+
+ for task in tasks:
+ if not task.completed_at:
+ return False
+ if task.completed_at.date() > task.due_date:
+ return False
+ return True
+
+
+def _winner_user_ids(year: int, month: int) -> set[int]:
+ rows = MonthlyScoreSnapshot.query.filter_by(year=year, month=month, rank=1).all()
+ return {row.user_id for row in rows}
+
+
+def evaluate_monthly_badges(year: int, month: int) -> list[str]:
+ award_time = _month_end_award_time(year, month)
+ unlocked: list[str] = []
+ winners = _winner_user_ids(year, month)
+ previous_year, previous_month_value = previous_month(year, month)
+ previous_winners = _winner_user_ids(previous_year, previous_month_value)
+
+ for user in User.query.order_by(User.id.asc()).all():
+ if user.id in winners:
+ if award_badge(user, "monthly_champion", awarded_at=award_time, context={"year": year, "month": month}):
+ unlocked.append(f"{user.name}: Monatssieger")
+
+ if user.id in previous_winners:
+ if award_badge(user, "title_defender", awarded_at=award_time, context={"year": year, "month": month}):
+ unlocked.append(f"{user.name}: Titelverteidiger")
+ 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}):
+ unlocked.append(f"{user.name}: Comeback Kid")
+
+ if _user_had_clean_month(user.id, year, month):
+ if award_badge(user, "white_glove", awarded_at=award_time, context={"year": year, "month": month}):
+ unlocked.append(f"{user.name}: Weiße Weste")
+
+ if unlocked:
+ db.session.commit()
+ return unlocked
+
+
+def sync_existing_badges() -> None:
+ for user in User.query.order_by(User.id.asc()).all():
+ evaluate_task_badges(user)
+
+ archived_months = (
+ db.session.query(MonthlyScoreSnapshot.year, MonthlyScoreSnapshot.month)
+ .group_by(MonthlyScoreSnapshot.year, MonthlyScoreSnapshot.month)
+ .order_by(MonthlyScoreSnapshot.year.asc(), MonthlyScoreSnapshot.month.asc())
+ .all()
+ )
+ for row in archived_months:
+ evaluate_monthly_badges(row.year, row.month)
diff --git a/app/services/monthly.py b/app/services/monthly.py
index c745d38..d8abcb0 100644
--- a/app/services/monthly.py
+++ b/app/services/monthly.py
@@ -3,11 +3,9 @@ from __future__ import annotations
from collections import defaultdict
from datetime import datetime
-from sqlalchemy import extract, select
-
from ..extensions import db
-from ..models import BadgeDefinition, MonthlyScoreSnapshot, TaskInstance, User
-from .badges import compute_badge_awards
+from ..models import MonthlyScoreSnapshot, TaskInstance, User
+from .badges import badge_awards_for_month, evaluate_monthly_badges
from .dates import local_now, month_bounds, next_month, previous_month
@@ -21,7 +19,6 @@ def _build_ranking(rows: list[dict]) -> list[dict]:
def compute_monthly_scores(year: int, month: int) -> list[dict]:
start, end = month_bounds(year, month)
users = User.query.order_by(User.name.asc()).all()
- badges = BadgeDefinition.query.filter_by(active=True).all()
completed_tasks = TaskInstance.query.filter(
TaskInstance.completed_at.isnot(None),
TaskInstance.completed_at >= start,
@@ -37,8 +34,8 @@ def compute_monthly_scores(year: int, month: int) -> list[dict]:
for user in users:
personal_tasks = tasks_by_user.get(user.id, [])
base_points = sum(task.points_awarded for task in personal_tasks)
- awards = compute_badge_awards(badges, personal_tasks)
- bonus_points = sum(award["bonus_points"] for award in awards)
+ earned_badges = badge_awards_for_month(user.id, year, month)
+ bonus_points = sum(badge.badge_definition.bonus_points for badge in earned_badges if badge.badge_definition.active)
rows.append(
{
"user": user,
@@ -46,7 +43,7 @@ def compute_monthly_scores(year: int, month: int) -> list[dict]:
"bonus_points": bonus_points,
"total_points": base_points + bonus_points,
"completed_tasks_count": len(personal_tasks),
- "badges": awards,
+ "badges": earned_badges,
}
)
return _build_ranking(rows)
@@ -100,6 +97,7 @@ def archive_months_missing_up_to_previous() -> None:
)
)
db.session.commit()
+ evaluate_monthly_badges(year, month)
year, month = next_month(year, month)
@@ -120,4 +118,3 @@ def get_snapshot_rows(year: int, month: int) -> list[MonthlyScoreSnapshot]:
.order_by(MonthlyScoreSnapshot.rank.asc(), MonthlyScoreSnapshot.total_points.desc())
.all()
)
-
diff --git a/app/services/tasks.py b/app/services/tasks.py
index 1315969..855029c 100644
--- a/app/services/tasks.py
+++ b/app/services/tasks.py
@@ -6,6 +6,7 @@ from sqlalchemy import select
from ..extensions import db
from ..models import TaskInstance, TaskTemplate
+from .badges import evaluate_task_badges
from .dates import add_months, today_local
@@ -122,5 +123,6 @@ def complete_task(task: TaskInstance, completed_by_user_id: int) -> TaskInstance
task.status = "completed"
ensure_next_recurring_task(task)
db.session.commit()
+ if task.completed_by_user:
+ evaluate_task_badges(task.completed_by_user)
return task
-
diff --git a/app/static/css/style.css b/app/static/css/style.css
index eb0a51d..874a306 100644
--- a/app/static/css/style.css
+++ b/app/static/css/style.css
@@ -330,7 +330,8 @@ p {
.task-grid,
.scoreboard,
.archive-list,
-.badge-settings {
+.badge-settings,
+.earned-badges-grid {
display: grid;
gap: 16px;
}
@@ -389,6 +390,27 @@ p {
gap: 8px;
}
+.earned-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 14px;
+ border-radius: 999px;
+ background: rgba(37, 99, 235, 0.08);
+ color: var(--primary-strong);
+ font-weight: 700;
+}
+
+.earned-badge__icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ border-radius: 999px;
+ background: rgba(255, 255, 255, 0.72);
+}
+
.status-badge {
display: inline-flex;
align-items: center;
@@ -705,6 +727,66 @@ p {
padding: 18px;
}
+.badge-card {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ gap: 14px;
+ align-items: start;
+}
+
+.badge-card--earned {
+ background: rgba(37, 99, 235, 0.03);
+}
+
+.badge-card__icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 56px;
+ height: 56px;
+ border-radius: 18px;
+ background: linear-gradient(135deg, rgba(37, 99, 235, 0.14), rgba(52, 211, 153, 0.12));
+ color: var(--primary-strong);
+}
+
+.badge-card__icon .nav-icon,
+.badge-card__icon .nav-icon svg {
+ width: 26px;
+ height: 26px;
+}
+
+.badge-card__body {
+ display: grid;
+ gap: 8px;
+}
+
+.settings-tabs {
+ display: inline-flex;
+ flex-wrap: wrap;
+ gap: 10px;
+ padding: 6px;
+ border-radius: 22px;
+ background: rgba(255, 255, 255, 0.7);
+ border: 1px solid rgba(132, 152, 190, 0.18);
+ box-shadow: 0 12px 30px rgba(58, 82, 128, 0.1);
+}
+
+.settings-tab {
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 14px;
+ border-radius: 16px;
+ color: var(--muted);
+ font-weight: 700;
+}
+
+.settings-tab.is-active {
+ background: #fff;
+ color: var(--primary-strong);
+ box-shadow: 0 10px 20px rgba(37, 99, 235, 0.08);
+}
+
.push-box {
display: grid;
gap: 18px;
@@ -767,13 +849,14 @@ p {
.bottom-nav {
position: fixed;
- left: 14px;
- right: 14px;
+ left: 50%;
+ transform: translateX(-50%);
+ width: min(calc(100vw - 52px), 560px);
bottom: calc(10px + env(safe-area-inset-bottom));
display: grid;
grid-template-columns: repeat(6, 1fr);
- gap: 8px;
- padding: 10px;
+ gap: 6px;
+ padding: 8px;
border-radius: 24px;
background: rgba(255, 255, 255, 0.88);
backdrop-filter: blur(16px);
@@ -786,13 +869,14 @@ p {
.nav-link {
display: grid;
justify-items: center;
- gap: 6px;
- padding: 10px 6px;
+ gap: 5px;
+ padding: 10px 4px;
color: var(--muted);
border-radius: 16px;
text-align: center;
- font-size: 0.73rem;
+ font-size: 0.69rem;
font-weight: 700;
+ line-height: 1.1;
}
.bottom-nav__item.is-active,
@@ -855,7 +939,8 @@ p {
}
.form-grid--two,
- .badge-settings {
+ .badge-settings,
+ .earned-badges-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
diff --git a/app/static/icons/award.svg b/app/static/icons/award.svg
new file mode 100644
index 0000000..ce46de8
--- /dev/null
+++ b/app/static/icons/award.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/static/icons/badge-check.svg b/app/static/icons/badge-check.svg
new file mode 100644
index 0000000..6a1805f
--- /dev/null
+++ b/app/static/icons/badge-check.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/static/icons/bolt.svg b/app/static/icons/bolt.svg
new file mode 100644
index 0000000..6b33aad
--- /dev/null
+++ b/app/static/icons/bolt.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/static/icons/calendar-day.svg b/app/static/icons/calendar-day.svg
new file mode 100644
index 0000000..df4e2f2
--- /dev/null
+++ b/app/static/icons/calendar-day.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/static/icons/check-double.svg b/app/static/icons/check-double.svg
new file mode 100644
index 0000000..f3c2b2b
--- /dev/null
+++ b/app/static/icons/check-double.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/static/icons/crown.svg b/app/static/icons/crown.svg
new file mode 100644
index 0000000..9ae7483
--- /dev/null
+++ b/app/static/icons/crown.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/static/icons/fire.svg b/app/static/icons/fire.svg
new file mode 100644
index 0000000..2f0ebc5
--- /dev/null
+++ b/app/static/icons/fire.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/static/icons/flag-checkered.svg b/app/static/icons/flag-checkered.svg
new file mode 100644
index 0000000..cfb4e2f
--- /dev/null
+++ b/app/static/icons/flag-checkered.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/static/icons/rocket-launch.svg b/app/static/icons/rocket-launch.svg
new file mode 100644
index 0000000..d087fe2
--- /dev/null
+++ b/app/static/icons/rocket-launch.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/static/icons/shield.svg b/app/static/icons/shield.svg
new file mode 100644
index 0000000..0c45d90
--- /dev/null
+++ b/app/static/icons/shield.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/static/icons/sparkles.svg b/app/static/icons/sparkles.svg
new file mode 100644
index 0000000..f65c96f
--- /dev/null
+++ b/app/static/icons/sparkles.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/static/icons/star.svg b/app/static/icons/star.svg
new file mode 100644
index 0000000..a17cc0b
--- /dev/null
+++ b/app/static/icons/star.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/static/icons/users-crown.svg b/app/static/icons/users-crown.svg
new file mode 100644
index 0000000..3ffd643
--- /dev/null
+++ b/app/static/icons/users-crown.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/templates/partials/macros.html b/app/templates/partials/macros.html
index bbd3abb..cecb80d 100644
--- a/app/templates/partials/macros.html
+++ b/app/templates/partials/macros.html
@@ -2,6 +2,32 @@
{%- endmacro %}
+{% macro badge_chip(user_badge) -%}
+
+ {{ nav_icon(user_badge.badge_definition.icon_name) }}
+ {{ user_badge.badge_definition.name }}
+
+{%- endmacro %}
+
+{% macro badge_card(badge, earned=false, awarded_at=None) -%}
+ {{ badge.description }}
Admin-Bereich
+Die Icons stammen aus `heinz.marketing` und wurden für Putzliga lokal übernommen. Schwelle, Bonus und Aktiv-Status kannst du hier steuern.
+Profil & Benachrichtigungen
@@ -70,35 +79,19 @@Gamification
-Deine Trophäenwand
+Badge-Regeln können nur von einem Admin geändert werden.
+Noch keine Badges freigeschaltet. Die ersten kommen schnell, sobald Aufgaben erledigt werden.
+ {% endif %} + {% if current_user.is_admin %} +Badge-Regeln verwaltest du auf der separaten Badge-Seite.
{% endif %} diff --git a/seed.py b/seed.py index 301a65e..7f88005 100644 --- a/seed.py +++ b/seed.py @@ -6,6 +6,7 @@ from app import create_app from app.cli import seed_badges from app.extensions import db from app.models import TaskInstance, TaskTemplate, User +from app.services.badges import evaluate_task_badges from app.services.monthly import archive_months_missing_up_to_previous @@ -16,6 +17,16 @@ def seed_database() -> None: db.create_all() seed_badges() + admin = User( + name="Henri", + email="mail@hnz.io", + avatar_path="images/avatars/default.svg", + is_admin=True, + notification_task_due_enabled=True, + notification_monthly_winner_enabled=True, + ) + admin.set_password("putzliga123") + anna = User( name="Anna", email="anna@putzliga.local", @@ -33,7 +44,7 @@ def seed_database() -> None: ) ben.set_password("putzliga123") - db.session.add_all([anna, ben]) + db.session.add_all([admin, anna, ben]) db.session.flush() templates = [ @@ -72,6 +83,14 @@ def seed_database() -> None: recurrence_interval_unit="none", active=True, ), + TaskTemplate( + title="Flur-Grundreinigung", + description="Einmal alles: saugen, wischen, Schuhe sortieren.", + default_points=28, + default_assigned_user_id=ben.id, + recurrence_interval_unit="none", + active=True, + ), TaskTemplate( title="Bettwäsche wechseln", description="Neue Bettwäsche aufziehen.", @@ -130,6 +149,17 @@ def seed_database() -> None: task_template_id=templates[4].id, title=templates[4].title, description=templates[4].description, + assigned_user_id=ben.id, + due_date=(now - timedelta(days=3)).date(), + status="completed", + completed_at=current_month_anchor - timedelta(days=2), + completed_by_user_id=anna.id, + points_awarded=28, + ), + TaskInstance( + task_template_id=templates[5].id, + title=templates[5].title, + description=templates[5].description, assigned_user_id=anna.id, due_date=(now - timedelta(days=9)).date(), status="completed", @@ -141,7 +171,7 @@ def seed_database() -> None: task_template_id=templates[1].id, title=templates[1].title, description=templates[1].description, - assigned_user_id=ben.id, + assigned_user_id=anna.id, due_date=(previous_month_anchor - timedelta(days=1)).date(), status="completed", completed_at=previous_month_anchor, @@ -174,6 +204,8 @@ def seed_database() -> None: db.session.add_all(instances) db.session.commit() + for user in (admin, anna, ben): + evaluate_task_badges(user) archive_months_missing_up_to_previous() print("Seed-Daten geschrieben.")