diff --git a/packages/backend/models/user.js b/packages/backend/models/user.js
index bdf3585..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: "",
@@ -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: {
@@ -152,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 5830ad2..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,6 +133,75 @@
border-color: var(--accent-purple, #9b9ece);
}
+.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;
+}
+
.profileButtons {
margin-top: 34px;
display: flex;
@@ -209,8 +289,6 @@
padding: 0 16px;
}
-
-
.profileVisibilityOptions {
flex-direction: column;
}
@@ -219,6 +297,14 @@
width: 100%;
}
+ .profileInterestAddRow {
+ grid-template-columns: 1fr 48px;
+ }
+
+ .profileInterestAddBtn {
+ min-height: 48px;
+ }
+
.profileButtons {
flex-direction: column;
align-items: stretch;
diff --git a/packages/frontend/src/components/Profile.jsx b/packages/frontend/src/components/Profile.jsx
index 72c8015..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";
@@ -6,28 +6,76 @@ import "./Profile.css";
const AZURE_URL =
"https://goattimer-hgh5bxcub9hrdgha.centralus-01.azurewebsites.net";
+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",
- },
+ { 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);
+ }
+
+ 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 "";
+}
+
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);
const [message, setMessage] = useState("");
const [error, setError] = useState(null);
@@ -43,10 +91,13 @@ function Profile() {
setUser(data.user);
setForm({
age: data.user.age || "",
- interests: data.user.interests || "",
- profileVisibility:
- data.user.profileVisibility ||
- (data.user.isProfilePublic ? "public" : "private"),
+ interests: normalizeInterests(data.user.interests),
+ profileVisibility: data.user.profileVisibility || "private",
+ featureSettings: {
+ groupsEnabled: data.user.featureSettings?.groupsEnabled !== false,
+ leaderboardEnabled:
+ data.user.featureSettings?.leaderboardEnabled !== false,
+ },
});
setLoading(false);
})
@@ -56,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;
@@ -67,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) => {
@@ -79,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));
@@ -176,26 +266,9 @@ function Profile() {
/>
-
-
-
- setForm((current) => ({
- ...current,
- interests: e.target.value,
- }))
- }
- />
-
-
-
- #{user._id ? user._id.slice(-4) : "—"}
-
+
{user._id || "—"}
@@ -224,6 +297,109 @@ function Profile() {
+
+
Feature Controls
+
+ Enable or disable features for your account.
+
+
+
+
+
+
+
+
+
+
+
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}
+
+
+ ))
+ )}
+
+
+
{message && {message}
}
{error && {error}
}
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: [