From ce92597308c88a89e82cf0b8ed21514fb5b4303d Mon Sep 17 00:00:00 2001 From: Juan Salvador Zorrilla Date: Tue, 19 Aug 2025 23:01:06 +0200 Subject: [PATCH 001/109] add all necessary dependencies and libraries --- frontend/.gitignore | 23 ++++++++++ frontend/package.json | 16 +++++-- frontend/src/App.jsx | 8 ---- frontend/src/App.tsx | 13 ++++++ frontend/src/index.css | 1 + frontend/src/main.jsx | 10 ----- frontend/src/main.tsx | 29 +++++++++++++ frontend/src/routeTree.gen.ts | 77 ++++++++++++++++++++++++++++++++++ frontend/src/routes/__root.tsx | 20 +++++++++ frontend/src/routes/about.tsx | 13 ++++++ frontend/src/routes/index.tsx | 13 ++++++ frontend/tsconfig.json | 18 ++++++++ frontend/vite.config.js | 11 ++++- 13 files changed, 230 insertions(+), 22 deletions(-) create mode 100644 frontend/.gitignore delete mode 100644 frontend/src/App.jsx create mode 100644 frontend/src/App.tsx delete mode 100644 frontend/src/main.jsx create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/routeTree.gen.ts create mode 100644 frontend/src/routes/__root.tsx create mode 100644 frontend/src/routes/about.tsx create mode 100644 frontend/src/routes/index.tsx create mode 100644 frontend/tsconfig.json diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000000..d5b4e45805 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,23 @@ +# Node modules +node_modules/ + +# Build output +dist/ +build/ + +# Logs +*.log + +# Environment variables +.env +.env.local +.env.*.local + +# VS Code settings +.vscode/ + +# MacOS +.DS_Store + +# TanStack Query cache +.tanstack/ \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json index 7b2747e949..92b3c491e2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,17 +10,27 @@ "preview": "vite preview" }, "dependencies": { + "@tanstack/react-router": "^1.131.27", + "@tanstack/react-router-devtools": "^1.131.27", + "motion": "^12.23.12", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "zustand": "^5.0.7" }, "devDependencies": { - "@types/react": "^18.2.15", - "@types/react-dom": "^18.2.7", + "@tailwindcss/vite": "^4.1.12", + "@tanstack/router-plugin": "^1.131.27", + "@types/react": "^18.3.23", + "@types/react-dom": "^18.3.7", + "@typescript-eslint/eslint-plugin": "^8.40.0", + "@typescript-eslint/parser": "^8.40.0", "@vitejs/plugin-react": "^4.0.3", "eslint": "^8.45.0", "eslint-plugin-react": "^7.32.2", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.3", + "tailwindcss": "^4.1.12", + "typescript": "^5.9.2", "vite": "^6.3.5" } } diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx deleted file mode 100644 index 0a24275e6e..0000000000 --- a/frontend/src/App.jsx +++ /dev/null @@ -1,8 +0,0 @@ -export const App = () => { - - return ( - <> -

Welcome to Final Project!

- - ); -}; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000000..290f6f0412 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,13 @@ +import { Outlet } from '@tanstack/react-router' + +const App = () => { + + return ( +
+

My React + TanStack Router App

+ +
+ ); +}; + +export default App; \ No newline at end of file diff --git a/frontend/src/index.css b/frontend/src/index.css index e69de29bb2..a461c505f1 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -0,0 +1 @@ +@import "tailwindcss"; \ No newline at end of file diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx deleted file mode 100644 index 51294f3998..0000000000 --- a/frontend/src/main.jsx +++ /dev/null @@ -1,10 +0,0 @@ -import React from "react"; -import ReactDOM from "react-dom/client"; -import { App } from "./App.jsx"; -import "./index.css"; - -ReactDOM.createRoot(document.getElementById("root")).render( - - - -); diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000000..a885e2fd0e --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,29 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { RouterProvider, createRouter } from '@tanstack/react-router' +import "./index.css"; + +// import the generated route tree +import { routeTree } from './routeTree.gen' + +// create a new router instance +const router = createRouter({ routeTree }) + +// Register the router instance for type safety +declare module '@tanstack/react-router' { + interface Register { + router: typeof router + } +} + +// Render the app + +const rootElement = document.getElementById('root')! +if (!rootElement.innerHTML) { + const root = ReactDOM.createRoot(rootElement) + root.render( + + + + ) +} \ No newline at end of file diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts new file mode 100644 index 0000000000..59499d9fbf --- /dev/null +++ b/frontend/src/routeTree.gen.ts @@ -0,0 +1,77 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from './routes/__root' +import { Route as AboutRouteImport } from './routes/about' +import { Route as IndexRouteImport } from './routes/index' + +const AboutRoute = AboutRouteImport.update({ + id: '/about', + path: '/about', + getParentRoute: () => rootRouteImport, +} as any) +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/about': typeof AboutRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/about': typeof AboutRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/about': typeof AboutRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: '/' | '/about' + fileRoutesByTo: FileRoutesByTo + to: '/' | '/about' + id: '__root__' | '/' | '/about' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + AboutRoute: typeof AboutRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/about': { + id: '/about' + path: '/about' + fullPath: '/about' + preLoaderRoute: typeof AboutRouteImport + parentRoute: typeof rootRouteImport + } + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + } +} + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + AboutRoute: AboutRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() diff --git a/frontend/src/routes/__root.tsx b/frontend/src/routes/__root.tsx new file mode 100644 index 0000000000..fb22c3d324 --- /dev/null +++ b/frontend/src/routes/__root.tsx @@ -0,0 +1,20 @@ +import { createRootRoute, Link, Outlet } from '@tanstack/react-router' +import { TanStackRouterDevtools } from '@tanstack/react-router-devtools' + +export const Route = createRootRoute({ + component: () => ( + <> +
+ + Home + {' '} + + About + +
+
+ + + + ), +}) \ No newline at end of file diff --git a/frontend/src/routes/about.tsx b/frontend/src/routes/about.tsx new file mode 100644 index 0000000000..76e447b76f --- /dev/null +++ b/frontend/src/routes/about.tsx @@ -0,0 +1,13 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const About = () => { + return ( +
+

About

+
+ ) +} + +export const Route = createFileRoute('/about')({ + component: About, +}) \ No newline at end of file diff --git a/frontend/src/routes/index.tsx b/frontend/src/routes/index.tsx new file mode 100644 index 0000000000..c4cf7aa2c0 --- /dev/null +++ b/frontend/src/routes/index.tsx @@ -0,0 +1,13 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Index = () => { + return ( +
+

Welcome Home!

+
+ ) +} + +export const Route = createFileRoute('/')({ + component: Index, +}) \ No newline at end of file diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000000..ff1462c7f2 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "jsx": "react-jsx", // for React 18+ + "moduleResolution": "Node", + "allowJs": true, // let .js files coexist with .ts/.tsx + "checkJs": false, // don’t enforce types in .js files + "strict": true, // turn off strict mode for now + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true + }, + "include": ["src"] +} \ No newline at end of file diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 5a33944a9b..668c4132e5 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -1,7 +1,16 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' +import { tanstackRouter } from '@tanstack/router-plugin/vite' +import tailwindcss from '@tailwindcss/vite' // https://vitejs.dev/config/ export default defineConfig({ - plugins: [react()], + plugins: [ + tanstackRouter({ + target: 'react', + autoCodeSplitting: true, + }), + react(), + tailwindcss(), + ], }) From f65857789937772775dec95ec9fbf3f9b2cce680 Mon Sep 17 00:00:00 2001 From: Juan Salvador Zorrilla Date: Wed, 20 Aug 2025 15:53:26 +0200 Subject: [PATCH 002/109] set up libraries and foundation for the backend --- backend/.gitignore | 7 +++++++ backend/package.json | 18 ++++++++++++------ backend/server.js | 17 +++++++++++++++-- 3 files changed, 34 insertions(+), 8 deletions(-) create mode 100644 backend/.gitignore diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000000..5f06c8464d --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,7 @@ +node_modules +.DS_Store +.env +.env.local +.env.development.local +.env.test.local +.env.production.local \ No newline at end of file diff --git a/backend/package.json b/backend/package.json index 08f29f2448..729d3780ce 100644 --- a/backend/package.json +++ b/backend/package.json @@ -9,12 +9,18 @@ "author": "", "license": "ISC", "dependencies": { - "@babel/core": "^7.17.9", - "@babel/node": "^7.16.8", - "@babel/preset-env": "^7.16.11", + "bcrypt": "^6.0.0", "cors": "^2.8.5", + "dotenv": "^17.2.1", "express": "^4.17.3", - "mongoose": "^8.4.0", - "nodemon": "^3.0.1" + "express-list-endpoints": "^7.1.1", + "jsonwebtoken": "^9.0.2", + "mongoose": "^8.4.0" + }, + "devDependencies": { + "@babel/core": "^7.28.3", + "@babel/node": "^7.28.0", + "@babel/preset-env": "^7.28.3", + "nodemon": "^3.1.10" } -} \ No newline at end of file +} diff --git a/backend/server.js b/backend/server.js index 070c875189..bd17c4cd32 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1,10 +1,13 @@ import express from "express"; import cors from "cors"; import mongoose from "mongoose"; +import listEndpoints from "express-list-endpoints"; +import dotenv from "dotenv"; + +dotenv.config(); const mongoUrl = process.env.MONGO_URL || "mongodb://localhost/final-project"; mongoose.connect(mongoUrl); -mongoose.Promise = Promise; const port = process.env.PORT || 8080; const app = express(); @@ -12,10 +15,20 @@ const app = express(); app.use(cors()); app.use(express.json()); +// List all API endpoints for documentation + app.get("/", (req, res) => { - res.send("Hello Technigo!"); + const endpoints = listEndpoints(app); + res.json({ + message: "Welcome to the BrainPet API!", + endpoints: endpoints, + }); }); +// Set up endpoints + + + // Start the server app.listen(port, () => { console.log(`Server running on http://localhost:${port}`); From 732c4f13ca30871c137378af7f2422b45cc60a31 Mon Sep 17 00:00:00 2001 From: Juan Salvador Zorrilla Date: Wed, 20 Aug 2025 23:34:48 +0200 Subject: [PATCH 003/109] add rest of backend skeleton and boilerplate --- backend/.gitignore | 9 ++++++--- backend/config/db.js | 19 +++++++++++++++++++ backend/models/user.js | 0 backend/package.json | 4 ++-- backend/server.js | 19 +++++++++++++++---- 5 files changed, 42 insertions(+), 9 deletions(-) create mode 100644 backend/config/db.js create mode 100644 backend/models/user.js diff --git a/backend/.gitignore b/backend/.gitignore index 5f06c8464d..07fa2b2a2e 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -1,7 +1,10 @@ +# Node modules node_modules + +# MacOS .DS_Store + +# Environment variables .env .env.local -.env.development.local -.env.test.local -.env.production.local \ No newline at end of file +.env.*.local \ No newline at end of file diff --git a/backend/config/db.js b/backend/config/db.js new file mode 100644 index 0000000000..409f07d1a9 --- /dev/null +++ b/backend/config/db.js @@ -0,0 +1,19 @@ +import mongoose from "mongoose"; + +const connectDB = async () => { + try { + const uri = + process.env.NODE_ENV === "production" + ? process.env.MONGODB_URI_PROD + : process.env.MONGODB_URI_DEV; + + await mongoose.connect(uri); + + console.log(`✅ MongoDB connected to ${uri}`); + } catch (err) { + console.error("❌ MongoDB connection error:", err.message); + process.exit(1); + } +}; + +export default connectDB; \ No newline at end of file diff --git a/backend/models/user.js b/backend/models/user.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/package.json b/backend/package.json index 729d3780ce..16bff89283 100644 --- a/backend/package.json +++ b/backend/package.json @@ -3,8 +3,8 @@ "version": "1.0.0", "description": "Server part of final project", "scripts": { - "start": "babel-node server.js", - "dev": "nodemon server.js --exec babel-node" + "start": "NODE_ENV=production babel-node server.js", + "dev": "NODE_ENV=development nodemon server.js --exec babel-node" }, "author": "", "license": "ISC", diff --git a/backend/server.js b/backend/server.js index bd17c4cd32..bebda81525 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1,13 +1,11 @@ import express from "express"; import cors from "cors"; -import mongoose from "mongoose"; import listEndpoints from "express-list-endpoints"; import dotenv from "dotenv"; +import connectDB from "./config/db.js" dotenv.config(); - -const mongoUrl = process.env.MONGO_URL || "mongodb://localhost/final-project"; -mongoose.connect(mongoUrl); +connectDB(); const port = process.env.PORT || 8080; const app = express(); @@ -27,6 +25,19 @@ app.get("/", (req, res) => { // Set up endpoints +// Endpoint for registering a user. + +// Endpoint for logging in a user. + +// Endpoint for retrieving the data of an authenticated user. + +// Endpoint for fetching inventory. + +// Endpoint for fething all exercise modules. + +// Endpoint for fetching pet stats. + +// Endpoint for fetching user stats. // Start the server From f4fcd32538079753147c25d2981fdb28f4131a72 Mon Sep 17 00:00:00 2001 From: Juan Salvador Zorrilla Date: Thu, 21 Aug 2025 00:03:44 +0200 Subject: [PATCH 004/109] add user model --- backend/models/user.js | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/backend/models/user.js b/backend/models/user.js index e69de29bb2..53acad34bf 100644 --- a/backend/models/user.js +++ b/backend/models/user.js @@ -0,0 +1,39 @@ +import mongoose from "mongoose"; + +const userSchema = new mongoose.Schema( + { + initials: { + type: String, + required: [true, "First and last name initials are required"], + minlength: 2, + maxlength: 6, + trim: true, + }, + email: { + type: String, + required: [true, "Email is required"], + unique: true, + lowercase: true, + match: [ + /^[a-zA-Z0-9._%+-]+@osloskolen\.no$/, + "Email must end with @osloskolen.no", + ], + }, + password: { + type: String, + required: [true, "Password is required"], + minlength: [8, "Password must be at least 8 characters"], + }, + classroomCode: { + type: String, + required: [true, "Classroom code is required"], + match: [ + /^[A-Z]{3}-\d{4}$/, + "Classroom code must be in format XXX-0000", + ], + }, + }, + { timestamps: true } +); + +export const User = mongoose.model("User", userSchema); \ No newline at end of file From 515dfd108d119b159c30ed0f00d02c5dc5629904 Mon Sep 17 00:00:00 2001 From: Juan Salvador Zorrilla Date: Thu, 21 Aug 2025 12:19:19 +0200 Subject: [PATCH 005/109] set up authentication controllers --- backend/controllers/authController.js | 94 +++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 backend/controllers/authController.js diff --git a/backend/controllers/authController.js b/backend/controllers/authController.js new file mode 100644 index 0000000000..39f144aab3 --- /dev/null +++ b/backend/controllers/authController.js @@ -0,0 +1,94 @@ +import { User } from "../models/user.js"; +import bcrypt from "bcrypt"; +import jwt from "jsonwebtoken"; + +// Helper to generate JWT +const generateToken = (id) => { + return jwt.sign({ id }, process.env.JWT_SECRET, { expiresIn: "30d" }); +}; + +// Register a new user +// POST /api/auth/register +// Public access + +export const registerUser = async (req, res) => { + const { initials, email, password, classroomCode } = req.body; + + try { + // Check if user exists + const userExists = await User.findOne({ email }); + if (userExists) { + return res.status(400).json({ error: "User already exists" }); + } + + // Hash password + const salt = await bcrypt.genSalt(10); + const hashedPassword = await bcrypt.hash(password, salt); + + // Create user + const user = await User.create({ + initials, + email, + password: hashedPassword, + classroomCode, + }); + + // Respond with token + res.status(201).json({ + _id: user._id, + initials: user.initials, + email: user.email, + classroomCode: user.classroomCode, + token: generateToken(user._id), + }); + + } catch (error) { + res.status(400).json({ error: error.message }); + } +}; + +// Log in an existing user +// POST api/auth/login +// Public access + +export const loginUser = async (req, res) => { + const { email, password } = req.body; + + try { + // Find user + const user = await User.findOne({ email }); + if (!user) { + return res.status(401).json({ error: "Invalid credentials" }); + } + + // Compare password + const isMatch = await bcrypt.compare(password, user.password); + if (!isMatch) { + return res.status(401).json({ error: "Invalid credentials" }); + } + + // Respond with token + res.json({ + _id: user._id, + initials: user.initials, + email: user.email, + classroomCode: user.classroomCode, + token: generateToken(user._id), + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}; + +// Get user profile info (minus password) +// GET api/auth/profile +// Private access + +export const getUserProfile = async (req, res) => { + try { + const user = await User.findById(req.user.id).select("-password"); + res.json(user); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}; From c250a847f7aaca4220c9dfd57ba9be4f018ab928 Mon Sep 17 00:00:00 2001 From: Juan Salvador Zorrilla Date: Thu, 21 Aug 2025 12:39:04 +0200 Subject: [PATCH 006/109] add authentication middleware --- backend/middleware/authMiddleware.js | 29 ++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 backend/middleware/authMiddleware.js diff --git a/backend/middleware/authMiddleware.js b/backend/middleware/authMiddleware.js new file mode 100644 index 0000000000..0ada5ac6fb --- /dev/null +++ b/backend/middleware/authMiddleware.js @@ -0,0 +1,29 @@ +import jwt from "jsonwebtoken"; +import { User } from "../models/user.js"; + +export const authenticateUser = async (req, res, next) => { + let token; + + if ( + req.headers.authorization && + req.headers.authorization.startsWith("Bearer") + ) { + try { + token = req.headers.authorization.split(" ")[1]; + + // Verify token + const decoded = jwt.verify(token, process.env.JWT_SECRET); + + // Attach user to request + req.user = await User.findById(decoded.id).select("-password"); + + next(); + } catch (error) { + return res.status(401).json({ error: "Not authorized, token failed" }); + } + } + + if (!token) { + return res.status(401).json({ error: "Not authorized, no token" }); + } +} \ No newline at end of file From 4c851ae6ba42f13ba161b75f3dc3fd2a9f9f53a7 Mon Sep 17 00:00:00 2001 From: Juan Salvador Zorrilla Date: Thu, 21 Aug 2025 12:59:49 +0200 Subject: [PATCH 007/109] set up auth routes and import to server.js --- backend/routes/authRoutes.js | 11 +++++++++++ backend/server.js | 8 ++++---- 2 files changed, 15 insertions(+), 4 deletions(-) create mode 100644 backend/routes/authRoutes.js diff --git a/backend/routes/authRoutes.js b/backend/routes/authRoutes.js new file mode 100644 index 0000000000..3d69fdd6c6 --- /dev/null +++ b/backend/routes/authRoutes.js @@ -0,0 +1,11 @@ +import express from "express"; +import { registerUser, loginUser, getUserProfile } from "../controllers/authController.js"; +import { authenticateUser } from "../middleware/authMiddleware.js"; + +const router = express.Router(); + +router.post("/register", registerUser); +router.post("/login", loginUser); +router.get("/profile", authenticateUser, getUserProfile); + +export default router; \ No newline at end of file diff --git a/backend/server.js b/backend/server.js index bebda81525..49ba0e13be 100644 --- a/backend/server.js +++ b/backend/server.js @@ -3,6 +3,7 @@ import cors from "cors"; import listEndpoints from "express-list-endpoints"; import dotenv from "dotenv"; import connectDB from "./config/db.js" +import authRoutes from "./routes/authRoutes.js"; dotenv.config(); connectDB(); @@ -23,14 +24,13 @@ app.get("/", (req, res) => { }); }); -// Set up endpoints - // Endpoint for registering a user. - // Endpoint for logging in a user. - // Endpoint for retrieving the data of an authenticated user. +app.use("/api/auth", authRoutes); + + // Endpoint for fetching inventory. // Endpoint for fething all exercise modules. From cbc04442ecceb71585aabb4019b0a5861a3e590e Mon Sep 17 00:00:00 2001 From: Juan Salvador Zorrilla Date: Thu, 21 Aug 2025 23:37:15 +0200 Subject: [PATCH 008/109] add pet schema to models --- backend/models/pet.js | 79 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 backend/models/pet.js diff --git a/backend/models/pet.js b/backend/models/pet.js new file mode 100644 index 0000000000..14271cd8c0 --- /dev/null +++ b/backend/models/pet.js @@ -0,0 +1,79 @@ +import mongoose from "mongoose"; + +const petSchema = new mongoose.Schema( + { + owner: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true, + }, + name: { + type: String, + default: "Pomodoro", + }, + health: { + type: Number, + default: 5, + min: 0, + max: 5, + }, + happiness: { + type: Number, + default: 5, + min: 0, + max: 5, + }, + hunger: { + type: Number, + default: 5, + min: 0, + max: 5, + }, + coins: { + type: Number, + default: 0, + }, + experience: { + current: { + type: Number, + default: 0, + }, + required: { + type: Number, + default: 100, + }, + }, + level: { + type: Number, + default: 1, + }, + inventory: [ + { + itemName: String, + category: { + type: String, + enum: ["food", "toy", "medicine", "powerup", "misc"], + }, + quantity: { + type: Number, + default: 1, + }, + }, + ], + status: { + type: String, + enum: ["alive", "expired"], + default: "alive", + }, + bornAt: { + type: Date, + default: Date.now, + }, + expiredAt: { + type: Date, + }, + }, + { timestamps: true } +); + +export const Pet = mongoose.model("Pet", petSchema); \ No newline at end of file From ba22603030bae167952bbb37ca0eda0bd664962d Mon Sep 17 00:00:00 2001 From: Juan Salvador Zorrilla Date: Thu, 21 Aug 2025 23:55:32 +0200 Subject: [PATCH 009/109] add petRoutes --- backend/routes/petRoutes.js | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 backend/routes/petRoutes.js diff --git a/backend/routes/petRoutes.js b/backend/routes/petRoutes.js new file mode 100644 index 0000000000..23d251dc26 --- /dev/null +++ b/backend/routes/petRoutes.js @@ -0,0 +1,36 @@ +import express from "express"; +import { authenticateUser } from "../middleware/authMiddleware.js"; +import { + createPet, + getPet, + useItem, + addXP, + addCoins, + getInventory, + addItem, + removeItem, + getLeaderboard +} from "../controllers/petController.js"; + +const router = express.Router(); + +// Pet lifecycle +router.post("/", authenticateUser, createPet); +router.get("/", authenticateUser, getPet); + +// Gameplay +router.patch("/use-item", authenticateUser, useItem); + +// Progression +router.patch("/xp", authenticateUser, addXP); +router.patch("/coins", authenticateUser, addCoins); + +// Inventory +router.get("/inventory", authenticateUser, getInventory); +router.patch("/inventory/add", authenticateUser, addItem); +router.patch("/inventory/remove", authenticateUser, removeItem); + +// Leaderboard +router.get("/leaderboard", authenticateUser, getLeaderboard); + +export default router; \ No newline at end of file From 32bdd8410db20f9dd1cc845cf17093f552620f8a Mon Sep 17 00:00:00 2001 From: Juan Salvador Zorrilla Date: Fri, 22 Aug 2025 15:02:34 +0200 Subject: [PATCH 010/109] add lastupdated to petschema to allow for automatic expiration --- backend/models/pet.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/models/pet.js b/backend/models/pet.js index 14271cd8c0..bdbb86c070 100644 --- a/backend/models/pet.js +++ b/backend/models/pet.js @@ -60,6 +60,10 @@ const petSchema = new mongoose.Schema( }, }, ], + lastUpdated: { + type: Date, + default: Date.now, + }, status: { type: String, enum: ["alive", "expired"], From f08a800f075dbc69a5cf7dd93a113b43875ca8ad Mon Sep 17 00:00:00 2001 From: Juan Salvador Zorrilla Date: Fri, 22 Aug 2025 16:09:29 +0200 Subject: [PATCH 011/109] modify petschema to include conditions --- backend/models/pet.js | 6 ++++++ backend/utils/petUtils.js | 27 +++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 backend/utils/petUtils.js diff --git a/backend/models/pet.js b/backend/models/pet.js index bdbb86c070..e91fc2d8e2 100644 --- a/backend/models/pet.js +++ b/backend/models/pet.js @@ -64,6 +64,12 @@ const petSchema = new mongoose.Schema( type: Date, default: Date.now, }, + conditions: { + isPooped: { type: Boolean, default: false }, + isSick: { type: Boolean, default: false }, + poopTime: { type: Date }, + sicknessTime: { type: Date }, + }, status: { type: String, enum: ["alive", "expired"], diff --git a/backend/utils/petUtils.js b/backend/utils/petUtils.js new file mode 100644 index 0000000000..29d2a75726 --- /dev/null +++ b/backend/utils/petUtils.js @@ -0,0 +1,27 @@ +export function applyPetDecay(pet) { + if (!pet || pet.status === "expired") return pet; + + const now = new Date(); + const hoursElapsed = Math.floor((now - pet.lastUpdated) / (1000 * 60 * 60)); + + if (hoursElapsed >= 12) { + const cycles = Math.floor(hoursElapsed / 12); + + // decrease stats by cycles + pet.hunger = Math.max(0, pet.hunger - cycles); + pet.happiness = Math.max(0, pet.happiness - cycles); + pet.health = Math.max(0, pet.health - cycles); + + // expire if any stat hits 0 + if (pet.health === 0 || pet.hunger === 0 || pet.happiness === 0) { + pet.status = "expired"; + pet.expiredAt = now; + } + + // Carry over leftover hours until the next cycle + const newLastUpdated = new Date(pet.lastUpdated.getTime() + cycles * 12 * 60 * 60 * 1000); + pet.lastUpdated = newLastUpdated; + } + + return pet; +} \ No newline at end of file From c6dca03e9e62170a5fe321ec1b7710e0d1913463 Mon Sep 17 00:00:00 2001 From: Juan Salvador Zorrilla Date: Sat, 23 Aug 2025 10:18:58 +0200 Subject: [PATCH 012/109] tweak pet decay util function --- backend/.gitignore | 6 +++++- backend/utils/petUtils.js | 28 ++++++++++++++++++++-------- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/backend/.gitignore b/backend/.gitignore index 07fa2b2a2e..65aaeb6ec6 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -7,4 +7,8 @@ node_modules # Environment variables .env .env.local -.env.*.local \ No newline at end of file +.env.*.local + +# Misc + +todo.md \ No newline at end of file diff --git a/backend/utils/petUtils.js b/backend/utils/petUtils.js index 29d2a75726..2afa98c1db 100644 --- a/backend/utils/petUtils.js +++ b/backend/utils/petUtils.js @@ -1,16 +1,27 @@ +// Pet decay util + export function applyPetDecay(pet) { if (!pet || pet.status === "expired") return pet; const now = new Date(); const hoursElapsed = Math.floor((now - pet.lastUpdated) / (1000 * 60 * 60)); - if (hoursElapsed >= 12) { - const cycles = Math.floor(hoursElapsed / 12); + if (hoursElapsed >= 6) { + // calculate elapsed cycles for each stat + let hungerCycles = Math.floor(hoursElapsed / 12); + + let happinessCycles = pet.conditions.isPooped + ? Math.floor(hoursElapsed / 6) + : Math.floor(hoursElapsed / 12); + + let healthCycles = pet.conditions.isSick + ? Math.floor(hoursElapsed / 6) + : Math.floor(hoursElapsed / 12); - // decrease stats by cycles - pet.hunger = Math.max(0, pet.hunger - cycles); - pet.happiness = Math.max(0, pet.happiness - cycles); - pet.health = Math.max(0, pet.health - cycles); + // decrease stats by number of elapsed cycles + pet.hunger = Math.max(0, pet.hunger - hungerCycles); + pet.happiness = Math.max(0, pet.happiness - happinessCycles); + pet.health = Math.max(0, pet.health - healthCycles); // expire if any stat hits 0 if (pet.health === 0 || pet.hunger === 0 || pet.happiness === 0) { @@ -18,8 +29,9 @@ export function applyPetDecay(pet) { pet.expiredAt = now; } - // Carry over leftover hours until the next cycle - const newLastUpdated = new Date(pet.lastUpdated.getTime() + cycles * 12 * 60 * 60 * 1000); + // update lastUpdated to the most recent decay boundary (i.e. carry over leftover hours) + const cyclesElapsed = Math.floor(hoursElapsed / 6); + const newLastUpdated = new Date(pet.lastUpdated.getTime() + cyclesElapsed * 6 * 60 * 60 * 1000); pet.lastUpdated = newLastUpdated; } From 041c8b30cdc56f717b581ac13da761be641741b1 Mon Sep 17 00:00:00 2001 From: Juan Salvador Zorrilla Date: Sat, 23 Aug 2025 10:27:41 +0200 Subject: [PATCH 013/109] add getPet controller to petController --- backend/controllers/petController.js | 26 ++++++++++++++++++++++++++ backend/utils/petUtils.js | 2 +- 2 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 backend/controllers/petController.js diff --git a/backend/controllers/petController.js b/backend/controllers/petController.js new file mode 100644 index 0000000000..820aadb467 --- /dev/null +++ b/backend/controllers/petController.js @@ -0,0 +1,26 @@ +import { Pet } from "../models/pet.js"; +import { applyPetDecay } from "../utils/petUtils.js"; + +// GET current pet + +export const getPet = async (req, res) => { + try { + // Find pet by user + let pet = await Pet.findOne({ owner: req.user._id }); + + if (!pet) { + return res.status(404).json({ message: "No active pet found" }); + } + + // Apply natural decay for hunger, happiness, health + pet = applyPetDecay(pet); + + // Save changes with updated stats + await pet.save(); + + res.json(pet); + } catch (error) { + console.error("Error fetching pet:", error); + res.status(500).json({ message: "Server error" }); + } +}; \ No newline at end of file diff --git a/backend/utils/petUtils.js b/backend/utils/petUtils.js index 2afa98c1db..d973d71041 100644 --- a/backend/utils/petUtils.js +++ b/backend/utils/petUtils.js @@ -1,6 +1,6 @@ // Pet decay util -export function applyPetDecay(pet) { +export const applyPetDecay = (pet) => { if (!pet || pet.status === "expired") return pet; const now = new Date(); From 9a602d584a8299302ed221ce4f5629968a238590 Mon Sep 17 00:00:00 2001 From: Juan Salvador Zorrilla Date: Sat, 23 Aug 2025 10:47:16 +0200 Subject: [PATCH 014/109] add petCreate controller function in petController --- backend/controllers/petController.js | 41 ++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/backend/controllers/petController.js b/backend/controllers/petController.js index 820aadb467..a83b8f2dc5 100644 --- a/backend/controllers/petController.js +++ b/backend/controllers/petController.js @@ -1,9 +1,46 @@ import { Pet } from "../models/pet.js"; import { applyPetDecay } from "../utils/petUtils.js"; +// POST create new pet + +export const createPet = async (req, res) => { + + try { + + const { name } = req.body; + + // Find user's current pet + let existingPet = await Pet.findOne({ owner: req.user._id }).sort({ createdAt: -1 }); + + if (existingPet) { + existingPet = applyPetDecay(existingPet); + + if (existingPet.status === "alive") { + return res.status(400).json({ message: "You already have a living pet!" }); + } + } + + // Create new pet + const newPet = await Pet.create({ + owner: req.user._id, + name: name || "Pomodoro", + }); + + res.status(201).json(newPet); + + } catch (error) { + + console.error("Error creating pet:", error); + + res.status(500).json({ message: "Server error" }); + + } +}; + // GET current pet export const getPet = async (req, res) => { + try { // Find pet by user let pet = await Pet.findOne({ owner: req.user._id }); @@ -19,8 +56,12 @@ export const getPet = async (req, res) => { await pet.save(); res.json(pet); + } catch (error) { + console.error("Error fetching pet:", error); + res.status(500).json({ message: "Server error" }); + } }; \ No newline at end of file From 9b1cff5e267b1a0d1dd51b6090f9770e11802b4e Mon Sep 17 00:00:00 2001 From: Juan Salvador Zorrilla Date: Sat, 23 Aug 2025 10:56:02 +0200 Subject: [PATCH 015/109] mount petRoutes to server.js --- backend/server.js | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/backend/server.js b/backend/server.js index 49ba0e13be..96bfd34baf 100644 --- a/backend/server.js +++ b/backend/server.js @@ -4,6 +4,7 @@ import listEndpoints from "express-list-endpoints"; import dotenv from "dotenv"; import connectDB from "./config/db.js" import authRoutes from "./routes/authRoutes.js"; +import petRoutes from "./routes/petRoutes.js"; dotenv.config(); connectDB(); @@ -30,15 +31,16 @@ app.get("/", (req, res) => { app.use("/api/auth", authRoutes); +// Endpoint for fetching pet. +// Endpoint for fetching pet inventory. +// Endpoint for adding item to inventory. +// Endpoint for removing item from inventory. +// Endpoint for using items on pet. +// Endpoint for adding XP to pet. +// Endpoint for adding coins to pet. +// Endpoint for calculating position on leaderboard. -// Endpoint for fetching inventory. - -// Endpoint for fething all exercise modules. - -// Endpoint for fetching pet stats. - -// Endpoint for fetching user stats. - +app.use("/api/pet", petRoutes); // Start the server app.listen(port, () => { From aa7edcf746c8e57627dc02150470ee71a6593ab6 Mon Sep 17 00:00:00 2001 From: Juan Salvador Zorrilla Date: Sat, 23 Aug 2025 11:50:20 +0200 Subject: [PATCH 016/109] add pet inventory controller functions --- backend/controllers/petController.js | 89 +++++++++++++++++++++++++++- backend/routes/petRoutes.js | 14 ++--- 2 files changed, 93 insertions(+), 10 deletions(-) diff --git a/backend/controllers/petController.js b/backend/controllers/petController.js index a83b8f2dc5..7eafd5cd23 100644 --- a/backend/controllers/petController.js +++ b/backend/controllers/petController.js @@ -2,7 +2,6 @@ import { Pet } from "../models/pet.js"; import { applyPetDecay } from "../utils/petUtils.js"; // POST create new pet - export const createPet = async (req, res) => { try { @@ -20,7 +19,7 @@ export const createPet = async (req, res) => { } } - // Create new pet + // Create pet const newPet = await Pet.create({ owner: req.user._id, name: name || "Pomodoro", @@ -38,7 +37,6 @@ export const createPet = async (req, res) => { }; // GET current pet - export const getPet = async (req, res) => { try { @@ -64,4 +62,89 @@ export const getPet = async (req, res) => { res.status(500).json({ message: "Server error" }); } +}; + +// ================ PET INVENTORY ================== + +// GET inventory +export const getInventory = async (req, res) => { + try { + const pet = await Pet.findOne({ owner: req.user._id }); + if (!pet) { + return res.status(404).json({ message: "Pet not found" }); + } + + // Apply decay to make sure stats are up to date + applyPetDecay(pet); + await pet.save(); + + res.json(pet.inventory); + + } catch (error) { + + res.status(500).json({ message: "Failed to fetch inventory", error }); + } +}; + +// PATCH add item +export const addItem = async (req, res) => { + try { + const { itemName, category, quantity } = req.body; + + if (!itemName || !category || !quantity) { + return res.status(400).json({ message: "Item name, category and quantity required" }); + } + + const pet = await Pet.findOne({ owner: req.user._id }); + if (!pet) { + return res.status(404).json({ message: "Pet not found" }); + } + + // Check if item already exists + const existingItem = pet.inventory.find((item) => item.itemName === itemName); + + if (existingItem) { + existingItem.quantity += quantity; + } else { + pet.inventory.push({ itemName, category, quantity }); + } + + await pet.save(); + res.json({ message: "Item(s) added", inventory: pet.inventory }); + } catch (error) { + res.status(500).json({ message: "Failed to add item", error }); + } +}; + +// PATCH remove item +export const removeItem = async (req, res) => { + try { + const { itemName, quantity = 1 } = req.body; + + if (!itemName) { + return res.status(400).json({ message: "Item name required" }); + } + + const pet = await Pet.findOne({ owner: req.user._id }); + if (!pet) { + return res.status(404).json({ message: "Pet not found" }); + } + + const item = pet.inventory.find((item) => item.itemName === itemName); + if (!item) { + return res.status(404).json({ message: "Item not found in inventory" }); + } + + item.quantity -= quantity; + + // If quantity <= 0, remove item entirely + if (item.quantity <= 0) { + pet.inventory = pet.inventory.filter((i) => i.itemName !== itemName); + } + + await pet.save(); + res.json({ message: "Item removed", inventory: pet.inventory }); + } catch (error) { + res.status(500).json({ message: "Failed to remove item", error }); + } }; \ No newline at end of file diff --git a/backend/routes/petRoutes.js b/backend/routes/petRoutes.js index 23d251dc26..3e0b82d491 100644 --- a/backend/routes/petRoutes.js +++ b/backend/routes/petRoutes.js @@ -19,18 +19,18 @@ router.post("/", authenticateUser, createPet); router.get("/", authenticateUser, getPet); // Gameplay -router.patch("/use-item", authenticateUser, useItem); +// router.patch("/use-item", authenticateUser, useItem); // Progression -router.patch("/xp", authenticateUser, addXP); -router.patch("/coins", authenticateUser, addCoins); +// router.patch("/xp", authenticateUser, addXP); +// router.patch("/coins", authenticateUser, addCoins); // Inventory -router.get("/inventory", authenticateUser, getInventory); -router.patch("/inventory/add", authenticateUser, addItem); -router.patch("/inventory/remove", authenticateUser, removeItem); +// router.get("/inventory", authenticateUser, getInventory); +// router.patch("/inventory/add", authenticateUser, addItem); +// router.patch("/inventory/remove", authenticateUser, removeItem); // Leaderboard -router.get("/leaderboard", authenticateUser, getLeaderboard); +// router.get("/leaderboard", authenticateUser, getLeaderboard); export default router; \ No newline at end of file From 9cdf2c604fb3c298862d5251a26d668e0101fec8 Mon Sep 17 00:00:00 2001 From: Juan Salvador Zorrilla Date: Sat, 23 Aug 2025 17:50:39 +0200 Subject: [PATCH 017/109] add useItem controller to petController to make use-item route work, and create item.js model for items --- backend/controllers/petController.js | 63 +++++++++++++++++++++++++++- backend/models/item.js | 32 ++++++++++++++ backend/routes/petRoutes.js | 6 +-- 3 files changed, 97 insertions(+), 4 deletions(-) create mode 100644 backend/models/item.js diff --git a/backend/controllers/petController.js b/backend/controllers/petController.js index 7eafd5cd23..6ba0683406 100644 --- a/backend/controllers/petController.js +++ b/backend/controllers/petController.js @@ -1,6 +1,9 @@ import { Pet } from "../models/pet.js"; +import { Item } from "../models/item.js" import { applyPetDecay } from "../utils/petUtils.js"; +// ================= PET ===================== + // POST create new pet export const createPet = async (req, res) => { @@ -119,7 +122,7 @@ export const addItem = async (req, res) => { // PATCH remove item export const removeItem = async (req, res) => { try { - const { itemName, quantity = 1 } = req.body; + const { itemName, quantity } = req.body; if (!itemName) { return res.status(400).json({ message: "Item name required" }); @@ -147,4 +150,62 @@ export const removeItem = async (req, res) => { } catch (error) { res.status(500).json({ message: "Failed to remove item", error }); } +}; + +// ================ PET USE ITEM(S) ==================== + +// PATCH /api/pet/use-item +export const useItem = async (req, res) => { + try { + const { itemName } = req.body; + + if (!itemName) { + return res.status(400).json({ message: "Item name required" }); + } + + let pet = await Pet.findOne({ owner: req.user._id }); + if (!pet) { + return res.status(404).json({ message: "Pet not found" }); + } + + // Apply decay before any changes + pet = applyPetDecay(pet); + + if (pet.status === "expired") { + return res.status(400).json({ message: "Cannot use item on an expired pet" }); + } + + // Find item in inventory + const inventoryItem = pet.inventory.find((item) => item.itemName === itemName && item.quantity > 0); + if (!inventoryItem) { + return res.status(404).json({ message: "Item not found in inventory" }); + } + + // get item details from Item collection + const storeItem = await Item.findOne({ name: itemName }); + if (!storeItem) { + return res.status(404).json({ message: "Item definition not found" }); + } + + // apply effect + const stat = storeItem.stat; + const effect = storeItem.effect; + + pet[stat] = Math.min(5, pet[stat] + effect); // cap at 5 bars + + // decrease quantity in inventory + inventoryItem.quantity -= 1; + if (inventoryItem.quantity <= 0) { + pet.inventory = pet.inventory.filter((i) => i.itemName !== itemName); + } + + await pet.save(); + + res.json({ + message: `${itemName} used on pet`, + pet, + }); + } catch (error) { + res.status(500).json({ message: "Failed to use item", error }); + } }; \ No newline at end of file diff --git a/backend/models/item.js b/backend/models/item.js new file mode 100644 index 0000000000..ea710cc8c4 --- /dev/null +++ b/backend/models/item.js @@ -0,0 +1,32 @@ +import mongoose from "mongoose"; + +const itemSchema = new mongoose.Schema({ + name: { + type: String, + required: true, + unique: true, + }, + category: { + type: String, + enum: ["food", "toy", "medicine", "powerup", "misc"], + required: true, + }, + stat: { + type: String, + enum: ["hunger", "happiness", "health", "coins", "xp", null], + required: false, // not all items will necessarily affect stats + }, + effect: { + type: Number, + default: 1, // how much it changes the stat + }, + price: { + type: Number, + default: 0, // store cost (in coins) + }, + description: { + type: String, + } +}, { timestamps: true }); + +export const Item = mongoose.model("Item", itemSchema); \ No newline at end of file diff --git a/backend/routes/petRoutes.js b/backend/routes/petRoutes.js index 3e0b82d491..59b77d9659 100644 --- a/backend/routes/petRoutes.js +++ b/backend/routes/petRoutes.js @@ -26,9 +26,9 @@ router.get("/", authenticateUser, getPet); // router.patch("/coins", authenticateUser, addCoins); // Inventory -// router.get("/inventory", authenticateUser, getInventory); -// router.patch("/inventory/add", authenticateUser, addItem); -// router.patch("/inventory/remove", authenticateUser, removeItem); +router.get("/inventory", authenticateUser, getInventory); +router.patch("/inventory/add", authenticateUser, addItem); +router.patch("/inventory/remove", authenticateUser, removeItem); // Leaderboard // router.get("/leaderboard", authenticateUser, getLeaderboard); From ee393fa43b6dc70b78db3e107d4635a99fee6e46 Mon Sep 17 00:00:00 2001 From: Juan Salvador Zorrilla Date: Sun, 24 Aug 2025 16:02:49 +0200 Subject: [PATCH 018/109] updated applyPetDecay to work with cron job and lazy evaluation --- backend/utils/petUtils.js | 86 ++++++++++++++++++++++++++------------- 1 file changed, 58 insertions(+), 28 deletions(-) diff --git a/backend/utils/petUtils.js b/backend/utils/petUtils.js index d973d71041..3d99bbda9b 100644 --- a/backend/utils/petUtils.js +++ b/backend/utils/petUtils.js @@ -4,36 +4,66 @@ export const applyPetDecay = (pet) => { if (!pet || pet.status === "expired") return pet; const now = new Date(); + + // Remove expired power-ups (lazy evaluation) + if (pet.activePowerups && pet.activePowerups.length > 0) { + pet.activePowerups = pet.activePowerups.filter( + (p) => p.expiresAt > now + ); + } + + // StatFreeze: skip decay and update time + const statFreeze = pet.activePowerups?.find( + (p) => p.type === "statFreeze" && p.expiresAt > now + ); + + if (statFreeze) { + // While frozen, prevent any decay from accumulating + pet.lastUpdated = now; + return pet; + } + + // calculate hours elapsed since last update const hoursElapsed = Math.floor((now - pet.lastUpdated) / (1000 * 60 * 60)); - if (hoursElapsed >= 6) { - // calculate elapsed cycles for each stat - let hungerCycles = Math.floor(hoursElapsed / 12); - - let happinessCycles = pet.conditions.isPooped - ? Math.floor(hoursElapsed / 6) - : Math.floor(hoursElapsed / 12); - - let healthCycles = pet.conditions.isSick - ? Math.floor(hoursElapsed / 6) - : Math.floor(hoursElapsed / 12); - - // decrease stats by number of elapsed cycles - pet.hunger = Math.max(0, pet.hunger - hungerCycles); - pet.happiness = Math.max(0, pet.happiness - happinessCycles); - pet.health = Math.max(0, pet.health - healthCycles); - - // expire if any stat hits 0 - if (pet.health === 0 || pet.hunger === 0 || pet.happiness === 0) { - pet.status = "expired"; - pet.expiredAt = now; - } - - // update lastUpdated to the most recent decay boundary (i.e. carry over leftover hours) - const cyclesElapsed = Math.floor(hoursElapsed / 6); - const newLastUpdated = new Date(pet.lastUpdated.getTime() + cyclesElapsed * 6 * 60 * 60 * 1000); - pet.lastUpdated = newLastUpdated; + if (hoursElapsed < 6) { + // Not enough time has passed to trigger any decay + return pet; + } + + // Figure out how many full decay cycles have passed + const hungerCycles = Math.floor(hoursElapsed / 12); + + const happinessCycles = pet.conditions.isPooped + ? Math.floor(hoursElapsed / 6) + : Math.floor(hoursElapsed / 12); + + const healthCycles = pet.conditions.isSick + ? Math.floor(hoursElapsed / 6) + : Math.floor(hoursElapsed / 12); + + // If no cycles completed, do nothing (i.e. carry leftover hours forward) + if (hungerCycles === 0 && happinessCycles === 0 && healthCycles === 0) { + return pet; + } + + // Apply any decay + pet.hunger = Math.max(0, pet.hunger - hungerCycles); + pet.happiness = Math.max(0, pet.happiness - happinessCycles); + pet.health = Math.max(0, pet.health - healthCycles); + + // expire pet if any stat hits 0 + if (pet.health === 0 || pet.hunger === 0 || pet.happiness === 0) { + pet.status = "expired"; + pet.expiredAt = now; } + // update lastUpdated to the most recent decay boundary (i.e. carry over leftover hours) + const cyclesElapsed = Math.floor(hoursElapsed / 6); + const newLastUpdated = new Date(pet.lastUpdated.getTime() + cyclesElapsed * 6 * 60 * 60 * 1000); + pet.lastUpdated = newLastUpdated; + return pet; -} \ No newline at end of file + +}; + From 6a332d412b32a586f4f3eadda25fc0d098f6d261 Mon Sep 17 00:00:00 2001 From: Juan Salvador Zorrilla Date: Sun, 24 Aug 2025 16:34:24 +0200 Subject: [PATCH 019/109] add statTimers to pet model to keep track of updated stats individually --- backend/models/pet.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/backend/models/pet.js b/backend/models/pet.js index e91fc2d8e2..beb0c1065a 100644 --- a/backend/models/pet.js +++ b/backend/models/pet.js @@ -64,12 +64,30 @@ const petSchema = new mongoose.Schema( type: Date, default: Date.now, }, + statTimers: { + hungerLastUpdated: { type: Date, default: Date.now }, + happinessLastUpdated: { type: Date, default: Date.now }, + healthLastUpdated: { type: Date, default: Date.now }, + }, conditions: { isPooped: { type: Boolean, default: false }, isSick: { type: Boolean, default: false }, poopTime: { type: Date }, sicknessTime: { type: Date }, }, + activePowerups: [ + { + type: { + type: String, + enum: ["statFreeze", "doubleCoins", "doubleXP"], // expand later potentially + required: true, + }, + expiresAt: { + type: Date, + required: true, + }, + } + ], status: { type: String, enum: ["alive", "expired"], From 598ed83f3c4519c39863812bc52f9e977cdacf7b Mon Sep 17 00:00:00 2001 From: Juan Salvador Zorrilla Date: Sun, 24 Aug 2025 18:11:14 +0200 Subject: [PATCH 020/109] update applyPetDecay function to correct bug where cycles never reach 12h if pet has a condition --- backend/utils/petUtils.js | 60 +++++++++++++++++++++++---------------- 1 file changed, 36 insertions(+), 24 deletions(-) diff --git a/backend/utils/petUtils.js b/backend/utils/petUtils.js index 3d99bbda9b..f0aab17e33 100644 --- a/backend/utils/petUtils.js +++ b/backend/utils/petUtils.js @@ -4,45 +4,54 @@ export const applyPetDecay = (pet) => { if (!pet || pet.status === "expired") return pet; const now = new Date(); + const MS_PER_HOUR = 1000 * 60 * 60; // Remove expired power-ups (lazy evaluation) - if (pet.activePowerups && pet.activePowerups.length > 0) { + if (pet.activePowerups?.length) { pet.activePowerups = pet.activePowerups.filter( (p) => p.expiresAt > now ); - } + } - // StatFreeze: skip decay and update time + // If statFreeze is active, reset all stat timers to now and skip decay const statFreeze = pet.activePowerups?.find( (p) => p.type === "statFreeze" && p.expiresAt > now ); if (statFreeze) { - // While frozen, prevent any decay from accumulating + Object.keys(pet.statTimers).forEach((key) => { + pet.statTimers[key] = now; + }); + pet.lastUpdated = now; + return pet; } - // calculate hours elapsed since last update - const hoursElapsed = Math.floor((now - pet.lastUpdated) / (1000 * 60 * 60)); - - if (hoursElapsed < 6) { - // Not enough time has passed to trigger any decay - return pet; + // Retrieve the last updated time for a stat + const getTimer = (key) => { + return pet.statTimers?.[key] ? new Date(pet.statTimers[key]) : now; } - // Figure out how many full decay cycles have passed - const hungerCycles = Math.floor(hoursElapsed / 12); + // Intervals in hours for each stat + const hungerInterval = 12; + const happinessInterval = pet.conditions.isPooped ? 6 : 12; + const healthInterval = pet.conditions.isSick ? 6 : 12; + + // Calculate cycles for each stat using its own timer + const hungerLastUpdated = getTimer("hungerLastUpdated"); + const happinessLastUpdated = getTimer("happinessLastUpdated"); + const healthLastUpdated = getTimer("healthLastUpdated"); - const happinessCycles = pet.conditions.isPooped - ? Math.floor(hoursElapsed / 6) - : Math.floor(hoursElapsed / 12); + const hungerHours = Math.floor((now - hungerLastUpdated) / MS_PER_HOUR); + const happinessHours = Math.floor((now - happinessLastUpdated) / MS_PER_HOUR); + const healthHours = Math.floor((now - healthLastUpdated) / MS_PER_HOUR); - const healthCycles = pet.conditions.isSick - ? Math.floor(hoursElapsed / 6) - : Math.floor(hoursElapsed / 12); + const hungerCycles = Math.floor(hungerHours / hungerInterval); + const happinessCycles = Math.floor(happinessHours / happinessInterval); + const healthCycles = Math.floor(healthHours / healthInterval); - // If no cycles completed, do nothing (i.e. carry leftover hours forward) + // Return if there are no stats to update if (hungerCycles === 0 && happinessCycles === 0 && healthCycles === 0) { return pet; } @@ -52,16 +61,19 @@ export const applyPetDecay = (pet) => { pet.happiness = Math.max(0, pet.happiness - happinessCycles); pet.health = Math.max(0, pet.health - healthCycles); - // expire pet if any stat hits 0 + // Update each stat timer by the amount consumed for that stat, leftover hours carried over to next check + pet.statTimers.hungerLastUpdated = new Date(hungerLastUpdated.getTime() + hungerCycles * hungerInterval * MS_PER_HOUR); + pet.statTimers.happinessLastUpdated = new Date(happinessLastUpdated.getTime() + happinessCycles * happinessInterval * MS_PER_HOUR); + pet.statTimers.healthLastUpdated = new Date(healthLastUpdated.getTime() + healthCycles * healthInterval * MS_PER_HOUR); + + // Expire pet if any stat hits 0 if (pet.health === 0 || pet.hunger === 0 || pet.happiness === 0) { pet.status = "expired"; pet.expiredAt = now; } - // update lastUpdated to the most recent decay boundary (i.e. carry over leftover hours) - const cyclesElapsed = Math.floor(hoursElapsed / 6); - const newLastUpdated = new Date(pet.lastUpdated.getTime() + cyclesElapsed * 6 * 60 * 60 * 1000); - pet.lastUpdated = newLastUpdated; + // Update lastUpdated (leave in for now) + pet.lastUpdated = now; return pet; From df01d340b195b360d27e7548e0bec70c8ea22a01 Mon Sep 17 00:00:00 2001 From: Juan Salvador Zorrilla Date: Sun, 24 Aug 2025 22:03:28 +0200 Subject: [PATCH 021/109] create cron job to update pets every hour --- backend/jobs/petCron.js | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 backend/jobs/petCron.js diff --git a/backend/jobs/petCron.js b/backend/jobs/petCron.js new file mode 100644 index 0000000000..03aa6571e6 --- /dev/null +++ b/backend/jobs/petCron.js @@ -0,0 +1,31 @@ +// jobs/petCron.js + +import cron from "node-cron"; +import dotenv from "dotenv"; +import connectDB from "../config/db.js"; +import { Pet } from "../models/pet.js"; +import { applyPetDecay } from "../utils/petUtils.js"; + +dotenv.config(); + +// Connect to DB +connectDB(); + +// Schedule cron job to run every hour at minute 0 +cron.schedule("0 * * * *", async () => { + console.log(`[${new Date().toISOString()}] Running pet hourly update...`); + + try { + const pets = await Pet.find({ status: "alive" }); + + for (let pet of pets) { + const updatedPet = applyPetDecay(pet); + await updatedPet.save(); + } + + console.log(`[${new Date().toISOString()}] Pet updates complete.`); + + } catch (err) { + console.error("Error running pet hourly update:", err); + } +}); \ No newline at end of file From 1470d70286ff1e8ded104c22b32b0702b7d67ade Mon Sep 17 00:00:00 2001 From: Juan Salvador Zorrilla Date: Sun, 24 Aug 2025 22:12:11 +0200 Subject: [PATCH 022/109] import cron job to server.js --- backend/server.js | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/server.js b/backend/server.js index 96bfd34baf..f19619564b 100644 --- a/backend/server.js +++ b/backend/server.js @@ -5,6 +5,7 @@ import dotenv from "dotenv"; import connectDB from "./config/db.js" import authRoutes from "./routes/authRoutes.js"; import petRoutes from "./routes/petRoutes.js"; +import "./jobs/petCron.js"; dotenv.config(); connectDB(); From 4f7216bc5601acf146b4516f4031e77be1f25e46 Mon Sep 17 00:00:00 2001 From: Juan Salvador Zorrilla Date: Sun, 24 Aug 2025 23:26:12 +0200 Subject: [PATCH 023/109] correct useItem controller so that items only consumed if they have an effect --- backend/controllers/petController.js | 71 ++++++++++++++++++++++++---- 1 file changed, 61 insertions(+), 10 deletions(-) diff --git a/backend/controllers/petController.js b/backend/controllers/petController.js index 6ba0683406..661c0f47bc 100644 --- a/backend/controllers/petController.js +++ b/backend/controllers/petController.js @@ -176,27 +176,78 @@ export const useItem = async (req, res) => { } // Find item in inventory - const inventoryItem = pet.inventory.find((item) => item.itemName === itemName && item.quantity > 0); + const inventoryItem = pet.inventory.find( + (item) => item.itemName === itemName && item.quantity > 0 + ); if (!inventoryItem) { return res.status(404).json({ message: "Item not found in inventory" }); } - // get item details from Item collection + // Fetch item details from Item collection in DB const storeItem = await Item.findOne({ name: itemName }); if (!storeItem) { return res.status(404).json({ message: "Item definition not found" }); } - // apply effect - const stat = storeItem.stat; - const effect = storeItem.effect; + // Apply all stat effects + let effectApplied = false; + + storeItem.effects?.forEach(({ stat, amount }) => { + if (["hunger", "happiness", "health"].includes(stat)) { + const before = pet[stat]; + pet[stat] = Math.min(5, Math.max(0, pet[stat] + amount)); // cap 0-5 + if (pet[stat] !== before) effectApplied = true; + } else if (["coins", "xp"].includes(stat)) { + const before = pet[stat]; + pet[stat] = Math.max(0, pet[stat] + amount); // no cap, but no negative values + if (pet[stat] !== before) effectApplied = true; + } + }); - pet[stat] = Math.min(5, pet[stat] + effect); // cap at 5 bars + // Apply all condition effects + let conditionApplied = false; - // decrease quantity in inventory - inventoryItem.quantity -= 1; - if (inventoryItem.quantity <= 0) { - pet.inventory = pet.inventory.filter((i) => i.itemName !== itemName); + storeItem.conditions?.forEach(({ condition, setTo }) => { + if (pet.conditions && condition in pet.conditions) { + const before = pet.conditions[condition]; + pet.conditions[condition] = setTo; + if (before !== setTo) conditionApplied = true; + } + }); + + // Apply power-up + let powerupApplied = false; + + if (storeItem.powerup?.type && storeItem.powerup?.duration) { + + // check if powerup is already active + const alreadyActive = pet.activePowerups.find( + (p) => + p.type === storeItem.powerup.type && + new Date(p.expiresAt).getTime() > Date.now() + ); + + if (!alreadyActive) { + pet.activePowerups.push({ + type: storeItem.powerup.type, + expiresAt: new Date(Date.now() + storeItem.powerup.duration), + }); + powerupApplied = true; + } else { + return res.status(400).json( + { message: `${storeItem.powerup.type} is already active.` } + ); + } + } + + // Only decrement inventory if something happened + if (effectApplied || conditionApplied || powerupApplied) { + inventoryItem.quantity -= 1; + if (inventoryItem.quantity <= 0) { + pet.inventory = pet.inventory.filter((i) => i.itemName !== itemName); + } + } else { + return res.status(400).json({ message: `${itemName} had no effect.` }); } await pet.save(); From 15d056dd157d6adfa99eb2970783346b232e5147 Mon Sep 17 00:00:00 2001 From: Juan Salvador Zorrilla Date: Sun, 24 Aug 2025 23:48:09 +0200 Subject: [PATCH 024/109] modify pet model to remove sicknessTime and PoopTime timestamps, as well as set coin limits --- backend/models/pet.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/models/pet.js b/backend/models/pet.js index beb0c1065a..5b31059b67 100644 --- a/backend/models/pet.js +++ b/backend/models/pet.js @@ -32,6 +32,8 @@ const petSchema = new mongoose.Schema( coins: { type: Number, default: 0, + min: 0, + max: 998, }, experience: { current: { @@ -72,8 +74,6 @@ const petSchema = new mongoose.Schema( conditions: { isPooped: { type: Boolean, default: false }, isSick: { type: Boolean, default: false }, - poopTime: { type: Date }, - sicknessTime: { type: Date }, }, activePowerups: [ { From 4a44528c60203b32166c80aab3e8f9d53e07cf6e Mon Sep 17 00:00:00 2001 From: Juan Salvador Zorrilla Date: Sun, 24 Aug 2025 23:52:23 +0200 Subject: [PATCH 025/109] add nextPoopTime and nextSicknessTime timestamps to pet model to allow scheduling randomized events --- backend/models/pet.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/models/pet.js b/backend/models/pet.js index 5b31059b67..9f8c6466f4 100644 --- a/backend/models/pet.js +++ b/backend/models/pet.js @@ -74,6 +74,8 @@ const petSchema = new mongoose.Schema( conditions: { isPooped: { type: Boolean, default: false }, isSick: { type: Boolean, default: false }, + nextPoopTime: { type: Date, default: () => new Date(Date.now() + Math.random() * 24 * 60 * 60 * 1000) }, + nextSicknessTime: { type: Date, default: () => new Date(Date.now() + Math.random() * 14 * 24 * 60 * 60 * 1000) }, }, activePowerups: [ { From 61465083754054a02a92d4509b82173a3f50ed66 Mon Sep 17 00:00:00 2001 From: Juan Salvador Zorrilla Date: Mon, 25 Aug 2025 00:43:53 +0200 Subject: [PATCH 026/109] add poop and sickness randomization logic to applyPetDecay controller --- backend/utils/petUtils.js | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/backend/utils/petUtils.js b/backend/utils/petUtils.js index f0aab17e33..045ae9e5f5 100644 --- a/backend/utils/petUtils.js +++ b/backend/utils/petUtils.js @@ -6,16 +6,42 @@ export const applyPetDecay = (pet) => { const now = new Date(); const MS_PER_HOUR = 1000 * 60 * 60; + // Poop logic + const POOP_INTERVAL = 24 * 60 * 60 * 1000; // 24 hours + const MIN_TIME_AFTER_LAST_POOP = 8 * 60 * 60 * 1000; // 8 hours + + if (!pet.conditions.isPooped && pet.conditions.nextPoopTime <= now) { + pet.conditions.isPooped = true; + + // Schedule next poop + pet.conditions.nextPoopTime = new Date( + now.getTime() + MIN_TIME_AFTER_LAST_POOP + Math.random() * (POOP_INTERVAL - MIN_TIME_AFTER_LAST_POOP) + ); + } + + // Sickness logic + const SICKNESS_INTERVAL = 14 * 24 * 60 * 60 * 1000; // 14 days + const MIN_TIME_AFTER_LAST_SICKNESS = 2 * 24 * 60 * 60 * 1000; // 2 days + + if (!pet.conditions.isSick && pet.conditions.nextSicknessTime <= now) { + pet.conditions.isSick = true; + + // Schedule next sickness + pet.conditions.nextSicknessTime = new Date( + now.getTime() + MIN_TIME_AFTER_LAST_SICKNESS + Math.random() * (SICKNESS_INTERVAL - MIN_TIME_AFTER_LAST_SICKNESS) + ); + } + // Remove expired power-ups (lazy evaluation) if (pet.activePowerups?.length) { pet.activePowerups = pet.activePowerups.filter( - (p) => p.expiresAt > now + (p) => new Date(p.expiresAt).getTime() > now ); } // If statFreeze is active, reset all stat timers to now and skip decay const statFreeze = pet.activePowerups?.find( - (p) => p.type === "statFreeze" && p.expiresAt > now + (p) => p.type === "statFreeze" && new Date(p.expiresAt).getTime() > now ); if (statFreeze) { From f6d43d3450e41ef84c520e1834668b5f46227abe Mon Sep 17 00:00:00 2001 From: Juan Salvador Zorrilla Date: Mon, 25 Aug 2025 10:39:02 +0200 Subject: [PATCH 027/109] add useItem route to petRoutes.js --- backend/routes/petRoutes.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/routes/petRoutes.js b/backend/routes/petRoutes.js index 59b77d9659..039fda44fa 100644 --- a/backend/routes/petRoutes.js +++ b/backend/routes/petRoutes.js @@ -19,7 +19,7 @@ router.post("/", authenticateUser, createPet); router.get("/", authenticateUser, getPet); // Gameplay -// router.patch("/use-item", authenticateUser, useItem); +router.patch("/use-item", authenticateUser, useItem); // Progression // router.patch("/xp", authenticateUser, addXP); From 78aa188e1248e8d1013d69812ae64b2fb2de9541 Mon Sep 17 00:00:00 2001 From: Juan Salvador Zorrilla Date: Mon, 25 Aug 2025 11:27:41 +0200 Subject: [PATCH 028/109] create addCoins, addXP and getLeaderboardcontrollers in petControllers.js --- backend/controllers/petController.js | 125 ++++++++++++++++++++++++++- 1 file changed, 124 insertions(+), 1 deletion(-) diff --git a/backend/controllers/petController.js b/backend/controllers/petController.js index 661c0f47bc..15ddf9134d 100644 --- a/backend/controllers/petController.js +++ b/backend/controllers/petController.js @@ -1,5 +1,6 @@ import { Pet } from "../models/pet.js"; import { Item } from "../models/item.js" +import { User } from "../models/user.js" import { applyPetDecay } from "../utils/petUtils.js"; // ================= PET ===================== @@ -152,7 +153,7 @@ export const removeItem = async (req, res) => { } }; -// ================ PET USE ITEM(S) ==================== +// ================ GAMPEPLAY ==================== // PATCH /api/pet/use-item export const useItem = async (req, res) => { @@ -259,4 +260,126 @@ export const useItem = async (req, res) => { } catch (error) { res.status(500).json({ message: "Failed to use item", error }); } +}; + +// ================ PROGRESSION ====================== + +// PATCH /api/pet/xp +export const addXP = async (req, res) => { + try { + const { amount } = req.body; + if (typeof amount !== "number" || amount <= 0) { + return res.status(400).json({ message: "XP amount must be a positive integer" }); + } + + let pet = await Pet.findOne({ owner: req.user._id }); + if (!pet) return res.status(404).json({ message: "Pet not found" }); + + // Apply decay first + pet = applyPetDecay(pet); + + if (pet.status === "expired") { + return res.status(400).json({ message: "Cannot add XP to an expired pet" }); + } + + // Check for doubleXP powerup + const hasDoubleXP = pet.activePowerups.some( + (p) => p.type === "doubleXP" && new Date(p.expiresAt).getTime() > Date.now() + ); + const finalAmount = hasDoubleXP ? amount * 2 : amount; + + // Add XP and handle level up + pet.experience.current += finalAmount; + + while (pet.experience.current >= pet.experience.required) { + pet.experience.current -= pet.experience.required; + pet.level += 1; + pet.experience.required = Math.floor(pet.experience.required * 1.25); // scale difficulty + } + + await pet.save(); + + res.json({ + message: `Added ${finalAmount} XP`, + pet, + }); + } catch (error) { + res.status(500).json({ message: "Failed to add XP", error }); + } +}; + +// PATCH /api/pet/coins +export const addCoins = async (req, res) => { + try { + const { amount } = req.body; + if (typeof amount !== "number" || amount <= 0) { + return res.status(400).json({ message: "Coin amount must be a positive integer" }); + } + + let pet = await Pet.findOne({ owner: req.user._id }); + if (!pet) return res.status(404).json({ message: "Pet not found" }); + + // Apply decay first + pet = applyPetDecay(pet); + + if (pet.status === "expired") { + return res.status(400).json({ message: "Cannot add coins to an expired pet" }); + } + + // Check for doubleCoins powerup + const hasDoubleCoins = pet.activePowerups.some( + (p) => p.type === "doubleCoins" && new Date(p.expiresAt).getTime() > Date.now() + ); + const finalAmount = hasDoubleCoins ? amount * 2 : amount; + + pet.coins = Math.min(998, pet.coins + finalAmount); + + await pet.save(); + + res.json({ + message: `Added ${finalAmount} coins`, + pet, + }); + } catch (error) { + res.status(500).json({ message: "Failed to add coins", error }); + } +}; + +// ==================== LEADERBOARD ======================= + +// GET /api/pet/leaderboard +export const getLeaderboard = async (req, res) => { + try { + // Fetch the current user + const user = await User.findById(req.user._id); + if (!user) { + return res.status(404).json({ message: "User not found" }); + } + + // Get all user IDs in the same class + const classUserIds = await User.find({ classroomCode: user.classroomCode }).distinct("_id"); + + // Fetch pets only from those users + let pets = await Pet.find({ status: "alive", owner: { $in: classUserIds } }) + .populate({ + path: "owner", + select: "initials classroomCode", + }) + .sort({ level: -1, "experience.current": -1, coins: -1 }) // ranking order priority: first by lvl, then xp, then coins + .limit(20); + + // Add rank to each pet + pets = pets.map((pet, index) => ({ + rank: index + 1, + ...pet.toObject(), + })); + + res.json({ + message: "Leaderboard retrieved", + classroomCode: user.classroomCode, + leaderboard: pets, + }); + } catch (error) { + res.status(500).json({ message: "Failed to fetch leaderboard", error }); + } }; \ No newline at end of file From f5830558c25998b30584cd858df1c6ecab1c23fc Mon Sep 17 00:00:00 2001 From: Juan Salvador Zorrilla Date: Mon, 25 Aug 2025 11:28:33 +0200 Subject: [PATCH 029/109] add routes for addXP, addCoins and getLeaderboard to petRoutes.js --- backend/routes/petRoutes.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/routes/petRoutes.js b/backend/routes/petRoutes.js index 039fda44fa..23d251dc26 100644 --- a/backend/routes/petRoutes.js +++ b/backend/routes/petRoutes.js @@ -22,8 +22,8 @@ router.get("/", authenticateUser, getPet); router.patch("/use-item", authenticateUser, useItem); // Progression -// router.patch("/xp", authenticateUser, addXP); -// router.patch("/coins", authenticateUser, addCoins); +router.patch("/xp", authenticateUser, addXP); +router.patch("/coins", authenticateUser, addCoins); // Inventory router.get("/inventory", authenticateUser, getInventory); @@ -31,6 +31,6 @@ router.patch("/inventory/add", authenticateUser, addItem); router.patch("/inventory/remove", authenticateUser, removeItem); // Leaderboard -// router.get("/leaderboard", authenticateUser, getLeaderboard); +router.get("/leaderboard", authenticateUser, getLeaderboard); export default router; \ No newline at end of file From 42102d74bfb3b0f421f346e8fb5570c138b85c07 Mon Sep 17 00:00:00 2001 From: Juan Salvador Zorrilla Date: Mon, 25 Aug 2025 11:42:48 +0200 Subject: [PATCH 030/109] add seedItems.js to seed DB with items --- backend/seeds/seedItems.js | 132 +++++++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 backend/seeds/seedItems.js diff --git a/backend/seeds/seedItems.js b/backend/seeds/seedItems.js new file mode 100644 index 0000000000..e18a7e3c84 --- /dev/null +++ b/backend/seeds/seedItems.js @@ -0,0 +1,132 @@ +// seeds/seedItems.js +import dotenv from "dotenv"; +import connectDB from "../config/db.js"; +import Item from "../models/item.js"; + +dotenv.config(); + +const items = [ + { + name: "Peanuts", + category: "food", + effects: [{ stat: "hunger", amount: 1 }], + price: 10, + description: "A handful of peanuts. Yum!" + }, + { + name: "Blueberries", + category: "food", + effects: [ + { stat: "hunger", amount: 1 }, + { stat: "health", amount: 1 }, + ], + price: 18, + description: "Your pet's favorite berry - healthy and delicious!" + }, + { + name: "Sushi", + category: "food", + effects: [{ stat: "hunger", amount: 2 }], + price: 20, + description: "A filling portion of freshly made maki. Heavenly!" + }, + { + name: "Burrito", + category: "food", + effects: [ + { stat: "hunger", amount: 2 }, + { stat: "happiness", amount: 1 }, + ], + price: 35, + description: "A hearty burrito to keep your pet full and happy. How nice!" + }, + { + name: "Yo-yo", + category: "toy", + effects: [{ stat: "happiness", amount: 1 }], + price: 10, + description: "A cool yo-yo to keep your pet busy. Neat!" + }, + { + name: "Ball", + category: "toy", + effects: [{ stat: "happiness", amount: 2 }], + price: 18, + description: "A bouncy ball to cheer up your pet. Hooray!" + }, + { + name: "Bandage", + category: "medicine", + effects: [{ stat: "health", amount: 1 }], + price: 10, + description: "A sturdy bandage to heal your pet's wounds. How nifty!" + }, + { + name: "Soap", + category: "medicine", + conditions: [ + { condition: "isPooped", setTo: false } + ], + price: 3, + description: "A bar of soap to clean up after your pet. Mess be gone!" + }, + { + name: "Mugwort", + category: "medicine", + effects: [{ stat: "health", amount: 2 }], + price: 18, + description: "A bitter herb to boost pet health. Open sesame!" + }, + { + name: "Medicine", + category: "medicine", + conditions: [ + { condition: "isSick", setTo: false } + ], + price: 30, + description: "A medicinal powder that cures disease. Hallelujah! " + }, + { + name: "Double XP", + category: "powerup", + powerup: { type: "doubleXP", duration: 30 * 60 * 1000 }, // 30 min + price: 150, + description: "Earn double XP for 30 minutes." + }, + { + name: "Double Coins", + category: "powerup", + powerup: { type: "doubleCoins", duration: 30 * 60 * 1000 }, // 30 min + price: 200, + description: "Earn double coins for 30 minutes." + }, + { + name: "Stat Freeze", + category: "powerup", + powerup: { type: "statFreeze", duration: 24 * 60 * 60 * 1000 }, // 24 hrs + price: 75, + description: "Freeze all pet stats for 24 hours." + }, +]; + +// Seeding function +const seedItems = async () => { + try { + await connectDB(); + + // Clear out old items before seeding + await Item.deleteMany({}); + console.log("Existing items deleted"); + + // Insert new items + await Item.insertMany(items); + console.log("Items seeded successfully"); + + process.exit(); + } catch (error) { + console.error("Error seeding items:", error); + process.exit(1); + } +}; + +seedItems(); From fcb371e524de041927681326b4e78e35a8014a72 Mon Sep 17 00:00:00 2001 From: Juan Salvador Zorrilla Date: Mon, 25 Aug 2025 12:00:45 +0200 Subject: [PATCH 031/109] tweak user model for better performance --- backend/models/user.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/models/user.js b/backend/models/user.js index 53acad34bf..cad086a3a8 100644 --- a/backend/models/user.js +++ b/backend/models/user.js @@ -8,12 +8,14 @@ const userSchema = new mongoose.Schema( minlength: 2, maxlength: 6, trim: true, + set: (value) => value.toUpperCase(), }, email: { type: String, required: [true, "Email is required"], unique: true, lowercase: true, + trim: true, match: [ /^[a-zA-Z0-9._%+-]+@osloskolen\.no$/, "Email must end with @osloskolen.no", @@ -27,6 +29,8 @@ const userSchema = new mongoose.Schema( classroomCode: { type: String, required: [true, "Classroom code is required"], + trim: true, + set: (value) => value.toUpperCase(), match: [ /^[A-Z]{3}-\d{4}$/, "Classroom code must be in format XXX-0000", From 6d3a89a4485db18262e057005a64ece65995a2ec Mon Sep 17 00:00:00 2001 From: Juan Salvador Zorrilla Date: Mon, 25 Aug 2025 12:12:01 +0200 Subject: [PATCH 032/109] add node-cron to dependencies, change stats for effects in items.js model --- backend/jobs/petCron.js | 2 +- backend/models/item.js | 43 +++++++++++++++++++++++++++++++++-------- backend/models/pet.js | 4 ++-- backend/package.json | 3 ++- 4 files changed, 40 insertions(+), 12 deletions(-) diff --git a/backend/jobs/petCron.js b/backend/jobs/petCron.js index 03aa6571e6..eb3b5c9a38 100644 --- a/backend/jobs/petCron.js +++ b/backend/jobs/petCron.js @@ -18,7 +18,7 @@ cron.schedule("0 * * * *", async () => { try { const pets = await Pet.find({ status: "alive" }); - for (let pet of pets) { + for (let pet of pets) { // potentially batch updates for performance in the future if need be (e.g. do 50 pets per batch) const updatedPet = applyPetDecay(pet); await updatedPet.save(); } diff --git a/backend/models/item.js b/backend/models/item.js index ea710cc8c4..4c89b7ba9b 100644 --- a/backend/models/item.js +++ b/backend/models/item.js @@ -11,18 +11,45 @@ const itemSchema = new mongoose.Schema({ enum: ["food", "toy", "medicine", "powerup", "misc"], required: true, }, - stat: { - type: String, - enum: ["hunger", "happiness", "health", "coins", "xp", null], - required: false, // not all items will necessarily affect stats + effects: { + type: [ + { + stat: { + type: String, + enum: ["hunger", "happiness", "health", "coins", "xp"], + }, + amount: Number, + }, + ], + default: [], }, - effect: { - type: Number, - default: 1, // how much it changes the stat + conditions: { + type: [ + { + condition: { + type: String, + enum: ["isSick", "isPooped"], // add more conditions later if need be + required: true, + }, + setTo: { + type: Boolean, + required: true, + }, + }, + ], + default: [], + }, + powerup: { + type: { + type: String, + enum: ["statFreeze", "doubleCoins", "doubleXP"], // add more later if need be + }, + duration: Number, // in ms, e.g. 30 * 60 * 1000 (that equals 30 mins) + default: null // items that aren't powerups don't need a powerup field }, price: { type: Number, - default: 0, // store cost (in coins) + default: 0, // price in coins }, description: { type: String, diff --git a/backend/models/pet.js b/backend/models/pet.js index 9f8c6466f4..f342b43924 100644 --- a/backend/models/pet.js +++ b/backend/models/pet.js @@ -74,8 +74,8 @@ const petSchema = new mongoose.Schema( conditions: { isPooped: { type: Boolean, default: false }, isSick: { type: Boolean, default: false }, - nextPoopTime: { type: Date, default: () => new Date(Date.now() + Math.random() * 24 * 60 * 60 * 1000) }, - nextSicknessTime: { type: Date, default: () => new Date(Date.now() + Math.random() * 14 * 24 * 60 * 60 * 1000) }, + nextPoopTime: { type: Date, default: () => new Date(Date.now() + Math.random() * 24 * 60 * 60 * 1000) }, // once every 24hrs + nextSicknessTime: { type: Date, default: () => new Date(Date.now() + Math.random() * 14 * 24 * 60 * 60 * 1000) }, // once every 14 days }, activePowerups: [ { diff --git a/backend/package.json b/backend/package.json index 16bff89283..f61874fe1f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -15,7 +15,8 @@ "express": "^4.17.3", "express-list-endpoints": "^7.1.1", "jsonwebtoken": "^9.0.2", - "mongoose": "^8.4.0" + "mongoose": "^8.4.0", + "node-cron": "^4.2.1" }, "devDependencies": { "@babel/core": "^7.28.3", From f4aff012f30653f464964e9e9469fd580e2a1de2 Mon Sep 17 00:00:00 2001 From: Juan Salvador Zorrilla Date: Mon, 25 Aug 2025 14:40:26 +0200 Subject: [PATCH 033/109] added type: module to package.json --- backend/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/package.json b/backend/package.json index f61874fe1f..6630456524 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,7 @@ { "name": "project-final-backend", "version": "1.0.0", + "type": "module", "description": "Server part of final project", "scripts": { "start": "NODE_ENV=production babel-node server.js", From 1fee6e61ee4c9395873b0cd6a8969ba5933c8466 Mon Sep 17 00:00:00 2001 From: Juan Salvador Zorrilla Date: Mon, 25 Aug 2025 14:45:22 +0200 Subject: [PATCH 034/109] correct syntax error in importing Item to seedItems.js, and make powerup.default be an empty array in item.js --- backend/models/item.js | 2 +- backend/seeds/seedItems.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/models/item.js b/backend/models/item.js index 4c89b7ba9b..40b198a34b 100644 --- a/backend/models/item.js +++ b/backend/models/item.js @@ -45,7 +45,7 @@ const itemSchema = new mongoose.Schema({ enum: ["statFreeze", "doubleCoins", "doubleXP"], // add more later if need be }, duration: Number, // in ms, e.g. 30 * 60 * 1000 (that equals 30 mins) - default: null // items that aren't powerups don't need a powerup field + default: [] }, price: { type: Number, diff --git a/backend/seeds/seedItems.js b/backend/seeds/seedItems.js index e18a7e3c84..e71de7a887 100644 --- a/backend/seeds/seedItems.js +++ b/backend/seeds/seedItems.js @@ -1,7 +1,7 @@ // seeds/seedItems.js import dotenv from "dotenv"; import connectDB from "../config/db.js"; -import Item from "../models/item.js"; +import { Item } from "../models/item.js"; dotenv.config(); From 358d2f2346e5eeb33056c72cc25ea2f5a6c5ee7d Mon Sep 17 00:00:00 2001 From: Juan Salvador Zorrilla Date: Mon, 25 Aug 2025 15:03:15 +0200 Subject: [PATCH 035/109] change powerup.default to equal an empty object since powerup is an object, not an array --- backend/models/item.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/models/item.js b/backend/models/item.js index 40b198a34b..be4d739a2f 100644 --- a/backend/models/item.js +++ b/backend/models/item.js @@ -45,7 +45,7 @@ const itemSchema = new mongoose.Schema({ enum: ["statFreeze", "doubleCoins", "doubleXP"], // add more later if need be }, duration: Number, // in ms, e.g. 30 * 60 * 1000 (that equals 30 mins) - default: [] + default: {} }, price: { type: Number, From 5b38ef907a0f4c7a4b0b0aa805f74abad9416585 Mon Sep 17 00:00:00 2001 From: Juan Salvador Zorrilla Date: Mon, 25 Aug 2025 15:53:22 +0200 Subject: [PATCH 036/109] tweak sickness and poop logic so that times get rescheduled even if still pooped or sick --- backend/server.js | 1 + backend/utils/petUtils.js | 30 +++++++++++++++++++++--------- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/backend/server.js b/backend/server.js index f19619564b..b60b95f7ca 100644 --- a/backend/server.js +++ b/backend/server.js @@ -32,6 +32,7 @@ app.get("/", (req, res) => { app.use("/api/auth", authRoutes); +// Endpoint for creating a pet. // Endpoint for fetching pet. // Endpoint for fetching pet inventory. // Endpoint for adding item to inventory. diff --git a/backend/utils/petUtils.js b/backend/utils/petUtils.js index 045ae9e5f5..ccc929ce4f 100644 --- a/backend/utils/petUtils.js +++ b/backend/utils/petUtils.js @@ -10,10 +10,15 @@ export const applyPetDecay = (pet) => { const POOP_INTERVAL = 24 * 60 * 60 * 1000; // 24 hours const MIN_TIME_AFTER_LAST_POOP = 8 * 60 * 60 * 1000; // 8 hours - if (!pet.conditions.isPooped && pet.conditions.nextPoopTime <= now) { - pet.conditions.isPooped = true; - - // Schedule next poop + // Always reschedule next poop if the scheduled time has passed + if (pet.conditions.nextPoopTime <= now) { + + // Only mark as pooped if not already + if (!pet.conditions.isPooped) { + pet.conditions.isPooped = true; + } + + // Schedule the next poop from now pet.conditions.nextPoopTime = new Date( now.getTime() + MIN_TIME_AFTER_LAST_POOP + Math.random() * (POOP_INTERVAL - MIN_TIME_AFTER_LAST_POOP) ); @@ -23,12 +28,19 @@ export const applyPetDecay = (pet) => { const SICKNESS_INTERVAL = 14 * 24 * 60 * 60 * 1000; // 14 days const MIN_TIME_AFTER_LAST_SICKNESS = 2 * 24 * 60 * 60 * 1000; // 2 days - if (!pet.conditions.isSick && pet.conditions.nextSicknessTime <= now) { - pet.conditions.isSick = true; - - // Schedule next sickness + // Always reschedule next sickness if the scheduled time has passed + if (pet.conditions.nextSicknessTime <= now) { + + // Only mark as sick if not already + if (!pet.conditions.isSick) { + pet.conditions.isSick = true; + } + + // Schedule the next sickness from now pet.conditions.nextSicknessTime = new Date( - now.getTime() + MIN_TIME_AFTER_LAST_SICKNESS + Math.random() * (SICKNESS_INTERVAL - MIN_TIME_AFTER_LAST_SICKNESS) + now.getTime() + + MIN_TIME_AFTER_LAST_SICKNESS + + Math.random() * (SICKNESS_INTERVAL - MIN_TIME_AFTER_LAST_SICKNESS) ); } From a2f64752580586d2742128a5679ff730ab1dd45d Mon Sep 17 00:00:00 2001 From: Juan Salvador Zorrilla Date: Mon, 25 Aug 2025 16:35:09 +0200 Subject: [PATCH 037/109] tweaked item price in seedItems.js --- backend/seeds/seedItems.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/seeds/seedItems.js b/backend/seeds/seedItems.js index e71de7a887..13fb5373ff 100644 --- a/backend/seeds/seedItems.js +++ b/backend/seeds/seedItems.js @@ -83,7 +83,7 @@ const items = [ conditions: [ { condition: "isSick", setTo: false } ], - price: 30, + price: 40, description: "A medicinal powder that cures disease. Hallelujah! " }, { From 8b2028588bb1aad0da3b3d08b849ae2808054158 Mon Sep 17 00:00:00 2001 From: Juan Salvador Zorrilla Date: Mon, 25 Aug 2025 19:10:37 +0200 Subject: [PATCH 038/109] add store routes to petRoutes.js --- backend/routes/petRoutes.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/backend/routes/petRoutes.js b/backend/routes/petRoutes.js index 23d251dc26..2f3a23312f 100644 --- a/backend/routes/petRoutes.js +++ b/backend/routes/petRoutes.js @@ -30,6 +30,11 @@ router.get("/inventory", authenticateUser, getInventory); router.patch("/inventory/add", authenticateUser, addItem); router.patch("/inventory/remove", authenticateUser, removeItem); +// Store +router.get("/store", authenticateUser, getStoreItems); +router.get("/store/:id", authenticateUser, getStoreItemById); +router.post("/store/buy", authenticateUser, buyItem); + // Leaderboard router.get("/leaderboard", authenticateUser, getLeaderboard); From df6e3362e37f7932a8026165a67b968dc5357143 Mon Sep 17 00:00:00 2001 From: Juan Salvador Zorrilla Date: Mon, 25 Aug 2025 21:50:05 +0200 Subject: [PATCH 039/109] create storeRoutes.js and storeController.js to house store related logic --- backend/controllers/storeController.js | 77 ++++++++++++++++++++++++++ backend/routes/petRoutes.js | 5 -- backend/routes/storeRoutes.js | 12 ++++ 3 files changed, 89 insertions(+), 5 deletions(-) create mode 100644 backend/controllers/storeController.js create mode 100644 backend/routes/storeRoutes.js diff --git a/backend/controllers/storeController.js b/backend/controllers/storeController.js new file mode 100644 index 0000000000..68729602f1 --- /dev/null +++ b/backend/controllers/storeController.js @@ -0,0 +1,77 @@ +import { Pet } from "../models/pet.js"; +import { Item } from "../models/item.js" + +// GET all items from store +export const getStoreItems = async (req, res) => { + try { + const items = await Item.find(); + res.status(200).json(items); + } catch (error) { + res.status(500).json({ message: "Error fetching store items", error }); + } +}; + +// GET fetch a single item by ID +export const getStoreItemById = async (req, res) => { + try { + const { id } = req.params; + const item = await Item.findById(id); + + if (!item) { + return res.status(404).json({ message: "Item not found" }); + } + + res.status(200).json(item); + } catch (error) { + res.status(500).json({ message: "Error fetching store item", error }); + } +}; + +// POST buy item from store +export const buyItem = async (req, res) => { + try { + const { itemId } = req.body; + const userId = req.user._id; // comes from authenticateUser middleware + + const pet = await Pet.findOne({ owner: userId }); + if (!pet) { + return res.status(404).json({ message: "Pet not found" }); + } + + const item = await Item.findById(itemId); + if (!item) { + return res.status(404).json({ message: "Item not found" }); + } + + if (pet.coins < item.price) { + return res.status(400).json({ message: "Not enough coins" }); + } + + // Deduct coins + pet.coins -= item.price; + + // Check if pet already has this item in inventory + const existingItem = pet.inventory.find( + (invItem) => invItem.itemName === item.name + ); + + if (existingItem) { + existingItem.quantity += 1; + } else { + pet.inventory.push({ + itemName: item.name, + category: item.category, + quantity: 1, + }); + } + + await pet.save(); + + res.json({ + message: `Successfully bought ${item.name}`, + pet, + }); + } catch (error) { + res.status(500).json({ message: "Error buying item", error }); + } +}; diff --git a/backend/routes/petRoutes.js b/backend/routes/petRoutes.js index 2f3a23312f..23d251dc26 100644 --- a/backend/routes/petRoutes.js +++ b/backend/routes/petRoutes.js @@ -30,11 +30,6 @@ router.get("/inventory", authenticateUser, getInventory); router.patch("/inventory/add", authenticateUser, addItem); router.patch("/inventory/remove", authenticateUser, removeItem); -// Store -router.get("/store", authenticateUser, getStoreItems); -router.get("/store/:id", authenticateUser, getStoreItemById); -router.post("/store/buy", authenticateUser, buyItem); - // Leaderboard router.get("/leaderboard", authenticateUser, getLeaderboard); diff --git a/backend/routes/storeRoutes.js b/backend/routes/storeRoutes.js new file mode 100644 index 0000000000..c17a361f7a --- /dev/null +++ b/backend/routes/storeRoutes.js @@ -0,0 +1,12 @@ +import express from "express"; +import { authenticateUser } from "../middleware/authMiddleware.js"; +import { getStoreItems, getStoreItemById, buyItem } from "../controllers/storeController.js" + +const router = express.Router(); + +// Store +router.get("/store", authenticateUser, getStoreItems); +router.get("/store/:id", authenticateUser, getStoreItemById); +router.post("/store/buy", authenticateUser, buyItem); + +export default router; \ No newline at end of file From 62d707a7e14e96ff82f73d17b103795fc9f85abd Mon Sep 17 00:00:00 2001 From: Juan Salvador Zorrilla Date: Mon, 25 Aug 2025 21:55:45 +0200 Subject: [PATCH 040/109] import storeRoutes to server.js and tweak path names on storeRoutes.js --- backend/routes/storeRoutes.js | 6 +++--- backend/server.js | 7 +++++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/backend/routes/storeRoutes.js b/backend/routes/storeRoutes.js index c17a361f7a..a450868854 100644 --- a/backend/routes/storeRoutes.js +++ b/backend/routes/storeRoutes.js @@ -5,8 +5,8 @@ import { getStoreItems, getStoreItemById, buyItem } from "../controllers/storeCo const router = express.Router(); // Store -router.get("/store", authenticateUser, getStoreItems); -router.get("/store/:id", authenticateUser, getStoreItemById); -router.post("/store/buy", authenticateUser, buyItem); +router.get("/", authenticateUser, getStoreItems); +router.get("/:id", authenticateUser, getStoreItemById); +router.post("/buy", authenticateUser, buyItem); export default router; \ No newline at end of file diff --git a/backend/server.js b/backend/server.js index b60b95f7ca..11dc29a3f4 100644 --- a/backend/server.js +++ b/backend/server.js @@ -5,6 +5,7 @@ import dotenv from "dotenv"; import connectDB from "./config/db.js" import authRoutes from "./routes/authRoutes.js"; import petRoutes from "./routes/petRoutes.js"; +import storeRoutes from "./routes/storeRoutes.js"; import "./jobs/petCron.js"; dotenv.config(); @@ -44,6 +45,12 @@ app.use("/api/auth", authRoutes); app.use("/api/pet", petRoutes); +// Endpoint for getting all store items +// Endpoint for getting single store item by id +// Endpoint for buying item from store + +app.use("/api/store", storeRoutes); + // Start the server app.listen(port, () => { console.log(`Server running on http://localhost:${port}`); From ecaaeb92cc5eb7b497d427e05bcc3cc9fb88041b Mon Sep 17 00:00:00 2001 From: Juan Salvador Zorrilla Date: Tue, 26 Aug 2025 12:12:45 +0200 Subject: [PATCH 041/109] add exercise model, exercise controller, exercise routes and extract coin/xp award logic to petServices from petController --- backend/controllers/exerciseController.js | 81 +++++++++++++++++++++++ backend/controllers/petController.js | 68 ++++++------------- backend/models/exercise.js | 49 ++++++++++++++ backend/routes/exerciseRoutes.js | 16 +++++ backend/server.js | 9 ++- backend/services/petService.js | 51 ++++++++++++++ 6 files changed, 224 insertions(+), 50 deletions(-) create mode 100644 backend/controllers/exerciseController.js create mode 100644 backend/models/exercise.js create mode 100644 backend/routes/exerciseRoutes.js create mode 100644 backend/services/petService.js diff --git a/backend/controllers/exerciseController.js b/backend/controllers/exerciseController.js new file mode 100644 index 0000000000..ad79ae31fa --- /dev/null +++ b/backend/controllers/exerciseController.js @@ -0,0 +1,81 @@ +import { Exercise } from "../models/exercise.js"; +import { awardXP, awardCoins } from "../services/petService.js"; + +// POST create a new exercise (teacher only) +export const createExercise = async (req, res) => { + try { + const { title, classroomCode, questions, totalCoins, totalXP } = req.body; + + if (!title || !classroomCode || !questions || !totalCoins || !totalXP) { + return res.status(400).json({ message: "Missing required fields" }); + } + + const exercise = new Exercise({ + title, + classroomCode, + questions, + totalCoins, + totalXP, + createdBy: req.user._id, + }); + + await exercise.save(); + res.status(201).json({ message: "Exercise created", exercise }); + } catch (error) { + res.status(500).json({ message: "Failed to create exercise", error }); + } +}; + +// GET exercises for a given classroom +export const getExercisesByClassroom = async (req, res) => { + try { + const { classroomCode } = req.params; // classroomCode is passed along in the URL as params + + const exercises = await Exercise.find({ classroomCode }).select( + "-questions.correctAnswer" // hide correct answers from students + ); + + res.status(200).json(exercises); + } catch (error) { + res.status(500).json({ message: "Error fetching exercises", error }); + } +}; + +// Submit answers and assign coins/xp +export const submitExercise = async (req, res) => { + try { + const { exerciseId } = req.params; // exerciseId comes from params (passed along in URL) + const { answers } = req.body; // answers: [{ questionIndex: 0, answer: 'a' }, ...] + + const exercise = await Exercise.findById(exerciseId); + if (!exercise) { + return res.status(404).json({ message: "Exercise not found" }); + } + + let correctCount = 0; + exercise.questions.forEach((q, idx) => { + if (answers[idx]?.answer === q.correctAnswer) correctCount++; + }); + + const coinsPerQuestion = exercise.totalCoins / exercise.questions.length; + const xpPerQuestion = exercise.totalXP / exercise.questions.length; + + const coinsGained = Math.round(correctCount * coinsPerQuestion); + const xpGained = Math.round(correctCount * xpPerQuestion); + + const coinResult = await awardCoins(req.user._id, coinsGained); + const xpResult = await awardXP(req.user._id, xpGained); + + res.status(200).json({ + message: "Exercise submitted", + correctCount, + totalQuestions: exercise.questions.length, + coinsGained: coinResult.finalAmount, + xpGained: xpResult.finalAmount, + pet: xpResult.pet, // both return the same pet, so xpResult.pet is enough + }); + + } catch (error) { + res.status(500).json({ message: "Failed to submit exercise", error }); + } +}; \ No newline at end of file diff --git a/backend/controllers/petController.js b/backend/controllers/petController.js index 15ddf9134d..2514cc260e 100644 --- a/backend/controllers/petController.js +++ b/backend/controllers/petController.js @@ -2,6 +2,7 @@ import { Pet } from "../models/pet.js"; import { Item } from "../models/item.js" import { User } from "../models/user.js" import { applyPetDecay } from "../utils/petUtils.js"; +import { awardXP, awardCoins } from "../services/petService.js"; // ================= PET ===================== @@ -272,38 +273,19 @@ export const addXP = async (req, res) => { return res.status(400).json({ message: "XP amount must be a positive integer" }); } - let pet = await Pet.findOne({ owner: req.user._id }); - if (!pet) return res.status(404).json({ message: "Pet not found" }); - - // Apply decay first - pet = applyPetDecay(pet); - - if (pet.status === "expired") { - return res.status(400).json({ message: "Cannot add XP to an expired pet" }); - } - - // Check for doubleXP powerup - const hasDoubleXP = pet.activePowerups.some( - (p) => p.type === "doubleXP" && new Date(p.expiresAt).getTime() > Date.now() - ); - const finalAmount = hasDoubleXP ? amount * 2 : amount; - - // Add XP and handle level up - pet.experience.current += finalAmount; - - while (pet.experience.current >= pet.experience.required) { - pet.experience.current -= pet.experience.required; - pet.level += 1; - pet.experience.required = Math.floor(pet.experience.required * 1.25); // scale difficulty - } - - await pet.save(); + const result = await awardXP(req.user._id, amount); res.json({ - message: `Added ${finalAmount} XP`, - pet, + message: `Added ${result.finalAmount} XP`, + pet: result.pet, }); } catch (error) { + if (error.message === "Pet not found") { + return res.status(404).json({ message: error.message }); + } + if (error.message.includes("expired")) { + return res.status(400).json({ message: error.message }); + } res.status(500).json({ message: "Failed to add XP", error }); } }; @@ -316,31 +298,19 @@ export const addCoins = async (req, res) => { return res.status(400).json({ message: "Coin amount must be a positive integer" }); } - let pet = await Pet.findOne({ owner: req.user._id }); - if (!pet) return res.status(404).json({ message: "Pet not found" }); - - // Apply decay first - pet = applyPetDecay(pet); - - if (pet.status === "expired") { - return res.status(400).json({ message: "Cannot add coins to an expired pet" }); - } - - // Check for doubleCoins powerup - const hasDoubleCoins = pet.activePowerups.some( - (p) => p.type === "doubleCoins" && new Date(p.expiresAt).getTime() > Date.now() - ); - const finalAmount = hasDoubleCoins ? amount * 2 : amount; - - pet.coins = Math.min(998, pet.coins + finalAmount); - - await pet.save(); + const result = await awardCoins(req.user._id, amount); res.json({ - message: `Added ${finalAmount} coins`, - pet, + message: `Added ${result.finalAmount} coins`, + pet: result.pet, }); } catch (error) { + if (error.message === "Pet not found") { + return res.status(404).json({ message: error.message }); + } + if (error.message.includes("expired")) { + return res.status(400).json({ message: error.message }); + } res.status(500).json({ message: "Failed to add coins", error }); } }; diff --git a/backend/models/exercise.js b/backend/models/exercise.js new file mode 100644 index 0000000000..e76e9cb1a9 --- /dev/null +++ b/backend/models/exercise.js @@ -0,0 +1,49 @@ +import mongoose from "mongoose"; + +const questionSchema = new mongoose.Schema({ + questionText: { + type: String, + required: true, + }, + options: { + type: [String], // array of 4 options (a, b, c, d) + validate: [arr => arr.length === 4, "Must provide exactly 4 options"], + required: true, + }, + correctAnswer: { + type: String, + enum: ["a", "b", "c", "d"], + required: true, + }, +}); + +const exerciseSchema = new mongoose.Schema( + { + title: { + type: String, + required: true, + }, + classroomCode: { + type: String, + required: true, + match: /^[A-Z]{3}-\d{4}$/, + }, + questions: { + type: [questionSchema], + required: true, + validate: [arr => arr.length > 0, "Must have at least one question"], + }, + totalCoins: { + type: Number, + default: 0 + }, + totalXP: { + type: Number, + default: 0 + }, + createdBy: { type: mongoose.Schema.Types.ObjectId, ref: "User" }, // optional + }, + { timestamps: true } +); + +export const Exercise = mongoose.model("Exercise", exerciseSchema); \ No newline at end of file diff --git a/backend/routes/exerciseRoutes.js b/backend/routes/exerciseRoutes.js new file mode 100644 index 0000000000..d377e5a9cb --- /dev/null +++ b/backend/routes/exerciseRoutes.js @@ -0,0 +1,16 @@ +import express from "express"; +import { authenticateUser } from "../middleware/authMiddleware.js"; +import { createExercise, getExercisesByClassroom, submitExercise } from "../controllers/exerciseController.js"; + +const router = express.Router(); + +// Create exercise (admin/teacher only) +router.post("/", authenticateUser, createExercise); + +// Fetch exercises by class code +router.get("/:classroomCode", authenticateUser, getExercisesByClassroom); + +// Submit exercise +router.post("/:exerciseId/submit", authenticateUser, submitExercise); + +export default router; \ No newline at end of file diff --git a/backend/server.js b/backend/server.js index 11dc29a3f4..19eb081f96 100644 --- a/backend/server.js +++ b/backend/server.js @@ -6,7 +6,8 @@ import connectDB from "./config/db.js" import authRoutes from "./routes/authRoutes.js"; import petRoutes from "./routes/petRoutes.js"; import storeRoutes from "./routes/storeRoutes.js"; -import "./jobs/petCron.js"; +import exerciseRoutes from "./routes/exerciseRoutes.js"; +import "./jobs/petCron.js"; dotenv.config(); connectDB(); @@ -51,6 +52,12 @@ app.use("/api/pet", petRoutes); app.use("/api/store", storeRoutes); +// Endpoint for creating exercise +// Endpoint for fetching exercises by classroomCode +// Endpoint for submitting exercise + +app.use("/api/exercises", exerciseRoutes); + // Start the server app.listen(port, () => { console.log(`Server running on http://localhost:${port}`); diff --git a/backend/services/petService.js b/backend/services/petService.js new file mode 100644 index 0000000000..5ad5cbd242 --- /dev/null +++ b/backend/services/petService.js @@ -0,0 +1,51 @@ +import { Pet } from "../models/pet.js"; +import { applyPetDecay } from "../utils/petUtils.js"; + +// Award XP + +export const awardXP = async (userId, amount) => { + let pet = await Pet.findOne({ owner: userId }); + if (!pet) throw new Error("Pet not found"); + + pet = applyPetDecay(pet); + if (pet.status === "expired") throw new Error("Cannot add XP to expired pet"); + + const hasDoubleXP = pet.activePowerups.some( + (p) => p.type === "doubleXP" && new Date(p.expiresAt).getTime() > Date.now() + ); + + const finalAmount = hasDoubleXP ? amount * 2 : amount; + + pet.experience.current += finalAmount; + + while (pet.experience.current >= pet.experience.required) { + pet.experience.current -= pet.experience.required; + pet.level += 1; + pet.experience.required = Math.floor(pet.experience.required * 1.25); + } + + await pet.save(); + + return { finalAmount, pet }; +}; + +// Award coins + +export const awardCoins = async (userId, amount) => { + let pet = await Pet.findOne({ owner: userId }); + if (!pet) throw new Error("Pet not found"); + + pet = applyPetDecay(pet); + if (pet.status === "expired") throw new Error("Cannot add coins to expired pet"); + + const hasDoubleCoins = pet.activePowerups.some( + (p) => p.type === "doubleCoins" && new Date(p.expiresAt).getTime() > Date.now() + ); + + const finalAmount = hasDoubleCoins ? amount * 2 : amount; + pet.coins = Math.min(998, pet.coins + finalAmount); + + await pet.save(); + + return { finalAmount, pet }; +}; \ No newline at end of file From bf89407019caec2c3235d9812ab02e42f154b3ae Mon Sep 17 00:00:00 2001 From: Juan Salvador Zorrilla Date: Tue, 26 Aug 2025 14:34:33 +0200 Subject: [PATCH 042/109] add seedExercises.js to add exercises to DB --- backend/seeds/seedExercises.js | 74 ++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 backend/seeds/seedExercises.js diff --git a/backend/seeds/seedExercises.js b/backend/seeds/seedExercises.js new file mode 100644 index 0000000000..6b2b2bf2b2 --- /dev/null +++ b/backend/seeds/seedExercises.js @@ -0,0 +1,74 @@ +// seeds/seedExercises.js +import dotenv from "dotenv"; +import connectDB from "../config/db.js"; +import { Exercise } from "../models/exercise.js"; + +dotenv.config(); + +const classroomCode = "ITA-2025"; + +const exercises = [ + // --- -ARE verbs exercise --- + { + title: "Regular Verbs -ARE", + classroomCode, + totalCoins: 25, + totalXP: 25, + questions: [ + { questionText: "Io ___ (parlare) italiano ogni giorno.", options: ["parlo", "parla", "parliamo", "parlate"], correctAnswer: "a" }, + { questionText: "Tu ___ (mangiare) la pizza stasera?", options: ["mangia", "mangio", "mangi", "mangiamo"], correctAnswer: "c" }, + { questionText: "Lui ___ (cantare) una canzone.", options: ["canto", "canta", "cantiamo", "cantate"], correctAnswer: "b" }, + { questionText: "Noi ___ (giocare) a calcio ogni sabato.", options: ["giocano", "giochiamo", "giocate", "giochiamo"], correctAnswer: "b" }, + { questionText: "Voi ___ (studiare) per l'esame domani.", options: ["studiate", "studiamo", "studiano", "studio"], correctAnswer: "a" }, + ], + }, + // --- -ERE verbs exercise --- + { + title: "Regular Verbs -ERE", + classroomCode, + totalCoins: 25, + totalXP: 25, + questions: [ + { questionText: "Io ___ (leggere) molti libri.", options: ["legge", "leggi", "leggo", "leggiamo"], correctAnswer: "c" }, + { questionText: "Tu ___ (prendere) l'autobus ogni giorno?", options: ["prendi", "prende", "prendiamo", "prendo"], correctAnswer: "a" }, + { questionText: "Lei ___ (scrivere) una lettera al professore.", options: ["scrivo", "scrive", "scriviamo", "scrivete"], correctAnswer: "b" }, + { questionText: "Noi ___ (vendere) cani e gatti.", options: ["vendiamo", "vendono", "vendo", "vende"], correctAnswer: "a" }, + { questionText: "Voi ___ (chiedere) scusa sempre?", options: ["chiediamo", "chiedo", "chiedete", "chiedono"], correctAnswer: "c" }, + ], + }, + // --- -IRE verbs exercise --- + { + title: "Regular Verbs -IRE", + classroomCode, + totalCoins: 25, + totalXP: 25, + questions: [ + { questionText: "Io ___ (dormire) da Lorenzo.", options: ["dormo", "dorme", "dormiamo", "dormite"], correctAnswer: "a" }, + { questionText: "Tu ___ (aprire) la finestra?", options: ["apri", "apre", "apriamo", "aprite"], correctAnswer: "a" }, + { questionText: "Lui ___ (partire) domani mattina.", options: ["partono", "partiamo", "parte", "parto"], correctAnswer: "c" }, + { questionText: "Noi ___ (offrire) il caffè a tutti", options: ["offrono", "offro", "offre", "offriamo"], correctAnswer: "d" }, + { questionText: "Voi ___ (sentire) la musica?", options: ["sentiamo", "sentite", "sento", "sente"], correctAnswer: "b" }, + ], + }, +]; + +const seedExercises = async () => { + try { + await connectDB(); + + // Delete existing exercises for this classroom to prevent duplicates + await Exercise.deleteMany({ classroomCode }); + console.log("Existing exercises deleted"); + + // Insert new exercises + await Exercise.insertMany(exercises); + console.log("Exercises seeded successfully"); + + process.exit(); + } catch (error) { + console.error("Error seeding exercises:", error); + process.exit(1); + } +}; + +seedExercises(); \ No newline at end of file From 0fa1db19334aa73e5b253901cfbfd5577d5d742b Mon Sep 17 00:00:00 2001 From: Juan Salvador Zorrilla Date: Tue, 26 Aug 2025 14:56:56 +0200 Subject: [PATCH 043/109] correct error in seedExercises.js, -are verbs, question 4 --- backend/seeds/seedExercises.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/seeds/seedExercises.js b/backend/seeds/seedExercises.js index 6b2b2bf2b2..e6630e090a 100644 --- a/backend/seeds/seedExercises.js +++ b/backend/seeds/seedExercises.js @@ -18,7 +18,7 @@ const exercises = [ { questionText: "Io ___ (parlare) italiano ogni giorno.", options: ["parlo", "parla", "parliamo", "parlate"], correctAnswer: "a" }, { questionText: "Tu ___ (mangiare) la pizza stasera?", options: ["mangia", "mangio", "mangi", "mangiamo"], correctAnswer: "c" }, { questionText: "Lui ___ (cantare) una canzone.", options: ["canto", "canta", "cantiamo", "cantate"], correctAnswer: "b" }, - { questionText: "Noi ___ (giocare) a calcio ogni sabato.", options: ["giocano", "giochiamo", "giocate", "giochiamo"], correctAnswer: "b" }, + { questionText: "Noi ___ (giocare) a calcio ogni sabato.", options: ["giocano", "giochiamo", "giocate", "giochi"], correctAnswer: "b" }, { questionText: "Voi ___ (studiare) per l'esame domani.", options: ["studiate", "studiamo", "studiano", "studio"], correctAnswer: "a" }, ], }, From 715b2b982d3daad991b373259bad95deac9d5c32 Mon Sep 17 00:00:00 2001 From: Juan Salvador Zorrilla Date: Tue, 26 Aug 2025 16:10:36 +0200 Subject: [PATCH 044/109] create login route and add to __root.tsx --- frontend/src/routeTree.gen.ts | 24 +++++++++++++++++++++--- frontend/src/routes/__root.tsx | 29 ++++++++++++++++++----------- frontend/src/routes/login.tsx | 13 +++++++++++++ 3 files changed, 52 insertions(+), 14 deletions(-) create mode 100644 frontend/src/routes/login.tsx diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 59499d9fbf..3638c074f8 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -9,9 +9,15 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' +import { Route as LoginRouteImport } from './routes/login' import { Route as AboutRouteImport } from './routes/about' import { Route as IndexRouteImport } from './routes/index' +const LoginRoute = LoginRouteImport.update({ + id: '/login', + path: '/login', + getParentRoute: () => rootRouteImport, +} as any) const AboutRoute = AboutRouteImport.update({ id: '/about', path: '/about', @@ -26,31 +32,42 @@ const IndexRoute = IndexRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/about': typeof AboutRoute + '/login': typeof LoginRoute } export interface FileRoutesByTo { '/': typeof IndexRoute '/about': typeof AboutRoute + '/login': typeof LoginRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/about': typeof AboutRoute + '/login': typeof LoginRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/about' + fullPaths: '/' | '/about' | '/login' fileRoutesByTo: FileRoutesByTo - to: '/' | '/about' - id: '__root__' | '/' | '/about' + to: '/' | '/about' | '/login' + id: '__root__' | '/' | '/about' | '/login' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute AboutRoute: typeof AboutRoute + LoginRoute: typeof LoginRoute } declare module '@tanstack/react-router' { interface FileRoutesByPath { + '/login': { + id: '/login' + path: '/login' + fullPath: '/login' + preLoaderRoute: typeof LoginRouteImport + parentRoute: typeof rootRouteImport + } '/about': { id: '/about' path: '/about' @@ -71,6 +88,7 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, AboutRoute: AboutRoute, + LoginRoute: LoginRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/frontend/src/routes/__root.tsx b/frontend/src/routes/__root.tsx index fb22c3d324..8ff099aa29 100644 --- a/frontend/src/routes/__root.tsx +++ b/frontend/src/routes/__root.tsx @@ -3,18 +3,25 @@ import { TanStackRouterDevtools } from '@tanstack/react-router-devtools' export const Route = createRootRoute({ component: () => ( - <> -
- - Home - {' '} - - About - -
+
+
- +
+ +
- +
), }) \ No newline at end of file diff --git a/frontend/src/routes/login.tsx b/frontend/src/routes/login.tsx new file mode 100644 index 0000000000..bccc7059aa --- /dev/null +++ b/frontend/src/routes/login.tsx @@ -0,0 +1,13 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Login = () => { + return ( +
+

Log In

+
+ ) +} + +export const Route = createFileRoute('/login')({ + component: Login, +}) \ No newline at end of file From 35b0000d987450ef506c50e98e3e506aa3f0ad1c Mon Sep 17 00:00:00 2001 From: Juan Salvador Zorrilla Date: Tue, 26 Aug 2025 22:54:09 +0200 Subject: [PATCH 045/109] map out structure --- frontend/.gitignore | 3 + frontend/src/components/app/AppHeader.tsx | 0 frontend/src/components/app/LeftPanel.tsx | 0 frontend/src/components/app/Menu.tsx | 0 frontend/src/components/app/RightPanel.tsx | 3 + .../src/components/app/views/Exercises.tsx | 0 .../src/components/app/views/Inventory.tsx | 0 .../src/components/app/views/Leaderboard.tsx | 0 .../src/components/app/views/Settings.tsx | 0 frontend/src/components/app/views/Stats.tsx | 0 frontend/src/components/app/views/Store.tsx | 0 .../src/components/landing/FeatureCard.tsx | 0 frontend/src/components/landing/Features.tsx | 0 .../src/components/landing/LandingPage.tsx | 0 .../src/components/landing/SignupCard.tsx | 0 frontend/src/components/layout/Footer.tsx | 0 frontend/src/components/layout/Header.tsx | 0 frontend/src/components/layout/NavBar.tsx | 0 frontend/src/routeTree.gen.ts | 251 +++++++++++++++++- frontend/src/routes/app/__layout.tsx | 9 + frontend/src/routes/app/exercises.tsx | 9 + frontend/src/routes/app/index.tsx | 9 + frontend/src/routes/app/inventory.tsx | 9 + frontend/src/routes/app/leaderboard.tsx | 9 + frontend/src/routes/app/settings.tsx | 9 + frontend/src/routes/app/stats.tsx | 9 + frontend/src/routes/app/store.tsx | 9 + frontend/src/routes/index.tsx | 2 +- frontend/src/routes/privpolicy.tsx | 9 + frontend/src/routes/tos.tsx | 9 + frontend/src/store/auth.ts | 0 frontend/src/store/pet.ts | 0 32 files changed, 345 insertions(+), 4 deletions(-) create mode 100644 frontend/src/components/app/AppHeader.tsx create mode 100644 frontend/src/components/app/LeftPanel.tsx create mode 100644 frontend/src/components/app/Menu.tsx create mode 100644 frontend/src/components/app/RightPanel.tsx create mode 100644 frontend/src/components/app/views/Exercises.tsx create mode 100644 frontend/src/components/app/views/Inventory.tsx create mode 100644 frontend/src/components/app/views/Leaderboard.tsx create mode 100644 frontend/src/components/app/views/Settings.tsx create mode 100644 frontend/src/components/app/views/Stats.tsx create mode 100644 frontend/src/components/app/views/Store.tsx create mode 100644 frontend/src/components/landing/FeatureCard.tsx create mode 100644 frontend/src/components/landing/Features.tsx create mode 100644 frontend/src/components/landing/LandingPage.tsx create mode 100644 frontend/src/components/landing/SignupCard.tsx create mode 100644 frontend/src/components/layout/Footer.tsx create mode 100644 frontend/src/components/layout/Header.tsx create mode 100644 frontend/src/components/layout/NavBar.tsx create mode 100644 frontend/src/routes/app/__layout.tsx create mode 100644 frontend/src/routes/app/exercises.tsx create mode 100644 frontend/src/routes/app/index.tsx create mode 100644 frontend/src/routes/app/inventory.tsx create mode 100644 frontend/src/routes/app/leaderboard.tsx create mode 100644 frontend/src/routes/app/settings.tsx create mode 100644 frontend/src/routes/app/stats.tsx create mode 100644 frontend/src/routes/app/store.tsx create mode 100644 frontend/src/routes/privpolicy.tsx create mode 100644 frontend/src/routes/tos.tsx create mode 100644 frontend/src/store/auth.ts create mode 100644 frontend/src/store/pet.ts diff --git a/frontend/.gitignore b/frontend/.gitignore index d5b4e45805..287c6c7448 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -8,6 +8,9 @@ build/ # Logs *.log +# Misc +todo.md + # Environment variables .env .env.local diff --git a/frontend/src/components/app/AppHeader.tsx b/frontend/src/components/app/AppHeader.tsx new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/src/components/app/LeftPanel.tsx b/frontend/src/components/app/LeftPanel.tsx new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/src/components/app/Menu.tsx b/frontend/src/components/app/Menu.tsx new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/src/components/app/RightPanel.tsx b/frontend/src/components/app/RightPanel.tsx new file mode 100644 index 0000000000..abeef635ec --- /dev/null +++ b/frontend/src/components/app/RightPanel.tsx @@ -0,0 +1,3 @@ +// This might potentially be redundant since the in __layout.tsx will act as the right panel in my app. +// But if I'm planning on having a wrapper around to add padding or styling then I should keep this. +// Otherwise safe to delete. \ No newline at end of file diff --git a/frontend/src/components/app/views/Exercises.tsx b/frontend/src/components/app/views/Exercises.tsx new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/src/components/app/views/Inventory.tsx b/frontend/src/components/app/views/Inventory.tsx new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/src/components/app/views/Leaderboard.tsx b/frontend/src/components/app/views/Leaderboard.tsx new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/src/components/app/views/Settings.tsx b/frontend/src/components/app/views/Settings.tsx new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/src/components/app/views/Stats.tsx b/frontend/src/components/app/views/Stats.tsx new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/src/components/app/views/Store.tsx b/frontend/src/components/app/views/Store.tsx new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/src/components/landing/FeatureCard.tsx b/frontend/src/components/landing/FeatureCard.tsx new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/src/components/landing/Features.tsx b/frontend/src/components/landing/Features.tsx new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/src/components/landing/LandingPage.tsx b/frontend/src/components/landing/LandingPage.tsx new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/src/components/landing/SignupCard.tsx b/frontend/src/components/landing/SignupCard.tsx new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/src/components/layout/Footer.tsx b/frontend/src/components/layout/Footer.tsx new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/src/components/layout/Header.tsx b/frontend/src/components/layout/Header.tsx new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/src/components/layout/NavBar.tsx b/frontend/src/components/layout/NavBar.tsx new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 3638c074f8..4e1b9c632a 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -8,11 +8,40 @@ // You should NOT make any changes in this file as it will be overwritten. // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. +import { createFileRoute } from '@tanstack/react-router' + import { Route as rootRouteImport } from './routes/__root' +import { Route as TosRouteImport } from './routes/tos' +import { Route as PrivpolicyRouteImport } from './routes/privpolicy' import { Route as LoginRouteImport } from './routes/login' import { Route as AboutRouteImport } from './routes/about' import { Route as IndexRouteImport } from './routes/index' +import { Route as AppIndexRouteImport } from './routes/app/index' +import { Route as AppStoreRouteImport } from './routes/app/store' +import { Route as AppStatsRouteImport } from './routes/app/stats' +import { Route as AppSettingsRouteImport } from './routes/app/settings' +import { Route as AppLeaderboardRouteImport } from './routes/app/leaderboard' +import { Route as AppInventoryRouteImport } from './routes/app/inventory' +import { Route as AppExercisesRouteImport } from './routes/app/exercises' +import { Route as App_layoutRouteImport } from './routes/app/__layout' + +const AppRouteImport = createFileRoute('/app')() +const AppRoute = AppRouteImport.update({ + id: '/app', + path: '/app', + getParentRoute: () => rootRouteImport, +} as any) +const TosRoute = TosRouteImport.update({ + id: '/tos', + path: '/tos', + getParentRoute: () => rootRouteImport, +} as any) +const PrivpolicyRoute = PrivpolicyRouteImport.update({ + id: '/privpolicy', + path: '/privpolicy', + getParentRoute: () => rootRouteImport, +} as any) const LoginRoute = LoginRouteImport.update({ id: '/login', path: '/login', @@ -28,39 +57,172 @@ const IndexRoute = IndexRouteImport.update({ path: '/', getParentRoute: () => rootRouteImport, } as any) +const AppIndexRoute = AppIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => AppRoute, +} as any) +const AppStoreRoute = AppStoreRouteImport.update({ + id: '/store', + path: '/store', + getParentRoute: () => AppRoute, +} as any) +const AppStatsRoute = AppStatsRouteImport.update({ + id: '/stats', + path: '/stats', + getParentRoute: () => AppRoute, +} as any) +const AppSettingsRoute = AppSettingsRouteImport.update({ + id: '/settings', + path: '/settings', + getParentRoute: () => AppRoute, +} as any) +const AppLeaderboardRoute = AppLeaderboardRouteImport.update({ + id: '/leaderboard', + path: '/leaderboard', + getParentRoute: () => AppRoute, +} as any) +const AppInventoryRoute = AppInventoryRouteImport.update({ + id: '/inventory', + path: '/inventory', + getParentRoute: () => AppRoute, +} as any) +const AppExercisesRoute = AppExercisesRouteImport.update({ + id: '/exercises', + path: '/exercises', + getParentRoute: () => AppRoute, +} as any) +const App_layoutRoute = App_layoutRouteImport.update({ + id: '/__layout', + getParentRoute: () => AppRoute, +} as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute '/about': typeof AboutRoute '/login': typeof LoginRoute + '/privpolicy': typeof PrivpolicyRoute + '/tos': typeof TosRoute + '/app': typeof App_layoutRoute + '/app/exercises': typeof AppExercisesRoute + '/app/inventory': typeof AppInventoryRoute + '/app/leaderboard': typeof AppLeaderboardRoute + '/app/settings': typeof AppSettingsRoute + '/app/stats': typeof AppStatsRoute + '/app/store': typeof AppStoreRoute + '/app/': typeof AppIndexRoute } export interface FileRoutesByTo { '/': typeof IndexRoute '/about': typeof AboutRoute '/login': typeof LoginRoute + '/privpolicy': typeof PrivpolicyRoute + '/tos': typeof TosRoute + '/app': typeof AppIndexRoute + '/app/exercises': typeof AppExercisesRoute + '/app/inventory': typeof AppInventoryRoute + '/app/leaderboard': typeof AppLeaderboardRoute + '/app/settings': typeof AppSettingsRoute + '/app/stats': typeof AppStatsRoute + '/app/store': typeof AppStoreRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/about': typeof AboutRoute '/login': typeof LoginRoute + '/privpolicy': typeof PrivpolicyRoute + '/tos': typeof TosRoute + '/app': typeof AppRouteWithChildren + '/app/__layout': typeof App_layoutRoute + '/app/exercises': typeof AppExercisesRoute + '/app/inventory': typeof AppInventoryRoute + '/app/leaderboard': typeof AppLeaderboardRoute + '/app/settings': typeof AppSettingsRoute + '/app/stats': typeof AppStatsRoute + '/app/store': typeof AppStoreRoute + '/app/': typeof AppIndexRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/about' | '/login' + fullPaths: + | '/' + | '/about' + | '/login' + | '/privpolicy' + | '/tos' + | '/app' + | '/app/exercises' + | '/app/inventory' + | '/app/leaderboard' + | '/app/settings' + | '/app/stats' + | '/app/store' + | '/app/' fileRoutesByTo: FileRoutesByTo - to: '/' | '/about' | '/login' - id: '__root__' | '/' | '/about' | '/login' + to: + | '/' + | '/about' + | '/login' + | '/privpolicy' + | '/tos' + | '/app' + | '/app/exercises' + | '/app/inventory' + | '/app/leaderboard' + | '/app/settings' + | '/app/stats' + | '/app/store' + id: + | '__root__' + | '/' + | '/about' + | '/login' + | '/privpolicy' + | '/tos' + | '/app' + | '/app/__layout' + | '/app/exercises' + | '/app/inventory' + | '/app/leaderboard' + | '/app/settings' + | '/app/stats' + | '/app/store' + | '/app/' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute AboutRoute: typeof AboutRoute LoginRoute: typeof LoginRoute + PrivpolicyRoute: typeof PrivpolicyRoute + TosRoute: typeof TosRoute + AppRoute: typeof AppRouteWithChildren } declare module '@tanstack/react-router' { interface FileRoutesByPath { + '/app': { + id: '/app' + path: '/app' + fullPath: '/app' + preLoaderRoute: typeof AppRouteImport + parentRoute: typeof rootRouteImport + } + '/tos': { + id: '/tos' + path: '/tos' + fullPath: '/tos' + preLoaderRoute: typeof TosRouteImport + parentRoute: typeof rootRouteImport + } + '/privpolicy': { + id: '/privpolicy' + path: '/privpolicy' + fullPath: '/privpolicy' + preLoaderRoute: typeof PrivpolicyRouteImport + parentRoute: typeof rootRouteImport + } '/login': { id: '/login' path: '/login' @@ -82,13 +244,96 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } + '/app/': { + id: '/app/' + path: '/' + fullPath: '/app/' + preLoaderRoute: typeof AppIndexRouteImport + parentRoute: typeof AppRoute + } + '/app/store': { + id: '/app/store' + path: '/store' + fullPath: '/app/store' + preLoaderRoute: typeof AppStoreRouteImport + parentRoute: typeof AppRoute + } + '/app/stats': { + id: '/app/stats' + path: '/stats' + fullPath: '/app/stats' + preLoaderRoute: typeof AppStatsRouteImport + parentRoute: typeof AppRoute + } + '/app/settings': { + id: '/app/settings' + path: '/settings' + fullPath: '/app/settings' + preLoaderRoute: typeof AppSettingsRouteImport + parentRoute: typeof AppRoute + } + '/app/leaderboard': { + id: '/app/leaderboard' + path: '/leaderboard' + fullPath: '/app/leaderboard' + preLoaderRoute: typeof AppLeaderboardRouteImport + parentRoute: typeof AppRoute + } + '/app/inventory': { + id: '/app/inventory' + path: '/inventory' + fullPath: '/app/inventory' + preLoaderRoute: typeof AppInventoryRouteImport + parentRoute: typeof AppRoute + } + '/app/exercises': { + id: '/app/exercises' + path: '/exercises' + fullPath: '/app/exercises' + preLoaderRoute: typeof AppExercisesRouteImport + parentRoute: typeof AppRoute + } + '/app/__layout': { + id: '/app/__layout' + path: '/app' + fullPath: '/app' + preLoaderRoute: typeof App_layoutRouteImport + parentRoute: typeof AppRoute + } } } +interface AppRouteChildren { + App_layoutRoute: typeof App_layoutRoute + AppExercisesRoute: typeof AppExercisesRoute + AppInventoryRoute: typeof AppInventoryRoute + AppLeaderboardRoute: typeof AppLeaderboardRoute + AppSettingsRoute: typeof AppSettingsRoute + AppStatsRoute: typeof AppStatsRoute + AppStoreRoute: typeof AppStoreRoute + AppIndexRoute: typeof AppIndexRoute +} + +const AppRouteChildren: AppRouteChildren = { + App_layoutRoute: App_layoutRoute, + AppExercisesRoute: AppExercisesRoute, + AppInventoryRoute: AppInventoryRoute, + AppLeaderboardRoute: AppLeaderboardRoute, + AppSettingsRoute: AppSettingsRoute, + AppStatsRoute: AppStatsRoute, + AppStoreRoute: AppStoreRoute, + AppIndexRoute: AppIndexRoute, +} + +const AppRouteWithChildren = AppRoute._addFileChildren(AppRouteChildren) + const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, AboutRoute: AboutRoute, LoginRoute: LoginRoute, + PrivpolicyRoute: PrivpolicyRoute, + TosRoute: TosRoute, + AppRoute: AppRouteWithChildren, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/frontend/src/routes/app/__layout.tsx b/frontend/src/routes/app/__layout.tsx new file mode 100644 index 0000000000..5a67437393 --- /dev/null +++ b/frontend/src/routes/app/__layout.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/app/__layout')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/app/__layout"!
+} diff --git a/frontend/src/routes/app/exercises.tsx b/frontend/src/routes/app/exercises.tsx new file mode 100644 index 0000000000..95e7b8ba9f --- /dev/null +++ b/frontend/src/routes/app/exercises.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/app/exercises')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/app/exercises"!
+} diff --git a/frontend/src/routes/app/index.tsx b/frontend/src/routes/app/index.tsx new file mode 100644 index 0000000000..0e370aec4c --- /dev/null +++ b/frontend/src/routes/app/index.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/app/')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/app/"!
+} diff --git a/frontend/src/routes/app/inventory.tsx b/frontend/src/routes/app/inventory.tsx new file mode 100644 index 0000000000..bdf19b2fa0 --- /dev/null +++ b/frontend/src/routes/app/inventory.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/app/inventory')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/app/inventory"!
+} diff --git a/frontend/src/routes/app/leaderboard.tsx b/frontend/src/routes/app/leaderboard.tsx new file mode 100644 index 0000000000..47510b32cf --- /dev/null +++ b/frontend/src/routes/app/leaderboard.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/app/leaderboard')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/app/leaderboard"!
+} diff --git a/frontend/src/routes/app/settings.tsx b/frontend/src/routes/app/settings.tsx new file mode 100644 index 0000000000..2f70b5d558 --- /dev/null +++ b/frontend/src/routes/app/settings.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/app/settings')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/app/settings"!
+} diff --git a/frontend/src/routes/app/stats.tsx b/frontend/src/routes/app/stats.tsx new file mode 100644 index 0000000000..01314832c5 --- /dev/null +++ b/frontend/src/routes/app/stats.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/app/stats')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/app/stats"!
+} diff --git a/frontend/src/routes/app/store.tsx b/frontend/src/routes/app/store.tsx new file mode 100644 index 0000000000..9f3a481db7 --- /dev/null +++ b/frontend/src/routes/app/store.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/app/store')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/app/store"!
+} diff --git a/frontend/src/routes/index.tsx b/frontend/src/routes/index.tsx index c4cf7aa2c0..828ea21186 100644 --- a/frontend/src/routes/index.tsx +++ b/frontend/src/routes/index.tsx @@ -3,7 +3,7 @@ import { createFileRoute } from '@tanstack/react-router' export const Index = () => { return (
-

Welcome Home!

+

Main content of the landing page goes here

) } diff --git a/frontend/src/routes/privpolicy.tsx b/frontend/src/routes/privpolicy.tsx new file mode 100644 index 0000000000..bcd67e3b41 --- /dev/null +++ b/frontend/src/routes/privpolicy.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/privpolicy')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/privpolicy"!
+} diff --git a/frontend/src/routes/tos.tsx b/frontend/src/routes/tos.tsx new file mode 100644 index 0000000000..d139f6cc20 --- /dev/null +++ b/frontend/src/routes/tos.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/tos')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/tos"!
+} diff --git a/frontend/src/store/auth.ts b/frontend/src/store/auth.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/src/store/pet.ts b/frontend/src/store/pet.ts new file mode 100644 index 0000000000..e69de29bb2 From 6eaa8b6a8ee0328e2630393c6dc75ebe68fb317b Mon Sep 17 00:00:00 2001 From: Juan Salvador Zorrilla Date: Wed, 27 Aug 2025 10:07:42 +0200 Subject: [PATCH 046/109] add custom colors and fonts to index.css --- frontend/src/index.css | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/frontend/src/index.css b/frontend/src/index.css index a461c505f1..ef188338bb 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1 +1,20 @@ -@import "tailwindcss"; \ No newline at end of file +@import url("https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap"); + +@import "tailwindcss"; + +@theme { + --color-ammo-100: #eeffcc; + --color-ammo-200: #bedc7f; + --color-ammo-300: #89a257; + --color-ammo-400: #4d8061; + --color-ammo-500: #305d42; + --color-ammo-600: #1e3a29; + --color-ammo-700: #112318; + --color-ammo-800: #040c06; + + --color-customs-100: #ffffff; + --color-customs-200: #414f43; + --color-customs-300: #597e64; /* sleep background color*/ + + --font-pstp: "Press Start 2P", "sans-serif"; +} From 4be56c046114e8f3572c96614ec6dc755725d031 Mon Sep 17 00:00:00 2001 From: Juan Salvador Zorrilla Date: Wed, 27 Aug 2025 10:28:00 +0200 Subject: [PATCH 047/109] add global styles --- frontend/src/index.css | 27 +++++++++++++++++++++++++++ frontend/src/routes/__root.tsx | 2 +- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/frontend/src/index.css b/frontend/src/index.css index ef188338bb..0d025cc8c3 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -18,3 +18,30 @@ --font-pstp: "Press Start 2P", "sans-serif"; } + +@layer base { + html { + font-family: var(--font-pstp); + } + + body { + background-color: var(--color-ammo-800); + color: var(--color-customs-100); /* default text color */ + } + + img { + max-width: 100%; + height: auto; + } + + a { + color: inherit; + text-decoration: none; + } + + /* Custom selection highlight, still to be tweaked */ + ::selection { + background: var(--color-ammo-400); + color: var(--color-ammo-800); + } +} diff --git a/frontend/src/routes/__root.tsx b/frontend/src/routes/__root.tsx index 8ff099aa29..f41eade2c7 100644 --- a/frontend/src/routes/__root.tsx +++ b/frontend/src/routes/__root.tsx @@ -4,7 +4,7 @@ import { TanStackRouterDevtools } from '@tanstack/react-router-devtools' export const Route = createRootRoute({ component: () => (
-