Skip to content

Commit 5a0705f

Browse files
feat: add pie chart of registration % on admin page for kishan (#203)
Co-authored-by: Nishant Aanjaney Jalan <[email protected]>
1 parent 88b1f7b commit 5a0705f

File tree

9 files changed

+272
-13
lines changed

9 files changed

+272
-13
lines changed

app/pages/index.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
<h1 class="font-ichack text-center text-4xl">Coming Soon...</h1>
55
<p class="mt-5 text-center text-2xl">
66
Come back nearer to the hackathon for your dashboard, profiles, teams,
7-
and more!
7+
our Discord server, and more!
88
</p>
99
</div>
1010
</div>

bun.lock

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"@types/nunjucks": "^3.2.6",
1515
"argon2": "^0.41.1",
1616
"build-url-ts": "^6.1.7",
17+
"chart.js": "^4.4.7",
1718
"csv-parse": "^5.6.0",
1819
"cva": "npm:class-variance-authority",
1920
"date-fns": "^4.1.0",
@@ -38,6 +39,7 @@
3839
"@inquirer/confirm": "^5.1.3",
3940
"@inquirer/select": "^4.0.6",
4041
"@types/bun": "^1.1.6",
42+
"@types/chart.js": "^2.9.41",
4143
"@types/matter-js": "^0.19.8",
4244
"@types/nodemailer": "^6.4.17",
4345
"@types/pg": "^8.11.6",
@@ -298,6 +300,8 @@
298300

299301
"@koa/router": ["@koa/[email protected]", "", { "dependencies": { "debug": "^4.3.4", "http-errors": "^2.0.0", "koa-compose": "^4.1.0", "methods": "^1.1.2", "path-to-regexp": "^6.3.0" } }, "sha512-sYcHglGKTxGF+hQ6x67xDfkE9o+NhVlRHBqq6gLywaMc6CojK/5vFZByphdonKinYlMLkEkacm+HEse9HzwgTA=="],
300302

303+
"@kurkle/color": ["@kurkle/[email protected]", "", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="],
304+
301305
"@kwsites/file-exists": ["@kwsites/[email protected]", "", { "dependencies": { "debug": "^4.1.1" } }, "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw=="],
302306

303307
"@kwsites/promise-deferred": ["@kwsites/[email protected]", "", {}, "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw=="],
@@ -526,6 +530,8 @@
526530

527531
"@types/bun": ["@types/[email protected]", "", { "dependencies": { "bun-types": "1.1.43" } }, "sha512-E+ue6NMcn4FXC5bDRE1W/BXUVs01h5Mt02qH8/8HGCox9akuh8KNOFdwvaQS9TDgT2RmUyJYFRRqA60WtTnm2g=="],
528532

533+
"@types/chart.js": ["@types/[email protected]", "", { "dependencies": { "moment": "^2.10.2" } }, "sha512-3dvkDvueckY83UyUXtJMalYoH6faOLkWQoaTlJgB4Djde3oORmNP0Jw85HtzTuXyliUHcdp704s0mZFQKio/KQ=="],
534+
529535
"@types/estree": ["@types/[email protected]", "", {}, "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw=="],
530536

531537
"@types/hast": ["@types/[email protected]", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
@@ -776,6 +782,8 @@
776782

777783
"character-entities-legacy": ["[email protected]", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
778784

785+
"chart.js": ["[email protected]", "", { "dependencies": { "@kurkle/color": "^0.3.0" } }, "sha512-pwkcKfdzTMAU/+jNosKhNL2bHtJc/sSmYgVbuGTEDhzkrhmyihmP7vUc/5ZK9WopidMDHNe3Wm7jOd/WhuHWuw=="],
786+
779787
"check-error": ["[email protected]", "", {}, "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw=="],
780788

781789
"cheerio": ["[email protected]", "", { "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.1.0", "encoding-sniffer": "^0.2.0", "htmlparser2": "^9.1.0", "parse5": "^7.1.2", "parse5-htmlparser2-tree-adapter": "^7.0.0", "parse5-parser-stream": "^7.1.2", "undici": "^6.19.5", "whatwg-mimetype": "^4.0.0" } }, "sha512-quS9HgjQpdaXOvsZz82Oz7uxtXiy6UIsIQcpBj7HRw2M63Skasm9qlDocAM7jNuaxdhpPU7c4kJN+gA5MCu4ww=="],
@@ -1382,6 +1390,8 @@
13821390

13831391
"mlly": ["[email protected]", "", { "dependencies": { "acorn": "^8.14.0", "pathe": "^1.1.2", "pkg-types": "^1.2.1", "ufo": "^1.5.4" } }, "sha512-xUsx5n/mN0uQf4V548PKQ+YShA4/IW0KI1dZhrNrPCLG+xizETbHTkOa1f8/xut9JRPp8kQuMnz0oqwkTiLo/A=="],
13841392

1393+
"moment": ["[email protected]", "", {}, "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how=="],
1394+
13851395
"mrmime": ["[email protected]", "", {}, "sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw=="],
13861396

13871397
"ms": ["[email protected]", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"@inquirer/confirm": "^5.1.3",
2424
"@inquirer/select": "^4.0.6",
2525
"@types/bun": "^1.1.6",
26+
"@types/chart.js": "^2.9.41",
2627
"@types/matter-js": "^0.19.8",
2728
"@types/nodemailer": "^6.4.17",
2829
"@types/pg": "^8.11.6",
@@ -58,6 +59,7 @@
5859
"@types/nunjucks": "^3.2.6",
5960
"argon2": "^0.41.1",
6061
"build-url-ts": "^6.1.7",
62+
"chart.js": "^4.4.7",
6163
"csv-parse": "^5.6.0",
6264
"cva": "npm:class-variance-authority",
6365
"date-fns": "^4.1.0",
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
<template>
2+
<UCard>
3+
<!-- <h3 class="text-center font-semibold">{{ title }}</h3> -->
4+
<canvas ref="chart" />
5+
</UCard>
6+
</template>
7+
8+
<script setup lang="ts">
9+
import {
10+
Chart,
11+
PieController,
12+
ArcElement,
13+
Tooltip,
14+
Legend,
15+
Title
16+
} from 'chart.js';
17+
18+
type Props = {
19+
title: string;
20+
labels: string[];
21+
data: number[];
22+
color: string[];
23+
};
24+
25+
const props = defineProps<Props>();
26+
27+
const chart = useTemplateRef<HTMLCanvasElement>('chart');
28+
29+
onMounted(() => {
30+
Chart.register(PieController, ArcElement, Tooltip, Legend, Title);
31+
try {
32+
new Chart(chart.value!.getContext('2d')!, {
33+
type: 'pie',
34+
data: {
35+
labels: props.labels,
36+
datasets: [
37+
{
38+
data: props.data,
39+
backgroundColor: props.color
40+
}
41+
]
42+
},
43+
options: {
44+
responsive: true,
45+
maintainAspectRatio: false,
46+
plugins: {
47+
legend: {
48+
display: true,
49+
position: 'bottom'
50+
},
51+
title: {
52+
display: true,
53+
text: props.title,
54+
font: {
55+
size: 24
56+
}
57+
}
58+
}
59+
}
60+
});
61+
} catch (e: any) {
62+
console.log(e);
63+
}
64+
});
65+
</script>

packages/admin/pages/admin/index.vue

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,49 @@
11
<script lang="ts" setup>
2+
const { getRegistrationStats } = useProfile();
3+
const { value: registrationStats } = await getRegistrationStats();
4+
const calculateStats = (stats: {
5+
registered_users: number;
6+
all_users: number;
7+
}) => [stats.registered_users, stats.all_users - stats.registered_users];
8+
const hackerStats = calculateStats(registrationStats!.hacker);
9+
const volunteerStats = calculateStats(registrationStats!.volunteer);
10+
const adminStats = calculateStats(registrationStats!.admin);
11+
212
definePageMeta({
313
middleware: ['require-auth'],
414
layout: 'admin'
515
});
16+
useHead({
17+
title: 'Admin Dashboard'
18+
});
619
</script>
720

821
<template>
9-
<div></div>
22+
<h2 class="pt-12 text-center text-5xl font-semibold">Admin Dashboard</h2>
23+
24+
<div class="mx-8 mt-8 grid grid-cols-3">
25+
<UCard class="col-span-2 row-span-2">
26+
<div class="grid grid-cols-2 gap-4">
27+
<h3 class="col-span-2 text-center text-3xl">Registration Statistics</h3>
28+
29+
<PieChart
30+
title="Hacker Stats"
31+
:labels="['Registered', 'Non-registered']"
32+
:data="hackerStats"
33+
:color="['#0060E6', '#D62D3B']" />
34+
35+
<PieChart
36+
title="Volunteer Stats"
37+
:labels="['Registered', 'Non-registered']"
38+
:data="volunteerStats"
39+
:color="['#0060E6', '#D62D3B']" />
40+
41+
<PieChart
42+
title="Admin Stats"
43+
:labels="['Registered', 'Non-registered']"
44+
:data="adminStats"
45+
:color="['#0060E6', '#D62D3B']" />
46+
</div>
47+
</UCard>
48+
</div>
1049
</template>

packages/admin/pages/admin/users.vue

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ const tableColumns = [
2727
];
2828
2929
// List of users related properties
30-
const { getProfiles, getSelf } = useProfile();
30+
const { getProfiles, getSelf, getRegistrationStats } = useProfile();
3131
const { deleteUser, createUser } = useAdmin();
3232
const { profile } = useProfileStore();
3333
const { data, refresh, status, error } = await useAsyncData(
@@ -99,11 +99,8 @@ definePageMeta({
9999

100100
<template>
101101
<UContainer class="relative max-h-full overflow-y-scroll">
102-
<h2 class="text-center text-5xl font-semibold">Manage Users</h2>
103-
<UButton
104-
label="Add User"
105-
@click="isAddUserPopupOpen = true"
106-
v-if="profile!.role == 'god'" />
102+
<h2 class="pt-12 text-center text-5xl font-semibold">Manage Users</h2>
103+
107104
<UAlert v-if="error" :title="error.message" />
108105
<UTable
109106
v-else

packages/common/composables/useProfile.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,22 @@ export default () => {
5555
return;
5656
});
5757

58+
const getRegistrationStats = async () =>
59+
Result.try(async () => {
60+
const res = await client.profile.register.stats.$get();
61+
62+
if (!res.ok) {
63+
const errorMessage = await res.text();
64+
throw new Error(errorMessage);
65+
}
66+
67+
return res.json();
68+
});
69+
5870
return {
5971
getProfiles,
6072
getSelf,
61-
register
73+
register,
74+
getRegistrationStats
6275
};
6376
};

server/src/profile/__tests__/registerUser.test.ts

Lines changed: 109 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
1+
import {
2+
afterAll,
3+
beforeAll,
4+
beforeEach,
5+
describe,
6+
expect,
7+
test
8+
} from 'bun:test';
29
import { profiles, type Profile, type SelectedProfile } from '../schema';
310
import { db } from '../../drizzle';
411
import { users, userSession, userToken } from '../../auth/schema';
@@ -23,7 +30,7 @@ const expectedSkeleton = {
2330
const expectedGet: Partial<Record<Role, SelectedProfile>> = {};
2431
const expectedSearch: Partial<Record<Role, Profile>> = {};
2532

26-
beforeAll(async () => {
33+
beforeEach(async () => {
2734
// Insert sample users into the database & sign in as one
2835
await db.execute(sql`TRUNCATE ${userSession} CASCADE`);
2936
await db.execute(sql`TRUNCATE ${users} CASCADE`);
@@ -271,3 +278,103 @@ describe('Profiles module > POST /register', () => {
271278
await db.delete(userToken).where(eq(userToken.id, token));
272279
});
273280
});
281+
282+
describe('Profile Modules > GET /register/stats', () => {
283+
test('can get registration stats', async () => {
284+
const res = await baseRoute.register.stats.$get(undefined, {
285+
headers: {
286+
Cookie: `auth_session=${sessionIds.god!!}`
287+
}
288+
});
289+
expect(res.status).toBe(200);
290+
const json = await res.json();
291+
292+
expect(json.hacker).toMatchObject({
293+
all_users: 1,
294+
registered_users: 1
295+
});
296+
297+
expect(json.volunteer).toMatchObject({
298+
all_users: 1,
299+
registered_users: 1
300+
});
301+
302+
expect(json.admin).toMatchObject({
303+
all_users: 1,
304+
registered_users: 1
305+
});
306+
});
307+
308+
test('registration stats is accurate', async () => {
309+
// Create new user
310+
const { userId, sessionId } = await createUserWithSession('hacker', {
311+
name: 'Jay Silver',
312+
313+
password: null
314+
});
315+
316+
// Expect all_users to increase by 1
317+
let res = await baseRoute.register.stats.$get(undefined, {
318+
headers: {
319+
Cookie: `auth_session=${sessionIds.god!!}`
320+
}
321+
});
322+
expect(res.status).toBe(200);
323+
let json = await res.json();
324+
expect(json.hacker).toMatchObject({
325+
all_users: 2,
326+
registered_users: 1
327+
});
328+
329+
// Register user
330+
const token = 'funnyLittleToken';
331+
await db.insert(userToken).values({
332+
id: token,
333+
userId: userId,
334+
expiresAt: tomorrow,
335+
type: 'registration_link'
336+
});
337+
338+
const registrationRes = await baseRoute.register.$post(
339+
{
340+
query: {
341+
token: token
342+
},
343+
form: {
344+
registrationDetails: JSON.stringify({
345+
password: 'This12Secure@',
346+
tShirtSize: 'M',
347+
dietary_restrictions: ['halal', 'green'],
348+
photos_opt_out: false,
349+
pronouns: ''
350+
})
351+
}
352+
},
353+
undefined
354+
);
355+
expect(registrationRes.status).toBe(200);
356+
357+
// Expect registered_users to increase by 1
358+
res = await baseRoute.register.stats.$get(undefined, {
359+
headers: {
360+
Cookie: `auth_session=${sessionIds.god!!}`
361+
}
362+
});
363+
expect(res.status).toBe(200);
364+
json = await res.json();
365+
expect(json.hacker).toMatchObject({
366+
all_users: 2,
367+
registered_users: 2
368+
});
369+
});
370+
371+
test('only admins can get registration stats', async () => {
372+
const res = await baseRoute.register.stats.$get(undefined, {
373+
headers: {
374+
Cookie: `auth_session=${sessionIds.hacker!!}`
375+
}
376+
});
377+
// @ts-expect-error - auth error :3
378+
expect(res.status).toBe(403);
379+
});
380+
});

0 commit comments

Comments
 (0)