Skip to content

Commit abb8b3c

Browse files
committed
Add daily applications summary charts for admins
- Upgrade package for Cloudscape components - Add summary charts to the Applicants page on the Admin site - Show daily applications grouped by school as a stacked bar chart - Show daily cumulative applications by role as a stacked area chart
1 parent 0554201 commit abb8b3c

11 files changed

+495
-89
lines changed

apps/api/src/admin/summary_handler.py

+89-2
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
1-
from collections import Counter
1+
from collections import Counter, defaultdict
2+
from datetime import date, datetime
3+
from typing import Iterable
4+
from zoneinfo import ZoneInfo
25

3-
from pydantic import BaseModel, TypeAdapter
6+
from pydantic import BaseModel, TypeAdapter, ValidationError
47

58
from models.user_record import ApplicantStatus, Role
69
from services import mongodb_handler
710
from services.mongodb_handler import Collection
811

12+
LOCAL_TIMEZONE = ZoneInfo("America/Los_Angeles")
13+
914

1015
class ApplicantSummaryRecord(BaseModel):
1116
status: ApplicantStatus
@@ -21,3 +26,85 @@ async def applicant_summary() -> Counter[ApplicantStatus]:
2126
applicants = TypeAdapter(list[ApplicantSummaryRecord]).validate_python(records)
2227

2328
return Counter(applicant.status for applicant in applicants)
29+
30+
31+
class ApplicationSubmissionTime(BaseModel):
32+
submission_time: datetime
33+
34+
35+
class ApplicationSchoolAndTime(ApplicationSubmissionTime):
36+
school: str
37+
38+
39+
class ApplicantSchoolStats(BaseModel):
40+
application_data: ApplicationSchoolAndTime
41+
42+
43+
async def applications_by_school() -> dict[str, dict[date, int]]:
44+
"""Get daily number of applications by school."""
45+
records = await mongodb_handler.retrieve(
46+
Collection.USERS,
47+
{"roles": Role.APPLICANT},
48+
["application_data.school", "application_data.submission_time"],
49+
)
50+
51+
try:
52+
applicant_stats_adapter = TypeAdapter(list[ApplicantSchoolStats])
53+
applicants = applicant_stats_adapter.validate_python(records)
54+
except ValidationError:
55+
raise RuntimeError("Could not parse applicant data.")
56+
57+
grouped_applications: dict[str, dict[date, int]] = defaultdict(
58+
lambda: defaultdict(int)
59+
)
60+
61+
for applicant in applicants:
62+
school = applicant.application_data.school
63+
day = applicant.application_data.submission_time.astimezone(
64+
LOCAL_TIMEZONE
65+
).date()
66+
grouped_applications[school][day] += 1
67+
68+
return grouped_applications
69+
70+
71+
class ApplicantRoleStats(BaseModel):
72+
roles: tuple[Role, ...]
73+
application_data: ApplicationSubmissionTime
74+
75+
76+
async def applications_by_role() -> dict[str, dict[date, int]]:
77+
"""Get daily number of applications by role."""
78+
records: list[dict[str, object]] = await mongodb_handler.retrieve(
79+
Collection.USERS,
80+
{"roles": Role.APPLICANT},
81+
["roles", "application_data.submission_time"],
82+
)
83+
84+
try:
85+
applicant_stats_adapter = TypeAdapter(list[ApplicantRoleStats])
86+
applicants = applicant_stats_adapter.validate_python(records)
87+
except ValidationError:
88+
raise RuntimeError("Could not parse applicant data.")
89+
90+
return {
91+
role.value: _count_applications_by_day(
92+
applicant.application_data
93+
for applicant in applicants
94+
if role in applicant.roles
95+
)
96+
for role in [Role.HACKER, Role.MENTOR, Role.VOLUNTEER]
97+
}
98+
99+
100+
def _count_applications_by_day(
101+
application_data: Iterable[ApplicationSubmissionTime],
102+
) -> dict[date, int]:
103+
"""Group the applications by the date of submission."""
104+
daily_applications = defaultdict[date, int](int)
105+
106+
for data in application_data:
107+
day = data.submission_time.astimezone(LOCAL_TIMEZONE).date()
108+
daily_applications[day] += 1
109+
110+
return daily_applications

apps/api/src/routers/admin.py

+19-4
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
import asyncio
2-
from datetime import datetime
2+
from datetime import date, datetime
33
from logging import getLogger
4-
from typing import Annotated, Any, Optional, Sequence
4+
from typing import Annotated, Any, Literal, Optional, Sequence
55

66
from fastapi import APIRouter, Body, Depends, HTTPException, status
77
from pydantic import BaseModel, EmailStr, TypeAdapter, ValidationError
8+
from typing_extensions import assert_never
89

9-
from admin import participant_manager, summary_handler
10-
from admin import applicant_review_processor
10+
from admin import applicant_review_processor, participant_manager, summary_handler
1111
from admin.participant_manager import Participant
1212
from auth.authorization import require_role
1313
from auth.user_identity import User, utc_now
@@ -147,6 +147,21 @@ async def applicant_summary() -> dict[ApplicantStatus, int]:
147147
return await summary_handler.applicant_summary()
148148

149149

150+
@router.get(
151+
"/summary/applications",
152+
response_model=dict[str, object],
153+
dependencies=[Depends(require_manager)],
154+
)
155+
async def applications(
156+
group_by: Literal["school", "role"]
157+
) -> dict[str, dict[date, int]]:
158+
if group_by == "school":
159+
return await summary_handler.applications_by_school()
160+
elif group_by == "role":
161+
return await summary_handler.applications_by_role()
162+
assert_never(group_by)
163+
164+
150165
@router.post("/review")
151166
async def submit_review(
152167
applicant: str = Body(),

apps/api/tests/test_summary_handler.py

+81-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1+
from datetime import date, datetime, timezone
12
from unittest.mock import AsyncMock, patch
23

3-
from admin.summary_handler import applicant_summary
4+
from admin import summary_handler
45

56

67
@patch("services.mongodb_handler.retrieve", autospec=True)
@@ -12,7 +13,8 @@ async def test_applicant_summary(mock_mongodb_handler_retrieve: AsyncMock) -> No
1213
+ [{"status": "WAITLISTED"}, {"status": "WAIVER_SIGNED"}] * 3
1314
)
1415

15-
summary = await applicant_summary()
16+
summary = await summary_handler.applicant_summary()
17+
1618
mock_mongodb_handler_retrieve.assert_awaited_once()
1719
assert dict(summary) == {
1820
"REJECTED": 20,
@@ -21,3 +23,80 @@ async def test_applicant_summary(mock_mongodb_handler_retrieve: AsyncMock) -> No
2123
"WAIVER_SIGNED": 3,
2224
"CONFIRMED": 24,
2325
}
26+
27+
28+
@patch("services.mongodb_handler.retrieve", autospec=True)
29+
async def test_applications_by_school(mock_mongodb_handler_retrieve: AsyncMock) -> None:
30+
"""Daily number of applications are grouped by school."""
31+
mock_mongodb_handler_retrieve.return_value = [
32+
{
33+
"application_data": {
34+
"school": "UC Irvine",
35+
"submission_time": datetime(1965, 10, 4, 20, 2, 4, tzinfo=timezone.utc),
36+
},
37+
},
38+
{
39+
"application_data": {
40+
"school": "UC Irvine",
41+
"submission_time": datetime(
42+
1965, 10, 4, 20, 15, 26, tzinfo=timezone.utc
43+
),
44+
},
45+
},
46+
{
47+
"application_data": {
48+
"school": "Cal State Long Beach",
49+
"submission_time": datetime(
50+
2024, 12, 17, 18, 4, 11, tzinfo=timezone.utc
51+
),
52+
},
53+
},
54+
]
55+
56+
applications = await summary_handler.applications_by_school()
57+
58+
mock_mongodb_handler_retrieve.assert_awaited_once()
59+
assert applications == {
60+
"UC Irvine": {date(1965, 10, 4): 2},
61+
"Cal State Long Beach": {date(2024, 12, 17): 1},
62+
}
63+
64+
65+
@patch("services.mongodb_handler.retrieve", autospec=True)
66+
async def test_applications_by_role(mock_mongodb_handler_retrieve: AsyncMock) -> None:
67+
"""Daily number of applications are grouped by role."""
68+
mock_mongodb_handler_retrieve.return_value = [
69+
{
70+
"roles": ["Applicant", "Hacker"],
71+
"application_data": {
72+
"submission_time": datetime(
73+
2024, 12, 12, 17, 0, 0, tzinfo=timezone.utc
74+
),
75+
},
76+
},
77+
{
78+
"roles": ["Applicant", "Hacker"],
79+
"application_data": {
80+
"submission_time": datetime(
81+
2024, 12, 12, 19, 0, 0, tzinfo=timezone.utc
82+
),
83+
},
84+
},
85+
{
86+
"roles": ["Applicant", "Mentor"],
87+
"application_data": {
88+
"submission_time": datetime(
89+
2024, 12, 14, 18, 0, 0, tzinfo=timezone.utc
90+
),
91+
},
92+
},
93+
]
94+
95+
applications = await summary_handler.applications_by_role()
96+
97+
mock_mongodb_handler_retrieve.assert_awaited_once()
98+
assert applications == {
99+
"Hacker": {date(2024, 12, 12): 2},
100+
"Mentor": {date(2024, 12, 14): 1},
101+
"Volunteer": {},
102+
}

apps/site/package.json

+3-3
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@
99
"lint": "next lint"
1010
},
1111
"dependencies": {
12-
"@cloudscape-design/collection-hooks": "^1.0.34",
13-
"@cloudscape-design/components": "^3.0.475",
14-
"@cloudscape-design/global-styles": "^1.0.20",
12+
"@cloudscape-design/collection-hooks": "^1.0.56",
13+
"@cloudscape-design/components": "^3.0.856",
14+
"@cloudscape-design/global-styles": "^1.0.33",
1515
"@fireworks-js/react": "^2.10.7",
1616
"@portabletext/react": "^3.0.11",
1717
"@radix-ui/react-accordion": "^1.1.2",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
"use client";
2+
3+
import Container from "@cloudscape-design/components/container";
4+
import ContentLayout from "@cloudscape-design/components/content-layout";
5+
import Header from "@cloudscape-design/components/header";
6+
import SpaceBetween from "@cloudscape-design/components/space-between";
7+
8+
import ApplicationsByRoleChart from "./ApplicationsByRoleChart";
9+
import ApplicationsBySchoolChart from "./ApplicationsBySchoolChart";
10+
11+
function ApplicantsSummary() {
12+
return (
13+
<ContentLayout
14+
defaultPadding
15+
header={<Header variant="h1">Applicants</Header>}
16+
>
17+
<SpaceBetween size="l">
18+
<Container
19+
header={
20+
<Header
21+
variant="h2"
22+
description="Daily applications submitted by school"
23+
>
24+
Applications Submitted
25+
</Header>
26+
}
27+
>
28+
<ApplicationsBySchoolChart />
29+
</Container>
30+
<Container
31+
header={
32+
<Header
33+
variant="h2"
34+
description="Cumulative daily applications submitted by role"
35+
>
36+
Cumulative Applications Submitted
37+
</Header>
38+
}
39+
>
40+
<ApplicationsByRoleChart />
41+
</Container>
42+
</SpaceBetween>
43+
</ContentLayout>
44+
);
45+
}
46+
47+
export default ApplicantsSummary;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import AreaChart from "@cloudscape-design/components/area-chart";
2+
import Box from "@cloudscape-design/components/box";
3+
4+
import { ParticipantRole } from "@/lib/userRecord";
5+
6+
import useApplicationsSummary from "./useApplicationsSummary";
7+
8+
const TIME_SPEC = "T00:00:00-08:00";
9+
const START_DAY = new Date("2024-12-10" + TIME_SPEC);
10+
const END_DAY = new Date("2025-01-11" + TIME_SPEC);
11+
12+
const ROLES = [
13+
ParticipantRole.Hacker,
14+
ParticipantRole.Mentor,
15+
ParticipantRole.Volunteer,
16+
] as const;
17+
18+
function ApplicationsByRoleChart() {
19+
const { loading, applications, error } = useApplicationsSummary("role");
20+
21+
const today = new Date();
22+
const end = today < END_DAY ? today : END_DAY;
23+
24+
const cumulativeApplications = Object.fromEntries(
25+
ROLES.map((role) => [
26+
role,
27+
getCumulativeApplications(applications[role] ?? {}, end),
28+
]),
29+
);
30+
31+
return (
32+
<AreaChart
33+
series={ROLES.map((role) => ({
34+
title: role,
35+
type: "area",
36+
data: cumulativeApplications[role].map(([d, count]) => {
37+
return { x: d, y: count };
38+
}),
39+
}))}
40+
xDomain={[START_DAY, end]}
41+
i18nStrings={{
42+
filterLabel: "Filter displayed data",
43+
filterPlaceholder: "Filter data",
44+
filterSelectedAriaLabel: "selected",
45+
xTickFormatter: (e) =>
46+
e.toLocaleDateString("en-US", { month: "short", day: "numeric" }),
47+
}}
48+
ariaLabel="Stacked area chart."
49+
errorText="Error loading data."
50+
statusType={loading ? "loading" : error ? "error" : "finished"}
51+
fitHeight
52+
height={300}
53+
loadingText="Loading chart"
54+
xScaleType="time"
55+
xTitle="Date (Pacific Time)"
56+
yTitle="Cumulative total applications submitted"
57+
empty={
58+
<Box textAlign="center" color="inherit">
59+
<b>No data available</b>
60+
<Box variant="p" color="inherit">
61+
There is no data available
62+
</Box>
63+
</Box>
64+
}
65+
/>
66+
);
67+
}
68+
69+
function getCumulativeApplications(
70+
applications: Record<string, number>,
71+
end: Date,
72+
): [Date, number][] {
73+
const cumulativeApplications: [Date, number][] = [];
74+
75+
let prev = 0;
76+
for (let d = new Date(START_DAY); d <= end; d.setDate(d.getDate() + 1)) {
77+
cumulativeApplications.push([
78+
new Date(d),
79+
// Index as YYYY-MM-DD and accumulate with previous value
80+
prev + (applications[d.toISOString().substring(0, 10)] ?? 0),
81+
]);
82+
prev = cumulativeApplications.at(-1)![1];
83+
}
84+
85+
return cumulativeApplications;
86+
}
87+
88+
export default ApplicationsByRoleChart;

0 commit comments

Comments
 (0)