Skip to content

Commit 1b826bc

Browse files
committed
feat: add Leaderboard feature with filtering and display components
1 parent 740de4e commit 1b826bc

File tree

4 files changed

+295
-1
lines changed

4 files changed

+295
-1
lines changed
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
<template>
2+
<Card>
3+
<CardHeader>
4+
<h3 class="font-semibold">{{ title }}</h3>
5+
</CardHeader>
6+
<CardContent>
7+
<div v-if="loading" class="p-4">Loading…</div>
8+
<div v-else-if="rows.length === 0" class="p-4 text-muted-foreground">
9+
{{ emptyMessage }}
10+
</div>
11+
<div v-else class="space-y-4">
12+
<div
13+
v-for="row in rows"
14+
:key="row.memberId"
15+
:class="[
16+
'leader-row flex items-center justify-between p-3 border rounded',
17+
getRowClass(row.rank),
18+
]"
19+
>
20+
<div class="flex items-center gap-3">
21+
<div class="w-12 text-sm font-medium flex items-center gap-2">
22+
<Crown v-if="row.rank === 1" class="h-6 w-6 text-yellow-500" />
23+
<Trophy
24+
v-else-if="row.rank === 2"
25+
class="h-6 w-6 text-slate-400"
26+
/>
27+
<Star v-else-if="row.rank === 3" class="h-5 w-5" />
28+
<span v-else class="text-sm">{{ row.rank }}</span>
29+
</div>
30+
31+
<MemberWithAvatar :member="row.member" />
32+
</div>
33+
34+
<div class="text-sm font-medium">{{ row.count }} {{ itemLabel }}</div>
35+
</div>
36+
</div>
37+
</CardContent>
38+
</Card>
39+
</template>
40+
41+
<script setup lang="ts">
42+
import type { Member } from "@/dto/members";
43+
import Card from "@/components/ui/card/Card.vue";
44+
import CardContent from "@/components/ui/card/CardContent.vue";
45+
import CardHeader from "@/components/ui/card/CardHeader.vue";
46+
import MemberWithAvatar from "@/components/members/MemberWithAvatar.vue";
47+
import { Crown, Trophy, Star } from "lucide-vue-next";
48+
49+
interface Row {
50+
memberId: string;
51+
count: number;
52+
items: string[];
53+
member: Member;
54+
rank: number;
55+
}
56+
57+
withDefaults(
58+
defineProps<{
59+
title: string;
60+
rows: Row[];
61+
loading?: boolean;
62+
itemLabel?: string;
63+
emptyMessage?: string;
64+
}>(),
65+
{
66+
loading: false,
67+
itemLabel: "items",
68+
emptyMessage: "No invitations found.",
69+
},
70+
);
71+
72+
const getRowClass = (rank: number) => {
73+
switch (rank) {
74+
case 1:
75+
return "bg-yellow-50"; // light gold
76+
case 2:
77+
return "bg-slate-50"; // light silver
78+
case 3:
79+
return "bg-amber-50"; // light bronze/amber
80+
default:
81+
return "";
82+
}
83+
};
84+
</script>
85+
86+
<style scoped>
87+
.leader-row img {
88+
border-radius: 0.375rem;
89+
}
90+
</style>

frontend/src/components/Navbar.vue

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<script setup lang="ts">
22
import { computed, ref, watch, type FunctionalComponent } from "vue";
3-
import { Menu, X, LogOut, Settings } from "lucide-vue-next";
3+
import { Menu, X, LogOut, Settings, Trophy } from "lucide-vue-next";
44
import { Button } from "@/components/ui/button";
55
import type { RouteLocationRaw } from "vue-router";
66
import { useQuery } from "@pinia/colada";
@@ -40,6 +40,7 @@ const navigation: NavigationItem[] = [
4040
{ name: "Me", to: { name: "dashboard" } },
4141
{ name: "Companies", to: { name: "companies" } },
4242
{ name: "Speakers", to: { name: "speakers" } },
43+
{ name: "Leaderboard", to: { name: "leaderboard" }, icon: Trophy },
4344
{ name: "Settings", to: { name: "settings" }, icon: Settings },
4445
];
4546

frontend/src/router.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,12 @@ const router = createRouter({
4646
name: "settings",
4747
component: () => import("./views/Dashboard/SettingsView.vue"),
4848
},
49+
{
50+
path: "leaderboard",
51+
name: "leaderboard",
52+
component: () =>
53+
import("./views/Dashboard/Leaderboard/LeaderboardView.vue"),
54+
},
4955
],
5056
},
5157
],
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
<template>
2+
<div class="space-y-6">
3+
<div class="flex items-center justify-between">
4+
<h2 class="text-2xl font-semibold">Leaderboard</h2>
5+
6+
<div class="flex items-center gap-3">
7+
<label class="text-sm text-muted-foreground">Filter status</label>
8+
<select v-model="selectedStatus" class="border rounded px-2 py-1">
9+
<option value="">All</option>
10+
<option
11+
v-for="(label, key) in humanReadableParticipationStatus"
12+
:key="key"
13+
:value="key"
14+
>
15+
{{ label }}
16+
</option>
17+
</select>
18+
</div>
19+
</div>
20+
21+
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
22+
<LeaderboardCard
23+
title="Companies invited"
24+
:rows="companyLeaderboard"
25+
:loading="isLoadingCompanies || isLoadingMembers"
26+
item-label="companies"
27+
/>
28+
29+
<LeaderboardCard
30+
title="Speakers invited"
31+
:rows="speakerLeaderboard"
32+
:loading="isLoadingSpeakers || isLoadingMembers"
33+
item-label="speakers"
34+
/>
35+
</div>
36+
</div>
37+
</template>
38+
39+
<script setup lang="ts">
40+
import { computed, ref } from "vue";
41+
import { useQuery } from "@pinia/colada";
42+
import { getAllCompanies } from "@/api/companies";
43+
import { getAllSpeakers } from "@/api/speakers";
44+
import { getAllMembers } from "@/api/members";
45+
import type { Company, CompanyParticipation } from "@/dto/companies";
46+
import type { Speaker, SpeakerParticipation } from "@/dto/speakers";
47+
import type { Member } from "@/dto/members";
48+
import { useEventStore } from "@/stores/event";
49+
import LeaderboardCard from "@/components/Leaderboard/LeaderboardCard.vue";
50+
import { humanReadableParticipationStatus } from "@/dto";
51+
// icons are used inside LeaderboardCard
52+
53+
const BOT_ACCOUNTS = new Set(["ToolBot!", "zzPartnerships"]);
54+
55+
const eventStore = useEventStore();
56+
const selectedStatus = ref<string>("");
57+
58+
const companiesFilters = computed(() => ({
59+
event: eventStore.selectedEvent?.id,
60+
}));
61+
const speakersFilters = computed(() => ({
62+
event: eventStore.selectedEvent?.id,
63+
}));
64+
const membersFilters = computed(() => ({
65+
event: eventStore.selectedEvent?.id,
66+
}));
67+
68+
const { data: companiesData, isLoading: isLoadingCompanies } = useQuery({
69+
key: () => ["leaderboard-companies", JSON.stringify(companiesFilters.value)],
70+
query: () => getAllCompanies(companiesFilters.value),
71+
});
72+
73+
const { data: speakersData, isLoading: isLoadingSpeakers } = useQuery({
74+
key: () => ["leaderboard-speakers", JSON.stringify(speakersFilters.value)],
75+
query: () => getAllSpeakers(speakersFilters.value),
76+
});
77+
78+
const { data: membersData, isLoading: isLoadingMembers } = useQuery({
79+
key: () => ["leaderboard-members", JSON.stringify(membersFilters.value)],
80+
query: () => getAllMembers(membersFilters.value),
81+
});
82+
83+
type LeaderRow = {
84+
memberId: string;
85+
count: number;
86+
items: string[]; // names
87+
member: Member;
88+
};
89+
type RankedLeaderRow = LeaderRow & { rank: number };
90+
91+
// Helper function to compute ranks with ties
92+
function computeRanks(rows: LeaderRow[]): RankedLeaderRow[] {
93+
const sorted = [...rows].sort((a, b) => b.count - a.count);
94+
const ranked: RankedLeaderRow[] = [];
95+
let prevCount: number | null = null;
96+
let prevRank = 0;
97+
for (let i = 0; i < sorted.length; i++) {
98+
const item = sorted[i];
99+
const rank =
100+
prevCount !== null && item.count === prevCount ? prevRank : prevRank + 1;
101+
prevCount = item.count;
102+
prevRank = rank;
103+
ranked.push({ ...item, rank });
104+
}
105+
return ranked;
106+
}
107+
108+
const companyLeaderboard = computed<RankedLeaderRow[]>(() => {
109+
const rows = new Map<string, LeaderRow>();
110+
const eventId = eventStore.selectedEvent?.id;
111+
const companies = (companiesData.value?.data || []) as Company[];
112+
companies.forEach((c: Company) => {
113+
(c.participations || []).forEach((p: CompanyParticipation) => {
114+
if (p.event !== eventId) return;
115+
if (selectedStatus.value && p.status !== selectedStatus.value) return;
116+
if (!p.member) return;
117+
const id = p.member as string;
118+
const entry =
119+
rows.get(id) ||
120+
({
121+
memberId: id,
122+
count: 0,
123+
items: [] as string[],
124+
member: { id, name: "Unknown", img: "", sinfoid: "" } as Member,
125+
} as LeaderRow);
126+
entry.count += 1;
127+
entry.items.push(c.name);
128+
rows.set(id, entry);
129+
});
130+
});
131+
132+
const arr = Array.from(rows.values());
133+
// attach member objects
134+
arr.forEach((r) => {
135+
r.member =
136+
membersData.value?.data?.find((m: Member) => m.id === r.memberId) ||
137+
({ id: r.memberId, name: "Unknown", img: "", sinfoid: "" } as Member);
138+
});
139+
140+
// filter out bot accounts by name or sinfoid
141+
const filtered = arr.filter((r) => {
142+
const name = (r.member?.name || "") as string;
143+
const sinfoid = (r.member?.sinfoid || "") as string;
144+
return !BOT_ACCOUNTS.has(name) && !BOT_ACCOUNTS.has(sinfoid);
145+
});
146+
147+
return computeRanks(filtered);
148+
});
149+
150+
const speakerLeaderboard = computed<RankedLeaderRow[]>(() => {
151+
const rows = new Map<string, LeaderRow>();
152+
const eventId = eventStore.selectedEvent?.id;
153+
const speakers = (speakersData.value?.data || []) as Speaker[];
154+
speakers.forEach((s: Speaker) => {
155+
(s.participations || []).forEach((p: SpeakerParticipation) => {
156+
if (p.event !== eventId) return;
157+
if (selectedStatus.value && p.status !== selectedStatus.value) return;
158+
if (!p.member) return;
159+
const id = p.member as string;
160+
const entry =
161+
rows.get(id) ||
162+
({
163+
memberId: id,
164+
count: 0,
165+
items: [] as string[],
166+
member: { id, name: "Unknown", img: "", sinfoid: "" } as Member,
167+
} as LeaderRow);
168+
entry.count += 1;
169+
entry.items.push(s.name);
170+
rows.set(id, entry);
171+
});
172+
});
173+
174+
const arr = Array.from(rows.values());
175+
arr.forEach((r) => {
176+
r.member =
177+
membersData.value?.data?.find((m: Member) => m.id === r.memberId) ||
178+
({ id: r.memberId, name: "Unknown", img: "", sinfoid: "" } as Member);
179+
});
180+
181+
// filter out bot accounts by name or sinfoid
182+
const bots = new Set(["ToolBot!", "zzPartnernerships"]);
183+
const filtered = arr.filter((r) => {
184+
const name = (r.member?.name || "") as string;
185+
const sinfoid = (r.member?.sinfoid || "") as string;
186+
return !bots.has(name) && !bots.has(sinfoid);
187+
});
188+
189+
return computeRanks(filtered);
190+
});
191+
</script>
192+
193+
<style scoped>
194+
.leader-row img {
195+
border-radius: 0.375rem;
196+
}
197+
</style>

0 commit comments

Comments
 (0)