From 230a9dfc1d3ea80803316b712732ab782e2cfecf Mon Sep 17 00:00:00 2001 From: brevelesgamboa Date: Fri, 29 May 2026 09:24:40 -0700 Subject: [PATCH 1/3] Expanded on interests, ability for users to see interests and add interests. --- packages/backend/models/user.js | 19 ++- packages/frontend/src/components/Profile.css | 91 ++++++++++++ packages/frontend/src/components/Profile.jsx | 147 ++++++++++++++++--- 3 files changed, 237 insertions(+), 20 deletions(-) diff --git a/packages/backend/models/user.js b/packages/backend/models/user.js index bdf3585..daf77b9 100644 --- a/packages/backend/models/user.js +++ b/packages/backend/models/user.js @@ -142,9 +142,22 @@ const userSchema = new mongoose.Schema( }, interests: { - type: String, - default: "", - trim: true, + type: [ + { + type: String, + trim: true, + maxlength: [24, "Each interest must be 24 characters or less"], + match: [ + /^[a-zA-Z0-9 '&-]+$/, + "Interests can only contain letters, numbers, spaces, apostrophes, ampersands, and hyphens", + ], + }, + ], + default: [], + validate: { + validator: (interests) => interests.length <= 10, + message: "You can only add up to 10 interests", + }, }, profileVisibility: { diff --git a/packages/frontend/src/components/Profile.css b/packages/frontend/src/components/Profile.css index 5830ad2..0fa611f 100644 --- a/packages/frontend/src/components/Profile.css +++ b/packages/frontend/src/components/Profile.css @@ -171,6 +171,89 @@ line-height: 1.1; } +.profileInterestsSection { + margin-top: 28px; + padding-top: 24px; + border-top: 1.5px solid rgba(0, 8, 7, 0.12); +} + +.profileInterestsTitle { + margin: 0 0 14px; + text-align: center; + font-family: "EB Garamond", serif; + font-size: clamp(1.3rem, 1.8vw, 1.8rem); + color: var(--brand-black); +} + +.profileInterestAddRow { + display: grid; + grid-template-columns: 1fr 54px; + gap: 10px; + align-items: center; +} + +.profileInterestInput { + min-width: 0; +} + +.profileInterestAddBtn { + min-height: 54px; + border: none; + border-radius: 999px; + background: var(--accent-purple, #9b9ece); + color: var(--brand-black); + font-family: "EB Garamond", serif; + font-size: 2rem; + font-weight: 700; + cursor: pointer; + transition: + transform 0.18s ease, + opacity 0.18s ease; +} + +.profileInterestAddBtn:hover { + transform: translateY(-1px); + opacity: 0.88; +} + +.profileInterestList { + display: flex; + justify-content: center; + flex-wrap: wrap; + gap: 10px; + margin-top: 14px; +} + +.profileInterestEmpty { + font-family: "EB Garamond", serif; + color: #858585; + margin: 0; +} + +.profileInterestTag { + display: inline-flex; + align-items: center; + gap: 8px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.42); + border: 1.5px solid rgba(0, 8, 7, 0.16); + padding: 8px 12px 8px 16px; + font-family: "EB Garamond", serif; + font-size: 1.05rem; + color: var(--brand-black); +} + +.profileInterestRemoveBtn { + border: none; + background: transparent; + color: #7f1d1d; + font-family: "EB Garamond", serif; + font-size: 1.2rem; + font-weight: 700; + cursor: pointer; + line-height: 1; +} + @media (max-width: 900px) { .profileCard { padding: 26px 22px 24px; @@ -236,4 +319,12 @@ .profileDelete { line-height: 1.2; } + + .profileInterestAddRow { + grid-template-columns: 1fr 48px; +} + +.profileInterestAddBtn { + min-height: 48px; +} } \ No newline at end of file diff --git a/packages/frontend/src/components/Profile.jsx b/packages/frontend/src/components/Profile.jsx index 72c8015..de0ef92 100644 --- a/packages/frontend/src/components/Profile.jsx +++ b/packages/frontend/src/components/Profile.jsx @@ -6,6 +6,58 @@ import "./Profile.css"; const AZURE_URL = "https://goattimer-hgh5bxcub9hrdgha.centralus-01.azurewebsites.net"; +const MAX_INTERESTS = 10; +const MAX_INTEREST_LENGTH = 24; + +function normalizeInterests(interests) { + if (Array.isArray(interests)) { + return interests.filter(Boolean); + } + + if (typeof interests === "string" && interests.trim()) { + return interests + .split(",") + .map((interest) => interest.trim()) + .filter(Boolean); + } + + return []; +} + +function cleanInterest(value) { + return value.trim().replace(/\s+/g, " "); +} + +function getInterestError(value, currentInterests) { + const cleaned = cleanInterest(value); + + if (!cleaned) { + return "Interest cannot be empty"; + } + + if (cleaned.length > MAX_INTEREST_LENGTH) { + return `Interest must be ${MAX_INTEREST_LENGTH} characters or less`; + } + + if (!/^[a-zA-Z0-9 '&-]+$/.test(cleaned)) { + return "Only letters, numbers, spaces, apostrophes, ampersands, and hyphens are allowed"; + } + + if (currentInterests.length >= MAX_INTERESTS) { + return `You can only add up to ${MAX_INTERESTS} interests`; + } + + if ( + currentInterests.some( + (interest) => interest.toLowerCase() === cleaned.toLowerCase(), + ) + ) { + return "That interest is already added"; + } + + return ""; +} + const PROFILE_VISIBILITY_OPTIONS = [ { value: "public", @@ -28,6 +80,7 @@ function Profile() { interests: "", profileVisibility: "private", }); + const [interestInput, setInterestInput] = useState(""); const [loading, setLoading] = useState(true); const [message, setMessage] = useState(""); const [error, setError] = useState(null); @@ -43,7 +96,7 @@ function Profile() { setUser(data.user); setForm({ age: data.user.age || "", - interests: data.user.interests || "", + interests: normalizeInterests(data.user.interests), profileVisibility: data.user.profileVisibility || (data.user.isProfilePublic ? "public" : "private"), @@ -97,6 +150,34 @@ function Profile() { }); } + function handleAddInterest() { + const cleaned = cleanInterest(interestInput); + const validationError = getInterestError(cleaned, form.interests); + + if (validationError) { + setError(validationError); + setMessage(""); + return; + } + + setForm((current) => ({ + ...current, + interests: [...current.interests, cleaned], + })); + + setInterestInput(""); + setError(""); + } + + function handleRemoveInterest(interestToRemove) { + setForm((current) => ({ + ...current, + interests: current.interests.filter( + (interest) => interest !== interestToRemove, + ), + })); + } + function handleDelete() { if (!user) return; @@ -177,24 +258,56 @@ function Profile() {
- - - setForm((current) => ({ - ...current, - interests: e.target.value, - })) - } - /> + +
{user._id || "—"}
-
- -
- #{user._id ? user._id.slice(-4) : "—"} +
+

Interests

+ +
+ setInterestInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleAddInterest(); + } + }} + /> + + +
+ +
+ {form.interests.length === 0 ? ( +

No interests added yet.

+ ) : ( + form.interests.map((interest) => ( + + {interest} + + + )) + )}
From 2f2cae35cf1bb6c01a6f044b8f02227589b499dc Mon Sep 17 00:00:00 2001 From: brevelesgamboa Date: Fri, 29 May 2026 11:04:25 -0700 Subject: [PATCH 2/3] Implemented profile visibility and the option to participate in leaderboard or groups. --- packages/backend/models/user.js | 14 +- packages/backend/routes/leaderboard.js | 17 +- packages/backend/routes/users.js | 74 +++--- .../frontend/src/components/CreateGroup.jsx | 9 +- .../frontend/src/components/Dashboard.jsx | 12 +- .../frontend/src/components/GroupDetail.jsx | 9 +- .../frontend/src/components/Leaderboard.jsx | 17 +- packages/frontend/src/components/Profile.css | 145 ++++++------ packages/frontend/src/components/Profile.jsx | 217 +++++++++++------- 9 files changed, 319 insertions(+), 195 deletions(-) diff --git a/packages/backend/models/user.js b/packages/backend/models/user.js index daf77b9..bb6a2ed 100644 --- a/packages/backend/models/user.js +++ b/packages/backend/models/user.js @@ -19,6 +19,7 @@ const dayNames = [ "saturday", "sunday", ]; + //user's weekly availability, including days, start time, end time, and timezone const scheduleSchema = new mongoose.Schema( { @@ -95,7 +96,6 @@ const userSchema = new mongoose.Schema( default: null, }, - // From the diagram firstName: { type: String, default: "", @@ -165,6 +165,18 @@ const userSchema = new mongoose.Schema( enum: ["public", "groups", "private"], default: "private", }, + + featureSettings: { + groupsEnabled: { + type: Boolean, + default: true, + }, + + leaderboardEnabled: { + type: Boolean, + default: true, + }, + }, }, { timestamps: true, diff --git a/packages/backend/routes/leaderboard.js b/packages/backend/routes/leaderboard.js index 1962124..bdd38cc 100644 --- a/packages/backend/routes/leaderboard.js +++ b/packages/backend/routes/leaderboard.js @@ -7,7 +7,7 @@ import Group from "../models/group.js"; const DEFAULT_LIMIT = 5; const MAX_LIMIT = 50; -//parse and cap leaderboard limit +//parses and caps leaderboard limit, falling back to default if invalid export function getLeaderboardLimit(rawLimit) { const parsedLimit = Number.parseInt(rawLimit, 10); @@ -17,10 +17,12 @@ export function getLeaderboardLimit(rawLimit) { return Math.min(parsedLimit, MAX_LIMIT); } + //gets user id as a string from either a user object or raw id function getUserId(user) { return user?._id?.toString?.() || user?.toString?.() || String(user || ""); } + //converts users into ranked leaderboard entries and marks the current user export function mapUsersToLeaderboardEntries(users, currentUserId) { return users.map((user, index) => { @@ -50,16 +52,15 @@ export async function leaderboardHandler(req, res) { let users; if (scope === "group" && groupId) { - // Validate groupId format if (!mongoose.Types.ObjectId.isValid(groupId)) { return res.status(400).json({ error: "Invalid group ID" }); } - // Fetch the group and get its users const group = await Group.findById(groupId) .populate({ path: "users", - select: "name email totalHours", + match: { profileVisibility: { $in: ["public", "groups"] } }, + select: "name email totalHours profileVisibility", options: { sort: { totalHours: -1, name: 1 }, limit }, }) .lean(); @@ -70,11 +71,13 @@ export async function leaderboardHandler(req, res) { users = group.users || []; } else { - // Global leaderboard - users = await User.find() + //global leaderboard only shows public users + users = await User.find({ + profileVisibility: "public", + }) .sort({ totalHours: -1, name: 1 }) .limit(limit) - .select("name email totalHours") + .select("name email totalHours profileVisibility") .lean(); } diff --git a/packages/backend/routes/users.js b/packages/backend/routes/users.js index 77f612d..649caa8 100644 --- a/packages/backend/routes/users.js +++ b/packages/backend/routes/users.js @@ -17,12 +17,12 @@ const allowedUserUpdateFields = [ "schedule", "commitmentLevel", "age", + "interests", + "profileVisibility", + "featureSettings", "totalHours", "weeklyHours", "todayHours", - "interests", - "isProfilePublic", - "profileVisibility", ]; const commitmentGoals = { @@ -170,25 +170,42 @@ router.get("/:userParam/groups", async (req, res) => { } }); -//get public profile for one user +//get profile viewer data for one user router.get("/:userParam/public", requireAuth, async (req, res) => { try { - const user = await User.findOne(getUserFilter(req.params.userParam)).select( - "name age interests isProfilePublic totalHours", - ); + const profileUser = await User.findOne( + getUserFilter(req.params.userParam), + ).select("name age interests profileVisibility totalHours groups"); - if (!user) { + if (!profileUser) { return res.status(404).json({ error: "User not found" }); } + const viewer = await User.findById(req.auth.userId).select("groups"); + + const visibility = profileUser.profileVisibility || "private"; + const isSelf = profileUser._id.toString() === req.auth.userId; + + const viewerGroups = new Set((viewer?.groups || []).map(String)); + const profileUserGroups = (profileUser.groups || []).map(String); + + const sharesGroup = profileUserGroups.some((groupId) => + viewerGroups.has(groupId), + ); + + const canViewDetails = + isSelf || + visibility === "public" || + (visibility === "groups" && sharesGroup); + return res.json({ user: { - _id: user._id, - name: user.name, - totalHours: user.totalHours || 0, - isProfilePublic: user.isProfilePublic, - age: user.isProfilePublic ? user.age || null : null, - interests: user.isProfilePublic ? user.interests || "" : "", + _id: profileUser._id, + name: profileUser.name, + totalHours: profileUser.totalHours || 0, + profileVisibility: visibility, + age: canViewDetails ? profileUser.age || null : null, + interests: canViewDetails ? profileUser.interests || [] : [], }, }); } catch (error) { @@ -214,8 +231,19 @@ router.get("/:userParam", async (req, res) => { }); //update user by id or email -router.put("/:userParam", async (req, res) => { +router.put("/:userParam", requireAuth, async (req, res) => { try { + const filter = getUserFilter(req.params.userParam); + const targetUser = await User.findOne(filter).select("_id"); + + if (!targetUser) { + return res.status(404).json({ error: "User not found" }); + } + + if (targetUser._id.toString() !== req.auth.userId) { + return res.status(403).json({ error: "You can only update yourself" }); + } + const updates = getAllowedUpdates(req.body, allowedUserUpdateFields); if (Object.keys(updates).length === 0) { @@ -224,18 +252,10 @@ router.put("/:userParam", async (req, res) => { }); } - const user = await User.findOneAndUpdate( - getUserFilter(req.params.userParam), - updates, - { - new: true, - runValidators: true, - }, - ).populate("groups"); - - if (!user) { - return res.status(404).json({ error: "User not found" }); - } + const user = await User.findByIdAndUpdate(targetUser._id, updates, { + new: true, + runValidators: true, + }).populate("groups"); return res.json(user); } catch (error) { diff --git a/packages/frontend/src/components/CreateGroup.jsx b/packages/frontend/src/components/CreateGroup.jsx index edc59f5..000d514 100644 --- a/packages/frontend/src/components/CreateGroup.jsx +++ b/packages/frontend/src/components/CreateGroup.jsx @@ -42,7 +42,14 @@ function CreateGroup() { if (!res.ok) throw new Error("Not logged in"); return res.json(); }) - .then((data) => setUser(data.user)) + .then((data) => { + if (data.user?.featureSettings?.groupsEnabled === false) { + navigate("/dashboard"); + return; + } + + setUser(data.user); + }) .catch(() => navigate("/login")); }, [navigate]); diff --git a/packages/frontend/src/components/Dashboard.jsx b/packages/frontend/src/components/Dashboard.jsx index e724117..3808410 100644 --- a/packages/frontend/src/components/Dashboard.jsx +++ b/packages/frontend/src/components/Dashboard.jsx @@ -91,9 +91,11 @@ const Dashboard = () => {

{dateString}

- - Groups - + {user?.featureSettings?.groupsEnabled !== false && ( + + Groups + + )} Profile @@ -136,7 +138,9 @@ const Dashboard = () => {
diff --git a/packages/frontend/src/components/GroupDetail.jsx b/packages/frontend/src/components/GroupDetail.jsx index f7ffccb..4a43f7a 100644 --- a/packages/frontend/src/components/GroupDetail.jsx +++ b/packages/frontend/src/components/GroupDetail.jsx @@ -28,7 +28,14 @@ function GroupDetail() { if (!res.ok) throw new Error("Not logged in"); return res.json(); }) - .then((data) => setUser(data.user)) + .then((data) => { + if (data.user?.featureSettings?.groupsEnabled === false) { + navigate("/dashboard"); + return; + } + + setUser(data.user); + }) .catch(() => navigate("/login")); }, [navigate]); diff --git a/packages/frontend/src/components/Leaderboard.jsx b/packages/frontend/src/components/Leaderboard.jsx index 28126bc..1b8e623 100644 --- a/packages/frontend/src/components/Leaderboard.jsx +++ b/packages/frontend/src/components/Leaderboard.jsx @@ -1,4 +1,4 @@ -//loads and displays the global or group leaderboard with the current user's hours highlightedz +//loads and displays the global or group leaderboard with the current user's hours highlighted import { useEffect, useState } from "react"; const AZURE_URL = @@ -24,7 +24,14 @@ function Leaderboard({ const [status, setStatus] = useState("loading"); const [error, setError] = useState(""); + const leaderboardDisabled = + user?.featureSettings?.leaderboardEnabled === false; + useEffect(() => { + if (leaderboardDisabled) { + return; + } + const controller = new AbortController(); async function loadLeaderboard() { @@ -69,7 +76,11 @@ function Leaderboard({ }); return () => controller.abort(); - }, [limit, scope, groupId]); + }, [limit, scope, groupId, leaderboardDisabled]); + + if (leaderboardDisabled) { + return null; + } const hasEntries = entries.length > 0; const emptyMessage = "No study time yet. Start a session to claim a spot."; @@ -117,12 +128,14 @@ function Leaderboard({ > {entry.rank} + {entry.name} {entry.isCurrentUser && ( You )} + {formatHours(entry.totalHours)} diff --git a/packages/frontend/src/components/Profile.css b/packages/frontend/src/components/Profile.css index 0fa611f..3142036 100644 --- a/packages/frontend/src/components/Profile.css +++ b/packages/frontend/src/components/Profile.css @@ -75,13 +75,16 @@ .profileInput[type="number"] { appearance: textfield; } -.profileVisibilitySection { + +.profileVisibilitySection, +.profileInterestsSection { margin-top: 28px; padding-top: 24px; border-top: 1.5px solid rgba(0, 8, 7, 0.12); } -.profileVisibilityTitle { +.profileVisibilityTitle, +.profileInterestsTitle { margin: 0 0 14px; text-align: center; font-family: "EB Garamond", serif; @@ -89,6 +92,14 @@ color: var(--brand-black); } +.profileVisibilityHelp { + margin: 0 0 16px; + text-align: center; + font-family: "EB Garamond", serif; + color: #858585; + font-size: 1.05rem; +} + .profileVisibilityOptions { display: flex; justify-content: center; @@ -122,69 +133,6 @@ border-color: var(--accent-purple, #9b9ece); } -.profileButtons { - margin-top: 34px; - display: flex; - justify-content: flex-end; - gap: 18px; -} - -.profileButton { - border: none; - border-radius: 999px; - color: var(--white); - font-family: "EB Garamond", serif; - font-weight: 700; - cursor: pointer; - transition: - transform 0.18s ease, - opacity 0.18s ease; -} - -.profileButton:hover { - transform: translateY(-1px); - opacity: 0.92; -} - -.profileSave { - min-width: 150px; - min-height: 68px; - padding: 0 28px; - background: #9b1f1f; - font-size: 2rem; -} - -.profileLogout { - min-width: 150px; - min-height: 68px; - padding: 0 28px; - background: #7f1d1d; - font-size: 2rem; -} - -.profileDelete { - min-width: 130px; - min-height: 68px; - padding: 10px 18px; - background: #5f0b0b; - font-size: 1rem; - line-height: 1.1; -} - -.profileInterestsSection { - margin-top: 28px; - padding-top: 24px; - border-top: 1.5px solid rgba(0, 8, 7, 0.12); -} - -.profileInterestsTitle { - margin: 0 0 14px; - text-align: center; - font-family: "EB Garamond", serif; - font-size: clamp(1.3rem, 1.8vw, 1.8rem); - color: var(--brand-black); -} - .profileInterestAddRow { display: grid; grid-template-columns: 1fr 54px; @@ -254,6 +202,55 @@ line-height: 1; } +.profileButtons { + margin-top: 34px; + display: flex; + justify-content: flex-end; + gap: 18px; +} + +.profileButton { + border: none; + border-radius: 999px; + color: var(--white); + font-family: "EB Garamond", serif; + font-weight: 700; + cursor: pointer; + transition: + transform 0.18s ease, + opacity 0.18s ease; +} + +.profileButton:hover { + transform: translateY(-1px); + opacity: 0.92; +} + +.profileSave { + min-width: 150px; + min-height: 68px; + padding: 0 28px; + background: #9b1f1f; + font-size: 2rem; +} + +.profileLogout { + min-width: 150px; + min-height: 68px; + padding: 0 28px; + background: #7f1d1d; + font-size: 2rem; +} + +.profileDelete { + min-width: 130px; + min-height: 68px; + padding: 10px 18px; + background: #5f0b0b; + font-size: 1rem; + line-height: 1.1; +} + @media (max-width: 900px) { .profileCard { padding: 26px 22px 24px; @@ -292,8 +289,6 @@ padding: 0 16px; } - - .profileVisibilityOptions { flex-direction: column; } @@ -302,6 +297,14 @@ width: 100%; } + .profileInterestAddRow { + grid-template-columns: 1fr 48px; + } + + .profileInterestAddBtn { + min-height: 48px; + } + .profileButtons { flex-direction: column; align-items: stretch; @@ -319,12 +322,4 @@ .profileDelete { line-height: 1.2; } - - .profileInterestAddRow { - grid-template-columns: 1fr 48px; -} - -.profileInterestAddBtn { - min-height: 48px; -} } \ No newline at end of file diff --git a/packages/frontend/src/components/Profile.jsx b/packages/frontend/src/components/Profile.jsx index de0ef92..fb0fd90 100644 --- a/packages/frontend/src/components/Profile.jsx +++ b/packages/frontend/src/components/Profile.jsx @@ -1,4 +1,4 @@ -// Displays the user profile, logout, deletion, and profile viewability settings. +// Displays the user profile, logout, deletion, profile viewability, interests, and feature controls. import { useState, useEffect } from "react"; import { useNavigate } from "react-router"; import "./Profile.css"; @@ -9,6 +9,12 @@ const AZURE_URL = const MAX_INTERESTS = 10; const MAX_INTEREST_LENGTH = 24; +const PROFILE_VISIBILITY_OPTIONS = [ + { value: "public", label: "Public" }, + { value: "groups", label: "People in my groups" }, + { value: "private", label: "Private" }, +]; + function normalizeInterests(interests) { if (Array.isArray(interests)) { return interests.filter(Boolean); @@ -58,27 +64,16 @@ function getInterestError(value, currentInterests) { return ""; } -const PROFILE_VISIBILITY_OPTIONS = [ - { - value: "public", - label: "Public", - }, - { - value: "groups", - label: "People in my groups", - }, - { - value: "private", - label: "Private", - }, -]; - function Profile() { const [user, setUser] = useState(null); const [form, setForm] = useState({ age: "", - interests: "", + interests: [], profileVisibility: "private", + featureSettings: { + groupsEnabled: true, + leaderboardEnabled: true, + }, }); const [interestInput, setInterestInput] = useState(""); const [loading, setLoading] = useState(true); @@ -97,9 +92,12 @@ function Profile() { setForm({ age: data.user.age || "", interests: normalizeInterests(data.user.interests), - profileVisibility: - data.user.profileVisibility || - (data.user.isProfilePublic ? "public" : "private"), + profileVisibility: data.user.profileVisibility || "private", + featureSettings: { + groupsEnabled: data.user.featureSettings?.groupsEnabled !== false, + leaderboardEnabled: + data.user.featureSettings?.leaderboardEnabled !== false, + }, }); setLoading(false); }) @@ -109,6 +107,34 @@ function Profile() { }); }, []); + function handleAddInterest() { + const cleaned = cleanInterest(interestInput); + const validationError = getInterestError(cleaned, form.interests); + + if (validationError) { + setError(validationError); + setMessage(""); + return; + } + + setForm((current) => ({ + ...current, + interests: [...current.interests, cleaned], + })); + + setInterestInput(""); + setError(""); + } + + function handleRemoveInterest(interestToRemove) { + setForm((current) => ({ + ...current, + interests: current.interests.filter( + (interest) => interest !== interestToRemove, + ), + })); + } + function handleSaveProfile() { if (!user) return; @@ -120,10 +146,10 @@ function Profile() { headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ - age: form.age ? Number(form.age) : undefined, + age: form.age === "" ? null : Number(form.age), interests: form.interests, profileVisibility: form.profileVisibility, - isProfilePublic: form.profileVisibility === "public", + featureSettings: form.featureSettings, }), }) .then((res) => { @@ -132,6 +158,17 @@ function Profile() { }) .then((data) => { setUser(data); + setForm((current) => ({ + ...current, + age: data.age || "", + interests: normalizeInterests(data.interests), + profileVisibility: data.profileVisibility || "private", + featureSettings: { + groupsEnabled: data.featureSettings?.groupsEnabled !== false, + leaderboardEnabled: + data.featureSettings?.leaderboardEnabled !== false, + }, + })); setMessage("Profile updated."); }) .catch((err) => setError(err.message)); @@ -150,34 +187,6 @@ function Profile() { }); } - function handleAddInterest() { - const cleaned = cleanInterest(interestInput); - const validationError = getInterestError(cleaned, form.interests); - - if (validationError) { - setError(validationError); - setMessage(""); - return; - } - - setForm((current) => ({ - ...current, - interests: [...current.interests, cleaned], - })); - - setInterestInput(""); - setError(""); - } - - function handleRemoveInterest(interestToRemove) { - setForm((current) => ({ - ...current, - interests: current.interests.filter( - (interest) => interest !== interestToRemove, - ), - })); - } - function handleDelete() { if (!user) return; @@ -259,7 +268,87 @@ function Profile() {
-
{user._id || "—"}
+
{user._id || "—"}
+
+ +
+

Profile Viewability

+ +
+ {PROFILE_VISIBILITY_OPTIONS.map((option) => ( + + ))} +
+
+ +
+

Feature Controls

+

+ Enable or disable features for your account. +

+ +
+ + + +
@@ -311,32 +400,6 @@ function Profile() {
-
-

Profile Viewability

- -
- {PROFILE_VISIBILITY_OPTIONS.map((option) => ( - - ))} -
-
- {message &&

{message}

} {error &&

{error}

} From c253b5024aefdb88bd44d1acf85b7005ebf6e98c Mon Sep 17 00:00:00 2001 From: brevelesgamboa Date: Sat, 30 May 2026 17:13:23 -0700 Subject: [PATCH 3/3] Fixed test error. The test was expecting the profile to not include the parameter profilevisibility which I added. I added the parameter and the test passes now --- packages/tests/backend/leaderboard.test.js | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/tests/backend/leaderboard.test.js b/packages/tests/backend/leaderboard.test.js index 5f10d99..17b3815 100644 --- a/packages/tests/backend/leaderboard.test.js +++ b/packages/tests/backend/leaderboard.test.js @@ -60,10 +60,14 @@ describe("leaderboard route handler", () => { res, ); - expect(User.find).toHaveBeenCalledOnce(); - expect(userQuery.sort).toHaveBeenCalledWith({ totalHours: -1, name: 1 }); - expect(userQuery.limit).toHaveBeenCalledWith(2); - expect(userQuery.select).toHaveBeenCalledWith("name email totalHours"); + expect(User.find).toHaveBeenCalledWith({ + profileVisibility: "public", + }); + expect(userQuery.sort).toHaveBeenCalledWith({ totalHours: -1, name: 1 }); + expect(userQuery.limit).toHaveBeenCalledWith(2); + expect(userQuery.select).toHaveBeenCalledWith( + "name email totalHours profileVisibility", + ); expect(res.json).toHaveBeenCalledWith({ scope: "global", entries: [