feat: add travel and concert streak patches

This commit is contained in:
kai
2026-08-28 13:58:41 +02:00
parent 2080c5c971
commit f05bae9ab1
2 changed files with 107 additions and 39 deletions
+86 -27
View File
@@ -89,6 +89,10 @@ BADGE_DEFINITIONS = (
("tour_veteran", "25 Gigs", "", 25, "25 besuchte Konzerte", "attendance"), ("tour_veteran", "25 Gigs", "", 25, "25 besuchte Konzerte", "attendance"),
("fifty_gigs", "50 Gigs", "💀", 50, "50 besuchte Konzerte", "attendance"), ("fifty_gigs", "50 Gigs", "💀", 50, "50 besuchte Konzerte", "attendance"),
("hundred_gigs", "100 Gigs", "👑", 100, "100 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( ATTENDANCE_BADGE_CODES = tuple(
badge_code badge_code
@@ -104,6 +108,14 @@ BADGE_BY_CODE = {
badge_code: (name, icon, threshold, description, category) badge_code: (name, icon, threshold, description, category)
for badge_code, name, icon, threshold, description, category in BADGE_DEFINITIONS 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(): def get_db_connection():
@@ -1129,41 +1141,65 @@ def remove_uploaded_file(path: str | None, destination_dir: str, url_prefix: str
return True 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 get_db_connection() as connection:
with connection.cursor() as cursor: with connection.cursor() as cursor:
cursor.execute( cursor.execute(
""" """
WITH attended AS ( SELECT concerts.venue_id, concerts.event_type,
SELECT concerts.venue_id concerts.start_datetime::date, COALESCE(venues.country, '')
, concerts.event_type FROM concert_attendance
FROM concert_attendance JOIN concerts ON concerts.id = concert_attendance.concert_id
JOIN concerts ON concerts.id = concert_attendance.concert_id LEFT JOIN venues ON venues.id = concerts.venue_id
WHERE concert_attendance.user_id = %s WHERE concert_attendance.user_id = %s
AND concert_attendance.status = 'attending' AND concert_attendance.status = 'attending'
AND COALESCE(concerts.end_datetime, concerts.start_datetime) < CURRENT_TIMESTAMP 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)
""", """,
(user_id,), (user_id,),
) )
total, max_same_venue = cursor.fetchone() rows = cursor.fetchall()
return total, max_same_venue
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 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: 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: if qualifies:
highest_attendance_badge = badge_code 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", "INSERT INTO user_badges (user_id, badge_code) VALUES (%s, %s) ON CONFLICT DO NOTHING",
(user_id, venue_badge), (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() connection.commit()
@@ -2337,11 +2387,10 @@ def render_profile(
{"username": row[0], "display_name": row[1], "avatar_path": row[2]} {"username": row[0], "display_name": row[1], "avatar_path": row[2]}
for row in cursor.fetchall() 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( grant_earned_badges(
profile["id"], profile["id"],
attended_count, attendance_stats,
max_same_venue,
profile["registered_at"], profile["registered_at"],
) )
profile = load_profile(username) profile = load_profile(username)
@@ -2361,14 +2410,24 @@ def render_profile(
for code, name, icon, threshold, description, category in BADGE_DEFINITIONS for code, name, icon, threshold, description, category in BADGE_DEFINITIONS
] ]
badges.sort(key=lambda badge: badge["sort_key"]) 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") template = templates.get_template("profile.html")
return HTMLResponse( return HTMLResponse(
template.render( template.render(
user=viewer, user=viewer,
profile=profile, profile=profile,
attended_count=attended_count, attended_count=attendance_stats["total"],
badges=badges, badges=badges,
badge_groups=badge_groups,
is_own_profile=force_own or is_own_profile, is_own_profile=force_own or is_own_profile,
can_view_details=can_view_details, can_view_details=can_view_details,
friendship=friendship, friendship=friendship,
+21 -12
View File
@@ -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, .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::before { left: 0; border-radius: 24px 0 45% 0; }
.kutte::after { right: 0; border-radius: 0 24px 0 45%; } .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-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 { 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; } .patch-image-frame { width: fit-content; height: fit-content; }
@@ -122,22 +126,27 @@
</section> </section>
<section class="badges-card"> <section class="badges-card">
<h2>🥋 Virtuelle Kutte</h2> <h2>🥋 Virtuelle Kutte</h2>
<p>Deine freigeschalteten Patches. Besuchs-Patches werden jeweils durch die höchste Stufe ersetzt.</p> <p>Deine freigeschalteten Patches nach Kategorien. Besuchs-Patches werden jeweils durch die höchste Stufe ersetzt.</p>
<div class="kutte"> <div class="kutte">
<div class="patch-grid"> <div class="patch-categories">
{% for badge in badges %} {% for group in badge_groups %}
{% if badge.earned %} <details class="patch-category">
<div class="patch earned {% if badge.image_path %}patch-image-frame{% else %}patch-icon-frame{% endif %}" title="{{ badge.name }} {{ badge.description }}" aria-label="{{ badge.name }}: {{ badge.description }}"> <summary>{{ group.label }} · {{ group.badges|length }}</summary>
{% if badge.image_path %} <div class="patch-grid">
<img class="patch-image" src="{{ badge.image_path }}" alt="Patch {{ badge.name }}"> {% for badge in group.badges %}
{% else %} <div class="patch earned {% if badge.image_path %}patch-image-frame{% else %}patch-icon-frame{% endif %}" title="{{ badge.name }} {{ badge.description }}" aria-label="{{ badge.name }}: {{ badge.description }}">
<span class="patch-icon">{{ badge.icon }}</span> {% if badge.image_path %}
{% endif %} <img class="patch-image" src="{{ badge.image_path }}" alt="Patch {{ badge.name }}">
{% else %}
<span class="patch-icon">{{ badge.icon }}</span>
{% endif %}
</div>
{% endfor %}
</div> </div>
{% endif %} </details>
{% endfor %} {% endfor %}
{% if not profile.earned_codes %} {% if not badge_groups %}
<p>Noch keine Patches dein erstes Konzert wartet!</p> <p>Noch keine Patches dein erstes Konzert wartet!</p>
{% endif %} {% endif %}
</div> </div>