Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
fastapi
uvicorn
httpx
watchfiles
watchfiles
pytest
55 changes: 55 additions & 0 deletions src/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,42 @@
"schedule": "Mondays, Wednesdays, Fridays, 2:00 PM - 3:00 PM",
"max_participants": 30,
"participants": ["john@mergington.edu", "olivia@mergington.edu"]
},
"Basketball Team": {
"description": "Competitive basketball team for intramural and league play",
"schedule": "Mondays and Wednesdays, 4:00 PM - 5:30 PM",
"max_participants": 15,
"participants": ["alex@mergington.edu"]
},
"Tennis Club": {
"description": "Learn tennis skills and compete in tournaments",
"schedule": "Tuesdays and Thursdays, 4:00 PM - 5:00 PM",
"max_participants": 10,
"participants": ["lucas@mergington.edu", "grace@mergington.edu"]
},
"Drama Club": {
"description": "Perform in theatrical productions and develop acting skills",
"schedule": "Wednesdays, 3:30 PM - 5:00 PM",
"max_participants": 25,
"participants": ["maya@mergington.edu", "james@mergington.edu"]
},
"Art Studio": {
"description": "Explore painting, drawing, and sculpture techniques",
"schedule": "Thursdays, 3:30 PM - 5:00 PM",
"max_participants": 18,
"participants": ["isabella@mergington.edu"]
},
"Debate Team": {
"description": "Develop public speaking and critical thinking skills through debate",
"schedule": "Mondays and Fridays, 3:30 PM - 4:30 PM",
"max_participants": 16,
"participants": ["ethan@mergington.edu", "ava@mergington.edu"]
},
"Science Club": {
"description": "Conduct experiments and explore advanced scientific concepts",
"schedule": "Tuesdays, 3:30 PM - 5:00 PM",
"max_participants": 20,
"participants": ["noah@mergington.edu"]
}
}

Expand All @@ -62,6 +98,25 @@ def signup_for_activity(activity_name: str, email: str):
# Get the specific activity
activity = activities[activity_name]

# Validate student is not already signed up
if email in activity["participants"]:
raise HTTPException(status_code=400, detail="Student already signed up for this activity")

# Add student
activity["participants"].append(email)
return {"message": f"Signed up {email} for {activity_name}"}


@app.delete("/activities/{activity_name}/participants")
def unregister_from_activity(activity_name: str, email: str):
"""Remove a student from an activity"""
if activity_name not in activities:
raise HTTPException(status_code=404, detail="Activity not found")

activity = activities[activity_name]

if email not in activity["participants"]:
raise HTTPException(status_code=404, detail="Participant not found for this activity")

activity["participants"].remove(email)
return {"message": f"Removed {email} from {activity_name}"}
85 changes: 72 additions & 13 deletions src/static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@ document.addEventListener("DOMContentLoaded", () => {
const signupForm = document.getElementById("signup-form");
const messageDiv = document.getElementById("message");

function showMessage(text, type) {
messageDiv.textContent = text;
messageDiv.className = type;
messageDiv.classList.remove("hidden");

setTimeout(() => {
messageDiv.classList.add("hidden");
Comment on lines 6 to +13
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

showMessage() overwrites messageDiv.className and starts a new setTimeout on every call without clearing any prior timeout. This can cause the message to be hidden unexpectedly if multiple actions happen within 5 seconds, and it also makes it hard to preserve any base styling classes on the message element. Consider keeping a single timeout handle (clear it before setting a new one) and setting classes in a way that preserves any baseline class (e.g., add/remove success/error rather than replacing the entire class list).

Suggested change
function showMessage(text, type) {
messageDiv.textContent = text;
messageDiv.className = type;
messageDiv.classList.remove("hidden");
setTimeout(() => {
messageDiv.classList.add("hidden");
let messageTimeoutId = null;
function showMessage(text, type) {
messageDiv.textContent = text;
// Preserve any baseline classes on messageDiv, only toggle status classes.
messageDiv.classList.remove("success", "error");
if (type) {
messageDiv.classList.add(type);
}
messageDiv.classList.remove("hidden");
// Ensure only one hide timeout is active at a time.
if (messageTimeoutId !== null) {
clearTimeout(messageTimeoutId);
}
messageTimeoutId = setTimeout(() => {
messageDiv.classList.add("hidden");
messageTimeoutId = null;

Copilot uses AI. Check for mistakes.
}, 5000);
}

// Function to fetch activities from API
async function fetchActivities() {
try {
Expand All @@ -12,19 +22,46 @@ document.addEventListener("DOMContentLoaded", () => {

// Clear loading message
activitiesList.innerHTML = "";
activitySelect.innerHTML = '<option value="">-- Select an activity --</option>';

// Populate activities list
Object.entries(activities).forEach(([name, details]) => {
const activityCard = document.createElement("div");
activityCard.className = "activity-card";

const spotsLeft = details.max_participants - details.participants.length;
const participantsMarkup = details.participants.length
? details.participants
.map(
(participant) => `
<li class="participant-item">
<span class="participant-email">${participant}</span>
<button
class="participant-delete-button"
type="button"
data-activity="${encodeURIComponent(name)}"
data-email="${encodeURIComponent(participant)}"
aria-label="Unregister ${participant} from ${name}"
>
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M9 3h6l1 2h4v2H4V5h4l1-2zm1 6h2v8h-2V9zm4 0h2v8h-2V9zM7 9h2v8H7V9zm-1 11h12l1-12H5l1 12z" />
</svg>
</button>
</li>
`
)
.join("")
: '<li class="participant-empty">No students registered yet.</li>';

activityCard.innerHTML = `
<h4>${name}</h4>
<p>${details.description}</p>
<p><strong>Schedule:</strong> ${details.schedule}</p>
<p><strong>Availability:</strong> ${spotsLeft} spots left</p>
<div class="participants-section">
<p class="participants-title">Participants</p>
<ul class="participants-list">${participantsMarkup}</ul>
</div>
`;
Comment on lines +33 to 65
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Participant emails (and activity names) are interpolated directly into innerHTML (including into an attribute via aria-label). Since emails come from user input and the backend doesn’t enforce strict email validation/escaping, this enables HTML injection/XSS in the activity list. Build the participants list with DOM APIs (createElement, textContent, setAttribute) or escape the values before injecting them into HTML strings.

Copilot uses AI. Check for mistakes.

activitiesList.appendChild(activityCard);
Expand Down Expand Up @@ -59,25 +96,47 @@ document.addEventListener("DOMContentLoaded", () => {
const result = await response.json();

if (response.ok) {
messageDiv.textContent = result.message;
messageDiv.className = "success";
signupForm.reset();
await fetchActivities();
showMessage(result.message, "success");
} else {
messageDiv.textContent = result.detail || "An error occurred";
messageDiv.className = "error";
showMessage(result.detail || "An error occurred", "error");
}
} catch (error) {
showMessage("Failed to sign up. Please try again.", "error");
console.error("Error signing up:", error);
}
});

activitiesList.addEventListener("click", async (event) => {
const deleteButton = event.target.closest(".participant-delete-button");

if (!deleteButton) {
return;
}

const activity = decodeURIComponent(deleteButton.dataset.activity);
const email = decodeURIComponent(deleteButton.dataset.email);

messageDiv.classList.remove("hidden");
try {
const response = await fetch(
`/activities/${encodeURIComponent(activity)}/participants?email=${encodeURIComponent(email)}`,
{
method: "DELETE",
}
);

// Hide message after 5 seconds
setTimeout(() => {
messageDiv.classList.add("hidden");
}, 5000);
const result = await response.json();

if (response.ok) {
await fetchActivities();
showMessage(result.message, "success");
} else {
showMessage(result.detail || "Unable to unregister participant.", "error");
}
} catch (error) {
messageDiv.textContent = "Failed to sign up. Please try again.";
messageDiv.className = "error";
messageDiv.classList.remove("hidden");
console.error("Error signing up:", error);
showMessage("Failed to unregister participant. Please try again.", "error");
console.error("Error unregistering participant:", error);
}
});

Expand Down
80 changes: 77 additions & 3 deletions src/static/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,10 @@ section h3 {
.activity-card {
margin-bottom: 15px;
padding: 15px;
border: 1px solid #ddd;
border-radius: 5px;
background-color: #f9f9f9;
border: 1px solid #d6def5;
border-radius: 14px;
background: linear-gradient(180deg, #ffffff 0%, #f5f8ff 100%);
box-shadow: 0 10px 24px rgba(26, 35, 126, 0.08);
}

.activity-card h4 {
Expand All @@ -74,6 +75,79 @@ section h3 {
margin-bottom: 8px;
}

.participants-section {
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid #dbe4ff;
}

.participants-title {
margin-bottom: 8px;
font-size: 0.95rem;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #1a237e;
}

.participants-list {
list-style: none;
margin: 0;
padding: 0;
color: #334155;
}

.participant-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
padding: 10px 12px;
border-radius: 10px;
background-color: rgba(219, 228, 255, 0.55);
}

.participant-email {
overflow-wrap: anywhere;
font-weight: 600;
}

.participant-delete-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
flex-shrink: 0;
border-radius: 999px;
border: 1px solid #f3b2b2;
background: linear-gradient(180deg, #fff5f5 0%, #ffe3e3 100%);
color: #b42318;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s, background 0.2s;
}

.participant-delete-button:hover {
transform: translateY(-1px);
box-shadow: 0 6px 14px rgba(180, 35, 24, 0.18);
background: linear-gradient(180deg, #ffe3e3 0%, #ffc9c9 100%);
}

.participant-delete-button svg {
width: 16px;
height: 16px;
fill: currentColor;
}

.participant-empty {
padding: 10px 12px;
border-radius: 10px;
background-color: rgba(219, 228, 255, 0.35);
color: #64748b;
font-style: italic;
}

.form-group {
margin-bottom: 15px;
}
Expand Down
Empty file added tests/__init__.py
Empty file.
Loading
Loading