diff --git a/chessServer/src/api/completeActivity.js b/chessServer/src/api/completeActivity.js new file mode 100644 index 00000000..67bf240f --- /dev/null +++ b/chessServer/src/api/completeActivity.js @@ -0,0 +1,21 @@ +/** + * Sends a PUT request to the middleware to mark an activity as complete. + * @param {string} username - The student's username + * @param {string} credentials - The student's auth token (JWT) + * @param {string} activityName - The activity identifier (e.g. 'captureQueen') + * @returns {Response} The fetch response + */ +const completeActivity = async (username, credentials, activityName) => { + const url = `${process.env.MIDDLEWARE_URL}/activities/${username}/activity`; + const response = await fetch(url, { + method: "PUT", + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${credentials}`, + }, + body: JSON.stringify({ activityName }), + }); + return response; +}; + +module.exports = { completeActivity }; diff --git a/chessServer/src/managers/EventHandlers.js b/chessServer/src/managers/EventHandlers.js index e2d1f19e..4aff87be 100644 --- a/chessServer/src/managers/EventHandlers.js +++ b/chessServer/src/managers/EventHandlers.js @@ -1,4 +1,5 @@ const GameManager = require("./GameManager"); +const { completeActivity } = require("../api/completeActivity"); const gameManager = new GameManager(); @@ -45,8 +46,7 @@ const registerSocketHandlers = (socket, io) => { socket.on("newPuzzle", (msg) => { try { const parsed = JSON.parse(msg); - console.log('data',parsed, msg); - // create the new puzzle + console.log('data', parsed, msg); gameManager.createOrJoinPuzzle({ student: parsed.student, mentor: parsed.mentor, @@ -54,6 +54,17 @@ const registerSocketHandlers = (socket, io) => { socketId: socket.id, credentials: parsed.credentials, }, io); + + // Student joining a puzzle session with their mentor counts as attending a session + if (parsed.role === "student" && parsed.credentials && parsed.student) { + completeActivity(parsed.student, parsed.credentials, "attendSession") + .then(r => { + if (r.status === 200) { + socket.emit("completeActivity"); + } + }) + .catch(e => console.log("attendSession error:", e)); + } } catch (err) { socket.emit("gameerror", err.message); @@ -72,38 +83,33 @@ const registerSocketHandlers = (socket, io) => { const state = res.result; gameManager.broadcastBoardState(res.result, io); console.log('Move: ', res); - if(!computerMove && credentials) { - const activityEvents = res.activityEvents; + // Gap 5: guard username too — it can be null on early moves before startRecording resolves + if (!computerMove && credentials && username) { + const activityEvents = res.activityEvents; if (activityEvents && activityEvents.length > 0) { - const studentId = state.studentId; + const studentId = state.studentId; const payload = { - activities: activityEvents, + activities: activityEvents, lastMove: { from, to, san: state.move?.san } }; console.log('Payload', payload); + // Gap 1: resolve the student socket so we always notify the student, not the mover const studentSocket = io.sockets.sockets.get(studentId); - //console.log('student socket', studentSocket); if (studentSocket) { try { - console.log('route:', `${process.env.MIDDLEWARE_URL}/activities/${username}/activity`); - const response = await fetch(`${process.env.MIDDLEWARE_URL}/activities/${username}/activity`, { - method: "PUT", - headers: { - 'Content-Type': 'application/json', - 'Authentication' : `Bearer ${credentials}`, - }, - body: JSON.stringify({ - activityName: payload.activities[0].name, - }) - }); - console.log('response',response); - socket.emit("completeActivity"); + for (const event of payload.activities) { + const response = await completeActivity(username, credentials, event.name); + console.log('completeActivity response', response.status); + // Gap 2: only notify on confirmed success; Gap 1: emit to studentSocket + if (response.status === 200) { + studentSocket.emit("completeActivity"); + } + } } catch (e) { - console.log('Error: ', e); + console.log('Error: ', e); } } } - } /* diff --git a/chessServer/src/managers/GameManager.js b/chessServer/src/managers/GameManager.js index c926ba1b..7720a49c 100644 --- a/chessServer/src/managers/GameManager.js +++ b/chessServer/src/managers/GameManager.js @@ -219,6 +219,15 @@ class GameManager { at: Date.now() }); } + + // Game over (checkmate or stalemate) — credit playMatch + if (board.isGameOver()) { + activityEvents.push({ + name: "playMatch", + meta: { san: moveResult.san, result: board.isCheckmate() ? "checkmate" : "draw" }, + at: Date.now() + }); + } //console.log(activityEvents); //console.log('student info',game.student); return { diff --git a/middlewareNode/package.json b/middlewareNode/package.json index 236c407e..774327a0 100644 --- a/middlewareNode/package.json +++ b/middlewareNode/package.json @@ -19,7 +19,8 @@ "passport": "^0.7.0", "passport-jwt": "^4.0.0", "request-ip": "^3.3.0", - "uuid": "^8.3.2" + "uuid": "^8.3.2", + "node-schedule": "^2.1.1" }, "scripts": { "start": "nodemon src/server.js" @@ -29,7 +30,5 @@ "volta": { "node": "18.20.8" }, - "devDependencies": { - "node-schedule": "^2.1.1" - } + "devDependencies": {} } diff --git a/middlewareNode/src/models/defaultLessons.js b/middlewareNode/src/models/defaultLessons.js new file mode 100644 index 00000000..cea0fe22 --- /dev/null +++ b/middlewareNode/src/models/defaultLessons.js @@ -0,0 +1,40 @@ +/** + * Default lesson progress entries for newly registered users. + * Matches the canonical lesson list used across the platform. + * All lessons start at lessonNumber: 0 (no progress). + * + * Used as the Mongoose default for users.lessonsCompleted. + * Must stay in sync with the lesson names in the newLessons collection. + */ +module.exports = [ + { piece: 'Piece Checkmate 1 Basic checkmates', lessonNumber: 0 }, + { piece: 'Checkmate Pattern 1 Recognize the patterns', lessonNumber: 0 }, + { piece: 'Checkmate Pattern 2 Recognize the patterns', lessonNumber: 0 }, + { piece: 'Checkmate Pattern 3 Recognize the patterns', lessonNumber: 0 }, + { piece: 'Checkmate Pattern 4 Recognize the patterns', lessonNumber: 0 }, + { piece: 'Piece checkmates 2 Challenging checkmates', lessonNumber: 0 }, + { piece: 'Knight and Bishop Mate interactive lesson', lessonNumber: 0 }, + { piece: 'The Pin Pin it to win it', lessonNumber: 0 }, + { piece: 'The Skewer Yum - Skewers!', lessonNumber: 0 }, + { piece: 'The Fork Use the fork, Luke', lessonNumber: 0 }, + { piece: 'Discovered Attacks Including discovered checks', lessonNumber: 0 }, + { piece: 'Double Check A very powerfull tactic', lessonNumber: 0 }, + { piece: 'Overloaded Pieces They have too much work', lessonNumber: 0 }, + { piece: 'Zwischenzug In-between moves', lessonNumber: 0 }, + { piece: 'X-Ray Attacking through an enemy piece', lessonNumber: 0 }, + { piece: 'Zugzwang Being forced to move', lessonNumber: 0 }, + { piece: 'Interference Interpose a piece to great effect', lessonNumber: 0 }, + { piece: 'Greek Gift Study the greek gift scrifice', lessonNumber: 0 }, + { piece: 'Deflection Distracting a defender', lessonNumber: 0 }, + { piece: 'Attraction Lure a piece to bad square', lessonNumber: 0 }, + { piece: 'Underpromotion Promote - but not to a queen!', lessonNumber: 0 }, + { piece: 'Desperado A piece is lost, but it can still help',lessonNumber: 0 }, + { piece: 'Counter Check Respond to a check with a check', lessonNumber: 0 }, + { piece: 'Undermining Remove the defending piece', lessonNumber: 0 }, + { piece: 'Clearance Get out of the way!', lessonNumber: 0 }, + { piece: 'Key Squares Reach the key square', lessonNumber: 0 }, + { piece: 'Opposition take the opposition', lessonNumber: 0 }, + { piece: '7th-Rank Rook Pawn Versus a Queen', lessonNumber: 0 }, + { piece: '7th-Rank Rook Pawn And Passive Rook vs Rook', lessonNumber: 0 }, + { piece: 'Basic Rook Endgames Lucena and Philidor', lessonNumber: 0 }, +]; diff --git a/middlewareNode/src/routes/activities.js b/middlewareNode/src/routes/activities.js index 9310a029..c89d0699 100644 --- a/middlewareNode/src/routes/activities.js +++ b/middlewareNode/src/routes/activities.js @@ -12,6 +12,7 @@ const config = require("config"); const express = require('express'); +const passport = require("passport"); const router = express.Router({mergeParams: true}); const { MongoClient, ObjectId } = require('mongodb'); require('dotenv').config(); @@ -65,6 +66,11 @@ router.get("/:username", async (req, res) => { const userActivities = await activities.findOne( { userId }, { projection: {activities: 1, _id: 0}} ); + // Return a safe empty structure if no activities document exists yet + if (!userActivities) { + return res.status(200).json({ activities: { activities: [] } }); + } + res.setHeader('Cache-Control', 'no-store'); return res.status(200).json({activities: userActivities}); } catch (err) { @@ -93,28 +99,45 @@ router.get("/:username/dates", async (req, res) => { }); -router.put("/:username/activity", async (req, res) => { +router.put("/:username/activity", passport.authenticate("jwt", { session: false }), async (req, res) => { try { const db = await getDb(); - const { username } = req.params; + const username = req.user.username; const { activityName } = req.body; const userId = await getUserId(db, username); if(!userId) { return res.status(404).json({error:'User not found'}); } const activities = db.collection("activities"); - const activityIncomplete = await activities.findOne( - { userId, "activities.name": activityName }, - { activities: {$elemMatch: { name: activityName }}, _id:0}, + const alreadyCompleted = await activities.findOne( + { userId, activities: { $elemMatch: { name: activityName, completed: true } } } ); - if(activityIncomplete) { - console.log('incomplete activity: ', activityName); + if (alreadyCompleted) { + return res.status(200).json({ message: 'already completed' }); } - await activities.updateOne( + const updateResult = await activities.updateOne( { userId, "activities.name": activityName }, { $set: { "activities.$.completed": true } } ); - return res.status(200).json({message:'success'}); + if (updateResult.modifiedCount === 0) { + // Activity not in today's random selection — add it as completed + const activityTypes = db.collection("activityTypes"); + const activityType = await activityTypes.findOne({ _id: activityName }); + const type = activityType?.type || "session"; + await activities.updateOne( + { userId }, + { $push: { activities: { name: activityName, type, completed: true } } } + ); + } + const updatedDoc = await activities.findOne({ userId }); + const allDone = updatedDoc.activities.every(activity => activity.completed); + if (allDone) { + await activities.updateOne( + { userId }, + { $push: { completedDates: new Date() } } + ); + } + return res.status(200).json({ message: 'success' }); } catch (err) { console.error('Error updating activities: ', err); return res.status(500).json({error: 'Server error'}); diff --git a/middlewareNode/src/routes/users.js b/middlewareNode/src/routes/users.js index 37a41ab6..29595161 100644 --- a/middlewareNode/src/routes/users.js +++ b/middlewareNode/src/routes/users.js @@ -175,6 +175,17 @@ router.post( }); await mainUser.save(); + // Create an activities document for students who register directly + if (role === "student") { + const newActivities = await selectActivities(); + const activitiesEntry = new Activities({ + userId: mainUser.id, + activities: newActivities, + completedDates: [], + }); + await activitiesEntry.save(); + } + res.status(200).json("Added users"); } catch (error) { console.error(error.message); @@ -231,6 +242,13 @@ router.post( recordingList: [], }); await newStudent.save(); + const newActivities = await selectActivities(); + const activitiesEntry = new Activities({ + userId: newStudent.id, + activities: newActivities, + completedDates: [], + }); + await activitiesEntry.save(); return res.status(200).json("Added student"); } catch (error) { console.error(error.message); diff --git a/middlewareNode/src/scheduler/activitiesScheduler.js b/middlewareNode/src/scheduler/activitiesScheduler.js index a3a71c75..9f0e692b 100644 --- a/middlewareNode/src/scheduler/activitiesScheduler.js +++ b/middlewareNode/src/scheduler/activitiesScheduler.js @@ -16,7 +16,7 @@ const schedule = require("node-schedule"); const config = require("config"); const { MongoClient } = require('mongodb'); -const { selectActivities } = require("../utils/activities"); +const { getActivityTypes, selectActivitiesFromList } = require("../utils/activities"); require('dotenv').config(); // Cache database client to prevent repeated connections @@ -45,11 +45,13 @@ const activityScheduler = schedule.scheduleJob('0 0 * * *', // Get all user activity documents const activitiesArray = await (activities.find({})).toArray(); - + + // Fetch activity types once and reuse for every user + const activityTypes = await getActivityTypes(); + // Reset activities for each user for(const userActivity of activitiesArray) { - // Generate new random activities for the day - const newActivities = await selectActivities(); + const newActivities = selectActivitiesFromList(activityTypes); const id = userActivity._id; // Update user's activities in database diff --git a/middlewareNode/src/utils/activities.js b/middlewareNode/src/utils/activities.js index 9312b917..e2401f39 100644 --- a/middlewareNode/src/utils/activities.js +++ b/middlewareNode/src/utils/activities.js @@ -25,38 +25,48 @@ async function getDb() { } /** - * Selects 4 random unique activities for a user's daily challenges - * - * Randomly picks activities from the activityTypes collection without duplicates. - * Each activity is initialized with completed: false. - * - * @returns {Array} Array of 4 activity objects with name, type, and completed fields + * Fetches all available activity types from the database. + * Call once and pass the result to selectActivitiesFromList to avoid repeated queries. + * @returns {Array} Array of activity type documents from MongoDB */ -const selectActivities = async () => { +const getActivityTypes = async () => { const db = await getDb(); - - // Fetch all available activity types - const activityList = await (db.collection("activityTypes").find({})).toArray(); - const chosenActivites = []; + return (db.collection("activityTypes").find({})).toArray(); +}; + +/** + * Selects 4 random unique activities from a pre-fetched activity list. + * Pure function — no DB access, safe to call in a loop. + * @param {Array} activityList - Array of activity type documents + * @returns {Array} Array of 4 activity objects with name, type, and completed fields + */ +const selectActivitiesFromList = (activityList) => { + const chosenIds = []; const newActivities = []; - - // Select 4 unique random activities + while (newActivities.length < 4) { - const activity = {}; - - // Randomly select an activity const selectedActivity = activityList[Math.floor(Math.random() * activityList.length)]; - - // Only add if not already selected (avoid duplicates) - if(!chosenActivites.includes(selectedActivity._id)) { - chosenActivites.push(selectedActivity._id); - activity.name = selectedActivity._id; - activity.type = selectedActivity.type; - activity.completed = false; - newActivities.push(activity); + + if (!chosenIds.includes(selectedActivity._id)) { + chosenIds.push(selectedActivity._id); + newActivities.push({ + name: selectedActivity._id, + type: selectedActivity.type, + completed: false, + }); } } return newActivities; -} +}; + +/** + * Convenience wrapper — queries DB then selects 4 activities. + * Use this for one-off calls. Use getActivityTypes + selectActivitiesFromList in loops. + * @returns {Array} Array of 4 activity objects + */ +const selectActivities = async () => { + const activityList = await getActivityTypes(); + return selectActivitiesFromList(activityList); +}; -module.exports = { selectActivities }; \ No newline at end of file +module.exports = { selectActivities, getActivityTypes, selectActivitiesFromList }; \ No newline at end of file diff --git a/react-ystemandchess/src/core/types/chess.d.ts b/react-ystemandchess/src/core/types/chess.d.ts index 3c5f597c..eed25662 100644 --- a/react-ystemandchess/src/core/types/chess.d.ts +++ b/react-ystemandchess/src/core/types/chess.d.ts @@ -19,6 +19,7 @@ export interface GameConfig { mentor: string; student: string; role: UserRole; + credentials?: string; } export interface BoardState { diff --git a/react-ystemandchess/src/core/utils/activityNames.ts b/react-ystemandchess/src/core/utils/activityNames.ts index 1d6007c8..79f4ba8b 100644 --- a/react-ystemandchess/src/core/utils/activityNames.ts +++ b/react-ystemandchess/src/core/utils/activityNames.ts @@ -42,12 +42,9 @@ const activityNameMap: Record = { * @param {Array} names - Array of activity objects * @returns {Array} Array of display names for the activities */ -export const parseActivities = (names: Array): Array => { - const namesArray = names.map((activity) => ({ - name: activityNameMap[activity.name] || activity.name, - type: activity.type, - completed: activity.completed, +export const parseActivities = (names: Array): Array<{ name: string, completed: boolean }> => { + return names.map((activity) => ({ + name: activityNameMap[activity.name] || activity.name, + completed: activity.completed, })); - return namesArray; - // TODO: Consider making API call to fetch display names dynamically } diff --git a/react-ystemandchess/src/features/lessons/piece-lessons/lesson-overlay/hooks/useChessSocket.ts b/react-ystemandchess/src/features/lessons/piece-lessons/lesson-overlay/hooks/useChessSocket.ts index 38fe1f51..3987f1d6 100644 --- a/react-ystemandchess/src/features/lessons/piece-lessons/lesson-overlay/hooks/useChessSocket.ts +++ b/react-ystemandchess/src/features/lessons/piece-lessons/lesson-overlay/hooks/useChessSocket.ts @@ -6,6 +6,7 @@ interface UseChessSocketOptions { student: string; mentor?: string; role?: "mentor" | "student" | "host" | "guest"; + credentials?: string; serverUrl: string; mode?: GameMode; trackMouse?: boolean; @@ -26,6 +27,7 @@ interface UseChessSocketOptions { onMessage?: (msg: string) => void; onRoleAssigned?: (role: "host" | "guest") => void; onColorAssigned?: (color: PlayerColor) => void; + onCompleteActivity?: () => void; } // ======== CENTRALIZED FEN NORMALIZATION ======== @@ -63,6 +65,7 @@ export const useChessSocket = ({ student, mentor = "", role = "student", + credentials = "", serverUrl, mode = "regular", trackMouse = false, @@ -80,6 +83,7 @@ export const useChessSocket = ({ onMessage, onRoleAssigned, onColorAssigned, + onCompleteActivity, }: UseChessSocketOptions) => { // ======== state ======== const [fen, setFen] = useState(""); @@ -96,12 +100,18 @@ export const useChessSocket = ({ const highlightFromRef = useRef(""); const highlightToRef = useRef(""); - // Store mentor/student/role in refs so they can be updated + // Store mentor/student/role/credentials in refs so they can be updated const mentorRef = useRef(mentor); const studentRef = useRef(student); const roleRef = useRef<"mentor" | "student" | "host" | "guest">(role); + const credentialsRef = useRef(credentials); + + // Keep latest callback ref so the stale socket closure always calls the current handler + const onCompleteActivityRef = useRef(onCompleteActivity); // ======== connect / listeners ======== + + useEffect(() => { const socket = io(serverUrl, { transports: ["websocket"], @@ -290,6 +300,10 @@ export const useChessSocket = ({ if (onError) onError(msg); }); + socket.on("completeActivity", () => { + if (onCompleteActivityRef.current) onCompleteActivityRef.current(); + }); + // cleanup when component unmounts return () => { try { @@ -320,7 +334,8 @@ export const useChessSocket = ({ const data: GameConfig = { mentor: mentorRef.current, student: studentRef.current, - role: roleRef.current + role: roleRef.current, + credentials: credentialsRef.current, }; console.log("Starting new puzzle:", data); socketRef.current?.emit("newPuzzle", JSON.stringify(data)); @@ -497,7 +512,9 @@ export const useChessSocket = ({ mentorRef.current = mentor; studentRef.current = student; roleRef.current = role; - }, [mentor, student, role]); + credentialsRef.current = credentials; + onCompleteActivityRef.current = onCompleteActivity; + }, [mentor, student, role, credentials, onCompleteActivity]); // allow runtime change of mentor/student/role const setUserInfo = useCallback( diff --git a/react-ystemandchess/src/features/puzzles/Puzzles.tsx b/react-ystemandchess/src/features/puzzles/Puzzles.tsx index 6f3c0163..89e48f74 100644 --- a/react-ystemandchess/src/features/puzzles/Puzzles.tsx +++ b/react-ystemandchess/src/features/puzzles/Puzzles.tsx @@ -1,4 +1,4 @@ -import React, { useRef, useState, useEffect, useCallback } from "react"; +import React, { useRef, useState, useEffect, useCallback, useMemo } from "react"; import { themesName, themesDescription, @@ -19,6 +19,7 @@ type PuzzlesProps = { mentor?: any; role?: any; styleType?: any; + onCompleteActivity?: () => void; }; // Helper function to normalize FEN (same as in socket) @@ -50,6 +51,7 @@ const Puzzles: React.FC = ({ mentor = null, role = "student", styleType = "page", + onCompleteActivity, }) => { const isProfile = styleType === "profile"; @@ -83,8 +85,17 @@ const Puzzles: React.FC = ({ const [startTime, setStartTime] = useState(null); const [username, setUsername] = useState(null); + // Decode username from JWT cookie directly — fallback when startRecording is slow/fails + const usernameFromJwt = useMemo(() => { + try { + return JSON.parse(atob((cookies.login as string).split('.')[1])).username as string; + } catch { + return null; + } + }, [cookies.login]); + // User identification - const studentId = student || cookies.login?.studentId || uuidv4(); + const studentId = student || usernameFromJwt || uuidv4(); const mentorId = mentor || "puzzle_mentor_" + studentId; // ============================================================================ @@ -305,7 +316,7 @@ const Puzzles: React.FC = ({ if (newFen) { setCurrentFEN(newFen); } - move.username = username; + move.username = student || usernameFromJwt || username; move.credentials = cookies.login; socket.sendMove(move); socket.sendLastMove(move.from, move.to); @@ -385,6 +396,7 @@ const Puzzles: React.FC = ({ student: studentId, mentor: mentorId, role: role, + credentials: cookies.login, serverUrl: environment.urls.chessServerURL, mode: "puzzle", @@ -394,6 +406,7 @@ const Puzzles: React.FC = ({ }, onMessage: handleSocketMessage, + onCompleteActivity, onRoleAssigned: (assignedRole) => { if (assignedRole === "host") { diff --git a/react-ystemandchess/src/features/student/student-profile/Modals/ActivitiesModal.scss b/react-ystemandchess/src/features/student/student-profile/Modals/ActivitiesModal.scss index 98b8ee12..ba6bcf9f 100644 --- a/react-ystemandchess/src/features/student/student-profile/Modals/ActivitiesModal.scss +++ b/react-ystemandchess/src/features/student/student-profile/Modals/ActivitiesModal.scss @@ -10,11 +10,13 @@ align-items: center; z-index: 999; animation: fadeIn 0.3s ease; + pointer-events: none; /* --------------------------------------------- Modal Container ---------------------------------------------- */ .modal-content { + pointer-events: auto; width: 80vw; max-width: 1000px; max-height: 90vh; @@ -108,6 +110,11 @@ &:hover { background: #f7a835; } + + &.growth-btn--active { + background: #c47a10; + box-shadow: 0 0 0 3px #fff, 0 0 0 5px #c47a10; + } } .water-meter { @@ -120,6 +127,36 @@ svg { width: 100%; height: 100%; + + /* Hide the hardcoded 50% fill, wave, and "50%" text paths baked into the SVG */ + g:nth-child(2), + g:nth-child(3), + g:nth-child(4) { + display: none; + } + } + + .water-fill { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + background: rgba(30, 120, 200, 0.45); + border-radius: 0 0 4px 4px; + transition: none; + pointer-events: none; + } + + .water-percent { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.65rem; + font-weight: 700; + color: #1a3a6e; + pointer-events: none; } } } @@ -182,6 +219,13 @@ display: flex; flex-direction: column; gap: 0.75rem; + + .no-activities { + font-size: 0.8rem; + color: #555; + font-style: italic; + padding: 0.5rem; + } } .activity-button { @@ -220,6 +264,24 @@ margin: 0; display: block; } + + .status-icon { + position: absolute; + bottom: 0.3rem; + right: 0.4rem; + font-size: 0.85rem; + font-weight: 700; + color: #5a9e6f; + } + + &.activity-button--completed { + background-color: #d4edda; + border: 1.5px solid #5a9e6f; + + .action { + color: #2e7d4f; + } + } } } } diff --git a/react-ystemandchess/src/features/student/student-profile/Modals/ActivitiesModal.tsx b/react-ystemandchess/src/features/student/student-profile/Modals/ActivitiesModal.tsx index c16e6c51..e9f68d6e 100644 --- a/react-ystemandchess/src/features/student/student-profile/Modals/ActivitiesModal.tsx +++ b/react-ystemandchess/src/features/student/student-profile/Modals/ActivitiesModal.tsx @@ -25,109 +25,144 @@ import { ReactComponent as TopicBag } from "../../../../assets/images/Activities import { ReactComponent as ShortBottomVine} from "../../../../assets/images/ActivitiesAssets/short_bottom_vine.svg"; import { ReactComponent as BottomVine} from "../../../../assets/images/ActivitiesAssets/bottom_vine.svg"; import { ReactComponent as Stemmy} from "../../../../assets/images/ActivitiesAssets/stemmy.svg"; -import { environment } from "../../../../environments/environment"; +import { environment } from "../../../../environments/environment"; import { useCookies } from "react-cookie"; import { parseActivities } from "../../../../core/utils/activityNames"; +type ActivityDisplay = { name: string; type: string; completed: boolean }; +type FilterType = "all" | "puzzle" | "practice"; + /** * ActivitiesModal component - displays daily activities in garden theme * @param {Function} onClose - Callback to close the modal * @param {string} username - Username to fetch activities for */ -const ActivitiesModal = ({ onClose, username }: { onClose: () => void; username: string }) => { - // Close modal only when clicking the background overlay (not child elements) - const handleOverlayClick = (e: React.MouseEvent) => { - if (e.target === e.currentTarget) { - onClose(); - } - }; - +const ActivitiesModal = ({ + onClose, + username, + refreshKey = 0, +}: { + onClose: () => void; + username: string; + refreshKey?: number; +}) => { const [cookies] = useCookies(['login']); - const [activities, setActivities] = useState(null); - const [loading, setLoading] = useState(true); - + const [activities, setActivities] = useState(null); + const [activeFilter, setActiveFilter] = useState("all"); const fetchActivities = async () => { try { const url = `${environment.urls.middlewareURL}/activities/${username}`; const response = await fetch(url, { method: "GET", + cache: "no-store", headers: { 'Authorization': `Bearer ${cookies.login}`, 'Content-Type': 'application/json', }, - }) + }); const json = await response.json(); - const data = json.activities.activities; - const activityNames = parseActivities(data); - setActivities(activityNames); - setLoading(false); + const raw: any[] = json.activities.activities; + const parsed = parseActivities(raw); + // Merge display names with raw type field for filtering + const mapped: ActivityDisplay[] = parsed.map((p, i) => ({ + name: p.name, + type: raw[i].type, + completed: p.completed, + })); + setActivities(mapped); } catch (err) { console.error("Error fetching activities:", err); } - - } + }; + useEffect(() => { - fetchActivities() - .catch(err => { - console.error(err) - }); - }, []); - - return ( - !loading && -
+ if (!username || !username.trim()) return; + fetchActivities().catch(console.error); + }, [refreshKey, username]); + + if (!activities) return null; + + const completedCount = activities.filter(a => a.completed).length; + const totalCount = activities.length; + const progressPercent = totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0; + + const filteredActivities = activities.filter(a => { + if (activeFilter === "puzzle") return a.type === "midMatch" || a.type === "puzzle"; + if (activeFilter === "practice") return a.type === "lesson" || a.type === "practice"; + return true; + }); + + const toggleFilter = (f: FilterType) => + setActiveFilter(prev => (prev === f ? "all" : f)); + + return ( +
- {/* Close (X) button */} - {/* Growth Box section with vine, SVG, buttons, and water meter */} + {/* Growth Box — filter tabs + water meter progress */}
- - + +
+
+ {progressPercent}%
- {/* Top vine area with hanging vine, task button, and daily activity buttons */} + {/* Top vine — activity list */}
- {/* Task banner button */} - {/* Stack of daily activity buttons. Activities are hard coded for now.*/}
- {activities.map((activity, idx) => { - //Use activity.completed to change visual for complete or incomplete task - return ( - - //check completed status, display conditional, then complete task in puzzles to check - ) - } - + {filteredActivities.length === 0 && ( +

No activities for this filter.

)} + {filteredActivities.map((activity, idx) => ( + + ))}
- {/* Topic bag area with clickable topic buttons and bottom vines */} + {/* Topic bag */}
@@ -138,7 +173,6 @@ const ActivitiesModal = ({ onClose, username }: { onClose: () => void; username:
- {/* Decorative stem character */}
diff --git a/react-ystemandchess/src/features/student/student-profile/Modals/BadgesModal.tsx b/react-ystemandchess/src/features/student/student-profile/Modals/BadgesModal.tsx index 54abf338..06eb74f2 100644 --- a/react-ystemandchess/src/features/student/student-profile/Modals/BadgesModal.tsx +++ b/react-ystemandchess/src/features/student/student-profile/Modals/BadgesModal.tsx @@ -41,6 +41,10 @@ const BadgesModal = ({ onClose }: { onClose: () => void }) => { ]); setCatalog(badges); setEarnedIds(earned.map((b: any) => b.badgeId)); + } catch (err) { + console.error("Error loading badges:", err); + setCatalog([]); + setEarnedIds([]); } finally { setLoading(false); } diff --git a/react-ystemandchess/src/features/student/student-profile/NewStudentProfile.tsx b/react-ystemandchess/src/features/student/student-profile/NewStudentProfile.tsx index d37e64fe..d9f57fa8 100644 --- a/react-ystemandchess/src/features/student/student-profile/NewStudentProfile.tsx +++ b/react-ystemandchess/src/features/student/student-profile/NewStudentProfile.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, useMemo, lazy, Suspense } from "react"; +import { useState, useEffect, useRef, useMemo, useCallback, lazy, Suspense } from "react"; import { SetPermissionLevel } from '../../../globals'; import { useCookies } from 'react-cookie'; import { environment } from "../../../environments/environment"; @@ -109,6 +109,9 @@ const NewStudentProfile = ({ userPortraitSrc }: any) => { const [showConfetti, setShowConfetti] = useState(false); const [celebrateAction, setCelebrateAction] = useState(false); + // Incremented each time a puzzle activity completes, triggering ActivitiesModal refetch + const [activityRefreshKey, setActivityRefreshKey] = useState(0); + // states for lessons tab const [lessonSelected, setLessonSelected] = useState(false); // whether user has navigated into a lesson const [piece, setPiece] = useState(""); // lesson name for props @@ -268,6 +271,10 @@ const NewStudentProfile = ({ userPortraitSrc }: any) => { } } + const handleActivityComplete = useCallback(() => { + setActivityRefreshKey(k => k + 1); + }, []); + // Fun click handler for portrait const handlePortraitClick = () => { setShowConfetti(true); @@ -420,7 +427,7 @@ const NewStudentProfile = ({ userPortraitSrc }: any) => { case "puzzles": return (
- +
); @@ -448,7 +455,7 @@ const NewStudentProfile = ({ userPortraitSrc }: any) => { } }; - const tabContent = useMemo(() => renderTabContent(), [activeTab, lessonSelected, piece, lessonNum, events, loading, hasMore]); + const tabContent = useMemo(() => renderTabContent(), [activeTab, lessonSelected, piece, lessonNum, events, loading, hasMore, handleActivityComplete, username, mentorUsername]); return (
@@ -602,7 +609,7 @@ const NewStudentProfile = ({ userPortraitSrc }: any) => { {/* Modals */} {activeModal === "streak" && setActiveModal(null)} />} - {activeModal === "activities" && setActiveModal(null)} username={username} />} + {activeModal === "activities" && setActiveModal(null)} username={username} refreshKey={activityRefreshKey} />} {activeModal === "badges" && setActiveModal(null)} />} {activeModal === "leaderboard" && setActiveModal(null)} />}