From f05bae9ab14eab8973b8300d342143f55d13279a Mon Sep 17 00:00:00 2001 From: kai Date: Fri, 28 Aug 2026 13:58:41 +0200 Subject: [PATCH] feat: add travel and concert streak patches --- app/main.py | 113 ++++++++++++++++++++++++++++--------- app/templates/profile.html | 33 +++++++---- 2 files changed, 107 insertions(+), 39 deletions(-) diff --git a/app/main.py b/app/main.py index 867d6b1..f925c27 100644 --- a/app/main.py +++ b/app/main.py @@ -89,6 +89,10 @@ BADGE_DEFINITIONS = ( ("tour_veteran", "25 Gigs", "⚡", 25, "25 besuchte Konzerte", "attendance"), ("fifty_gigs", "50 Gigs", "💀", 50, "50 besuchte Konzerte", "attendance"), ("hundred_gigs", "100 Gigs", "👑", 100, "100 besuchte Konzerte", "attendance"), + ("border_breaker", "BORDER BREAKER", "🛂", 1, "Erstes Konzert im Ausland", "travel"), + ("globe_banger", "GLOBE BANGER", "🌍", 5, "Konzerte in 5 Ländern", "travel"), + ("double_trouble", "DOUBLE TROUBLE", "⚡", 2, "Zwei Konzerte an zwei aufeinanderfolgenden Tagen", "streak"), + ("iron_weekend", "IRON WEEKEND", "🤘", 3, "Drei Konzerte innerhalb eines Wochenendes", "streak"), ) ATTENDANCE_BADGE_CODES = tuple( badge_code @@ -104,6 +108,14 @@ BADGE_BY_CODE = { badge_code: (name, icon, threshold, description, category) for badge_code, name, icon, threshold, description, category in BADGE_DEFINITIONS } +BADGE_CATEGORY_LABELS = { + "special": "⚔️ Spezial-Patches", + "beta": "🧪 Community", + "attendance": "🎸 Konzert-Meilensteine", + "venue": "🤘 Stammorte", + "travel": "🌍 Unterwegs", + "streak": "⚡ Tourmodus", +} def get_db_connection(): @@ -1129,41 +1141,65 @@ def remove_uploaded_file(path: str | None, destination_dir: str, url_prefix: str return True -def attended_concert_stats(user_id: int) -> tuple[int, int]: +def normalize_country(value: str) -> str: + country = (value or "").strip().casefold() + aliases = {"de": "deutschland", "deu": "deutschland", "germany": "deutschland"} + return aliases.get(country, country) + + +def attended_concert_stats(user_id: int) -> dict: with get_db_connection() as connection: with connection.cursor() as cursor: cursor.execute( """ - WITH attended AS ( - SELECT concerts.venue_id - , concerts.event_type - FROM concert_attendance - JOIN concerts ON concerts.id = concert_attendance.concert_id - WHERE concert_attendance.user_id = %s - AND concert_attendance.status = 'attending' - AND COALESCE(concerts.end_datetime, concerts.start_datetime) < CURRENT_TIMESTAMP - ), venue_counts AS ( - SELECT venue_id, COUNT(*) AS visit_count - FROM attended - WHERE venue_id IS NOT NULL AND event_type <> 'other' - GROUP BY venue_id - ) - SELECT - (SELECT COUNT(*) FROM attended), - COALESCE((SELECT MAX(visit_count) FROM venue_counts), 0) + SELECT concerts.venue_id, concerts.event_type, + concerts.start_datetime::date, COALESCE(venues.country, '') + FROM concert_attendance + JOIN concerts ON concerts.id = concert_attendance.concert_id + LEFT JOIN venues ON venues.id = concerts.venue_id + WHERE concert_attendance.user_id = %s + AND concert_attendance.status = 'attending' + AND COALESCE(concerts.end_datetime, concerts.start_datetime) < CURRENT_TIMESTAMP """, (user_id,), ) - total, max_same_venue = cursor.fetchone() - return total, max_same_venue + rows = cursor.fetchall() + + venue_counts = {} + countries = set() + dates = [] + weekend_counts = {} + for venue_id, event_type, concert_date, country_value in rows: + if venue_id is not None and event_type != "other": + venue_counts[venue_id] = venue_counts.get(venue_id, 0) + 1 + country = normalize_country(country_value) + if country: + countries.add(country) + dates.append(concert_date) + if concert_date.isoweekday() >= 5: + weekend_key = concert_date.isocalendar()[:2] + weekend_counts[weekend_key] = weekend_counts.get(weekend_key, 0) + 1 + + distinct_dates = sorted(set(dates)) + return { + "total": len(rows), + "max_same_venue": max(venue_counts.values(), default=0), + "country_count": len(countries), + "has_foreign_concert": any(country != "deutschland" for country in countries), + "has_consecutive_days": any( + later - earlier == timedelta(days=1) + for earlier, later in zip(distinct_dates, distinct_dates[1:]) + ), + "has_iron_weekend": max(weekend_counts.values(), default=0) >= 3, + } -def grant_earned_badges(user_id: int, attended_count: int, max_same_venue, registered_at): +def grant_earned_badges(user_id: int, stats: dict, registered_at): highest_attendance_badge = None - venue_badge = "regular" if max_same_venue >= 5 else None + venue_badge = "regular" if stats["max_same_venue"] >= 5 else None for badge_code, _name, _icon, threshold, _description, category in BADGE_DEFINITIONS: - qualifies = category == "attendance" and threshold is not None and attended_count >= threshold + qualifies = category == "attendance" and threshold is not None and stats["total"] >= threshold if qualifies: highest_attendance_badge = badge_code @@ -1199,6 +1235,20 @@ def grant_earned_badges(user_id: int, attended_count: int, max_same_venue, regis "INSERT INTO user_badges (user_id, badge_code) VALUES (%s, %s) ON CONFLICT DO NOTHING", (user_id, venue_badge), ) + achievement_codes = [] + if stats["has_foreign_concert"]: + achievement_codes.append("border_breaker") + if stats["country_count"] >= 5: + achievement_codes.append("globe_banger") + if stats["has_consecutive_days"]: + achievement_codes.append("double_trouble") + if stats["has_iron_weekend"]: + achievement_codes.append("iron_weekend") + for badge_code in achievement_codes: + cursor.execute( + "INSERT INTO user_badges (user_id, badge_code) VALUES (%s, %s) ON CONFLICT DO NOTHING", + (user_id, badge_code), + ) connection.commit() @@ -2337,11 +2387,10 @@ def render_profile( {"username": row[0], "display_name": row[1], "avatar_path": row[2]} for row in cursor.fetchall() ] - attended_count, max_same_venue = attended_concert_stats(profile["id"]) + attendance_stats = attended_concert_stats(profile["id"]) grant_earned_badges( profile["id"], - attended_count, - max_same_venue, + attendance_stats, profile["registered_at"], ) profile = load_profile(username) @@ -2361,14 +2410,24 @@ def render_profile( for code, name, icon, threshold, description, category in BADGE_DEFINITIONS ] badges.sort(key=lambda badge: badge["sort_key"]) + badge_groups = [ + { + "code": category, + "label": label, + "badges": [badge for badge in badges if badge["earned"] and badge["category"] == category], + } + for category, label in BADGE_CATEGORY_LABELS.items() + if any(badge["earned"] and badge["category"] == category for badge in badges) + ] template = templates.get_template("profile.html") return HTMLResponse( template.render( user=viewer, profile=profile, - attended_count=attended_count, + attended_count=attendance_stats["total"], badges=badges, + badge_groups=badge_groups, is_own_profile=force_own or is_own_profile, can_view_details=can_view_details, friendship=friendship, diff --git a/app/templates/profile.html b/app/templates/profile.html index a16918f..fb42342 100644 --- a/app/templates/profile.html +++ b/app/templates/profile.html @@ -26,6 +26,10 @@ .kutte::before, .kutte::after { content: ""; position: absolute; top: 0; width: 30%; height: 62px; background: #111; border-bottom: 3px solid #404040; } .kutte::before { left: 0; border-radius: 24px 0 45% 0; } .kutte::after { right: 0; border-radius: 0 24px 0 45%; } + .patch-categories { display:grid; gap:10px; } + .patch-category { padding:10px 12px; background:rgba(5,5,5,.55); border:1px solid #44403c; border-radius:10px; } + .patch-category > summary { color:#fca5a5; cursor:pointer; font-weight:700; } + .patch-category .patch-grid { margin-top:14px; } .patch-grid { display: flex; flex-wrap: wrap; gap: 14px; justify-content: flex-start; align-items: flex-start; } .patch { padding: 5px; display: grid; place-items: center; text-align: center; background: #242424; border: 2px dashed #78716c; border-radius: 9px; color: #f8fafc; } .patch-image-frame { width: fit-content; height: fit-content; } @@ -122,22 +126,27 @@

🥋 Virtuelle Kutte

-

Deine freigeschalteten Patches. Besuchs-Patches werden jeweils durch die höchste Stufe ersetzt.

+

Deine freigeschalteten Patches nach Kategorien. Besuchs-Patches werden jeweils durch die höchste Stufe ersetzt.

-
- {% for badge in badges %} - {% if badge.earned %} -
- {% if badge.image_path %} - Patch {{ badge.name }} - {% else %} - {{ badge.icon }} - {% endif %} +
+ {% for group in badge_groups %} +
+ {{ group.label }} · {{ group.badges|length }} +
+ {% for badge in group.badges %} +
+ {% if badge.image_path %} + Patch {{ badge.name }} + {% else %} + {{ badge.icon }} + {% endif %} +
+ {% endfor %}
- {% endif %} +
{% endfor %} - {% if not profile.earned_codes %} + {% if not badge_groups %}

Noch keine Patches – dein erstes Konzert wartet!

{% endif %}