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 @@ {{ icon_svg(name)|safe }} {%- 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) -%} +
+
+ {{ nav_icon(badge.icon_name) }} +
+
+ {{ badge.name }} +

{{ badge.description }}

+
+ Bonus {{ badge.bonus_points }} + Schwelle {{ badge.threshold }} + {% if awarded_at %} + Freigeschaltet {{ awarded_at|date_de }} + {% endif %} +
+
+
+{%- endmacro %} + {% macro status_badge(task) -%} {{ task.status_label }} {%- endmacro %} diff --git a/app/templates/scoreboard/index.html b/app/templates/scoreboard/index.html index 351d037..5c4f4f9 100644 --- a/app/templates/scoreboard/index.html +++ b/app/templates/scoreboard/index.html @@ -1,5 +1,5 @@ {% extends "base.html" %} -{% from "partials/macros.html" import avatar %} +{% from "partials/macros.html" import avatar, badge_chip %} {% block title %}Highscoreboard · Putzliga{% endblock %} {% block page_title %}Highscoreboard{% endblock %} {% block content %} @@ -50,7 +50,14 @@ {% if row.badges %}
{% for badge in row.badges %} - {{ badge.definition.name }} +{{ badge.bonus_points }} + {{ badge_chip(badge) }} + {% endfor %} +
+ {% endif %} + {% if row.user.awarded_badges %} +
+ {% for badge in row.user.awarded_badges[:3] %} + {{ badge_chip(badge) }} {% endfor %}
{% endif %} diff --git a/app/templates/settings/badges.html b/app/templates/settings/badges.html new file mode 100644 index 0000000..8d6a044 --- /dev/null +++ b/app/templates/settings/badges.html @@ -0,0 +1,41 @@ +{% extends "base.html" %} +{% from "partials/macros.html" import badge_card, nav_icon %} +{% block title %}Badge-Regeln · Putzliga{% endblock %} +{% block page_title %}Badge-Regeln{% endblock %} +{% block content %} +
+ {% for endpoint, label, icon in settings_tabs %} + + {{ nav_icon(icon) }} + {{ label }} + + {% endfor %} +
+ +
+

Admin-Bereich

+

Badges konfigurieren

+

Die Icons stammen aus `heinz.marketing` und wurden für Putzliga lokal übernommen. Schwelle, Bonus und Aktiv-Status kannst du hier steuern.

+
+ {% for badge in badges %} +
+ + {{ badge_card(badge) }} +
+ + +
+
+ + +
+ + +
+ {% endfor %} +
+
+{% endblock %} diff --git a/app/templates/settings/index.html b/app/templates/settings/index.html index 1f3c946..e55ba0d 100644 --- a/app/templates/settings/index.html +++ b/app/templates/settings/index.html @@ -1,8 +1,17 @@ {% extends "base.html" %} -{% from "partials/macros.html" import avatar, nav_icon %} +{% from "partials/macros.html" import avatar, badge_chip, nav_icon %} {% block title %}Optionen · Putzliga{% endblock %} {% block page_title %}Optionen{% endblock %} {% block content %} +
+ {% for endpoint, label, icon in settings_tabs %} + + {{ nav_icon(icon) }} + {{ label }} + + {% endfor %} +
+

Profil & Benachrichtigungen

@@ -70,35 +79,19 @@
-

Gamification

-

Badge-Regeln pflegen

- {% if current_user.is_admin %} -
- {% for badge in badges %} -
- -
- {{ badge.name }} -

{{ badge.description }}

-
-
- - -
-
- - -
- - -
+

Deine Trophäenwand

+

Freigeschaltete Badges

+ {% if earned_badges %} +
+ {% for badge in earned_badges %} + {{ badge_chip(badge) }} {% endfor %}
{% else %} -

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.")