diff --git a/app/main.py b/app/main.py
index 07313c1..12caace 100644
--- a/app/main.py
+++ b/app/main.py
@@ -81,11 +81,11 @@ BADGE_DEFINITIONS = (
("admin", "Admin", "🏴☠️", None, "Verantwortung für Pingu Concerts", "special"),
("beta_tester", "Beta Tester", "🧪", None, "In der Beta dabei", "beta"),
("first_gig", "Erster Gig", "🎸", 1, "Dein erstes besuchtes Konzert", "attendance"),
- ("regular", "Stammgast", "🤘", 5, "5 Konzerte besucht", "attendance"),
- ("ten_gigs", "Zehnerrunde", "🔥", 10, "10 Konzerte besucht", "attendance"),
- ("tour_veteran", "Tourveteran", "⚡", 25, "25 Konzerte besucht", "attendance"),
- ("fifty_gigs", "Fünfzig Konzerte", "💀", 50, "50 Konzerte besucht", "attendance"),
- ("hundred_gigs", "Hunderterclub", "👑", 100, "100 Konzerte besucht", "attendance"),
+ ("regular", "Stammgast", "🤘", 5, "5 Konzerte am selben Veranstaltungsort besucht", "attendance"),
+ ("ten_gigs", "Stammgast · Level 10", "🔥", 10, "10 Konzerte am selben Veranstaltungsort besucht", "attendance"),
+ ("tour_veteran", "Stammgast · Level 25", "⚡", 25, "25 Konzerte am selben Veranstaltungsort besucht", "attendance"),
+ ("fifty_gigs", "Stammgast · Level 50", "💀", 50, "50 Konzerte am selben Veranstaltungsort besucht", "attendance"),
+ ("hundred_gigs", "Stammgast · Level 100", "👑", 100, "100 Konzerte am selben Veranstaltungsort besucht", "attendance"),
)
ATTENDANCE_BADGE_CODES = tuple(
badge_code
@@ -1085,31 +1085,43 @@ def remove_uploaded_file(path: str | None, destination_dir: str, url_prefix: str
return True
-def attended_concert_count(user_id: int) -> int:
+def attended_concert_stats(user_id: int) -> tuple[int, int]:
with get_db_connection() as connection:
with connection.cursor() as cursor:
cursor.execute(
"""
- SELECT COUNT(*)
- 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
+ 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)
""",
(user_id,),
)
- return cursor.fetchone()[0]
+ total, max_same_venue = cursor.fetchone()
+ return total, max_same_venue
-def grant_earned_badges(user_id: int, attended_count: int, registered_at):
+def grant_earned_badges(user_id: int, attended_count: int, max_same_venue, registered_at):
highest_attendance_badge = None
for badge_code, _name, _icon, threshold, _description, category in BADGE_DEFINITIONS:
- if category == "attendance" and attended_count >= threshold:
+ qualifies = category == "attendance" and attended_count >= threshold
+ if category == "attendance" and threshold >= 5:
+ qualifies = max_same_venue >= threshold
+ if qualifies:
highest_attendance_badge = badge_code
with get_db_connection() as connection:
@@ -2205,10 +2217,11 @@ def render_profile(
connections["incoming"].append(item)
else:
connections["outgoing"].append(item)
- attended_count = attended_concert_count(profile["id"])
+ attended_count, max_same_venue = attended_concert_stats(profile["id"])
grant_earned_badges(
profile["id"],
attended_count,
+ max_same_venue,
profile["registered_at"],
)
profile = load_profile(username)
diff --git a/app/static/css/style.css b/app/static/css/style.css
index 73a853e..d5f6a9f 100644
--- a/app/static/css/style.css
+++ b/app/static/css/style.css
@@ -23,10 +23,20 @@ body {
"Segoe UI",
sans-serif;
- background: radial-gradient(circle at 50% -15%, #320909 0, transparent 32rem), var(--bg);
+ background:
+ linear-gradient(rgba(4, 4, 6, .58), rgba(4, 4, 6, .72)),
+ url('/static/images/crowd-stage-background.webp') center top / cover fixed no-repeat,
+ var(--bg);
color: var(--text);
}
+@media (max-width: 700px) {
+ body {
+ background-attachment: scroll;
+ background-position: center top;
+ }
+}
+
a {
color: inherit;
text-decoration: none;
@@ -366,6 +376,74 @@ form .button {
.admin-danger { padding: 9px 12px; color: #fecaca; border: 1px solid #7f1d1d; background: #450a0a; border-radius: 8px; cursor: pointer; }
.admin-danger:disabled { opacity: .4; cursor: not-allowed; }
+/* Heavy-Metal-Theme: Poster, Bühne und Backstage-Pass statt Standard-UI */
+body::after {
+ content: "";
+ position: fixed;
+ inset: 0;
+ pointer-events: none;
+ z-index: 0;
+ opacity: .16;
+ background-image:
+ radial-gradient(circle at 18% 22%, rgba(255,255,255,.12) 0 1px, transparent 1px),
+ radial-gradient(circle at 73% 64%, rgba(255,255,255,.08) 0 1px, transparent 1px);
+ background-size: 7px 9px, 11px 13px;
+ mix-blend-mode: screen;
+}
+
+header, main, .container { position: relative; z-index: 1; }
+
+h1, h2, h3, .logo, .button, button, .page-title h1 {
+ font-family: Impact, Haettenschweiler, "Arial Narrow Bold", sans-serif;
+ letter-spacing: .045em;
+}
+
+h1, .page-title h1 {
+ text-transform: uppercase;
+ text-shadow: 2px 2px 0 #250707, 0 0 18px rgba(220,38,38,.24);
+}
+
+.page-title {
+ border-left: 4px solid var(--accent);
+ padding-left: 16px;
+}
+
+.button {
+ border: 1px solid #ef4444;
+ box-shadow: 0 3px 0 #450a0a, 0 0 14px rgba(185,28,28,.2);
+ text-transform: uppercase;
+}
+
+.button:hover { transform: translateY(-1px); box-shadow: 0 4px 0 #450a0a, 0 0 20px rgba(220,38,38,.35); }
+
+.concert-card, .admin-section, .profile-card, .badges-card, .profile-edit {
+ box-shadow: 0 12px 28px rgba(0,0,0,.3), inset 0 1px rgba(255,255,255,.025);
+}
+
+.concert-card { position: relative; border-left: 3px solid #7f1d1d; }
+.concert-card::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ border-radius: inherit;
+ background: linear-gradient(120deg, rgba(255,255,255,.035), transparent 32%);
+}
+
+.event-category {
+ padding: 3px 8px;
+ border: 1px solid #5f1515;
+ border-radius: 999px;
+ background: rgba(127,29,29,.3);
+}
+
+.empty-state { background: rgba(16,16,16,.86); border-style: solid; }
+
+@media (max-width: 600px) {
+ .page-title { padding-left: 11px; }
+ .button { min-height: 42px; }
+}
+
@media (max-width: 700px) {
.admin-nav { display: grid; grid-template-columns: 1fr; }
.admin-section { padding: 16px; }
diff --git a/app/static/images/crowd-stage-background.webp b/app/static/images/crowd-stage-background.webp
new file mode 100644
index 0000000..73d1544
Binary files /dev/null and b/app/static/images/crowd-stage-background.webp differ
diff --git a/app/templates/index.html b/app/templates/index.html
index ee6f052..92a5eb5 100644
--- a/app/templates/index.html
+++ b/app/templates/index.html
@@ -14,7 +14,7 @@