diff --git a/README.md b/README.md
index a0532f23..a747b53a 100644
--- a/README.md
+++ b/README.md
@@ -43,43 +43,26 @@ Welcome to **GitHub Tracker**, a web app designed to help you monitor and analyz
## ๐ Setup Guide
1. Clone the repository to your local machine:
```bash
-$ git clone https://github.com/GitMetricsLab/github_tracker.git
+$ git clone https://github.com/yourusername/github-tracker.git
```
2. Navigate to the project directory:
```bash
$ cd github-tracker
```
+
3. Run the frontend
```bash
$ npm i
$ npm run dev
```
-This project utilizes [Vitest](https://vitest.dev/) and React Testing Library to ensure UI reliability.
-
-To run the frontend test suite, use the following command:
-```bash
-npm run test:client
-```
-
-
4. Run the backend
```bash
$ npm i
$ npm start
```
-### Backend Environment Variables
-
-Create `backend/.env` for local backend runs:
-
-| Variable | Required | Purpose |
-| --- | --- | --- |
-| `MONGO_URI` | Yes | MongoDB connection string used by Mongoose and the persistent session store |
-| `SESSION_SECRET` | Yes | Secret used to sign Express session cookies |
-| `NODE_ENV` | No | Set to `production` to send session cookies only over HTTPS |
-
## ๐งช Backend Unit & Integration Testing with Jasmine
This project uses the Jasmine framework for backend unit and integration tests. The tests cover:
diff --git a/backend/config/passportConfig.js b/backend/config/passportConfig.js
index a6a0590a..842f50ca 100644
--- a/backend/config/passportConfig.js
+++ b/backend/config/passportConfig.js
@@ -9,12 +9,12 @@ passport.use(
try {
const user = await User.findOne( {email} );
if (!user) {
- return done(null, false, { message: 'Invalid email or password' });
+ return done(null, false, { message: 'Email is invalid '});
}
const isMatch = await user.comparePassword(password);
if (!isMatch) {
- return done(null, false, { message: 'Invalid email or password' });
+ return done(null, false, { message: 'Invalid password' });
}
return done(null, {
@@ -35,13 +35,9 @@ passport.serializeUser((user, done) => {
});
// Deserialize user (retrieve user from session)
-// .select('-password -__v') excludes the bcrypt hash from req.user so it
-// cannot be accidentally serialized into an API response.
-// .lean() returns a plain object instead of a Mongoose document, preventing
-// model methods from being accessible on req.user.
passport.deserializeUser(async (id, done) => {
try {
- const user = await User.findById(id).select('-password -__v').lean();
+ const user = await User.findById(id);
done(null, user);
} catch (err) {
done(err, null);
diff --git a/backend/config/session.js b/backend/config/session.js
deleted file mode 100644
index 2f5d7800..00000000
--- a/backend/config/session.js
+++ /dev/null
@@ -1,32 +0,0 @@
-const MongoStore = require('connect-mongo');
-
-const SESSION_TTL_SECONDS = 14 * 24 * 60 * 60;
-
-function createSessionConfig({
- mongoUrl = process.env.MONGO_URI,
- sessionSecret = process.env.SESSION_SECRET,
- nodeEnv = process.env.NODE_ENV,
- storeFactory = MongoStore,
-} = {}) {
- if (!mongoUrl) {
- throw new Error('MONGO_URI is required to configure the session store');
- }
-
- return {
- secret: sessionSecret,
- resave: false,
- saveUninitialized: false,
- store: storeFactory.create({
- mongoUrl,
- ttl: SESSION_TTL_SECONDS,
- }),
- cookie: {
- secure: nodeEnv === 'production',
- },
- };
-}
-
-module.exports = {
- SESSION_TTL_SECONDS,
- createSessionConfig,
-};
diff --git a/backend/data/discussions.json b/backend/data/discussions.json
deleted file mode 100644
index e474e8ee..00000000
--- a/backend/data/discussions.json
+++ /dev/null
@@ -1,30 +0,0 @@
-{
- "discussions": [
- {
- "id": "b3808b42-91e4-4d81-991a-07a96d7a549f",
- "title": "Test discussion from shell",
- "body": "This is a sufficiently long discussion body to pass validation and save.",
- "category": "Help",
- "tags": [
- "test",
- "api"
- ],
- "authorId": "8d706914-51d9-40ca-981c-ac9dcb6a1881",
- "authorName": "Guest",
- "likes": [
- "00000000-0000-0000-0000-000000000000"
- ],
- "comments": [
- {
- "id": "d1b1bcad-6989-49ff-a587-667f8426198e",
- "text": "Yes",
- "authorId": "00000000-0000-0000-0000-000000000000",
- "authorName": "demo-user",
- "createdAt": "2026-05-27T12:56:14.816Z"
- }
- ],
- "createdAt": "2026-05-27T11:55:11.307Z",
- "updatedAt": "2026-05-27T13:00:24.816Z"
- }
- ]
-}
\ No newline at end of file
diff --git a/backend/data/users.json b/backend/data/users.json
deleted file mode 100644
index b4d801da..00000000
--- a/backend/data/users.json
+++ /dev/null
@@ -1,10 +0,0 @@
-{
- "users": [
- {
- "id": "00000000-0000-0000-0000-000000000000",
- "username": "demo-user",
- "email": "user@example.com",
- "password": "$2a$10$00000000000000000000000000000000000000000000000000000"
- }
- ]
-}
\ No newline at end of file
diff --git a/backend/models/Discussion.js b/backend/models/Discussion.js
deleted file mode 100644
index 94705f09..00000000
--- a/backend/models/Discussion.js
+++ /dev/null
@@ -1,81 +0,0 @@
-const mongoose = require('mongoose');
-
-const CommentSchema = new mongoose.Schema(
- {
- text: {
- type: String,
- required: true,
- trim: true,
- maxlength: 1000,
- },
- authorId: {
- type: String,
- required: true,
- },
- authorName: {
- type: String,
- required: true,
- trim: true,
- },
- },
- { timestamps: true }
-);
-
-const DiscussionSchema = new mongoose.Schema(
- {
- title: {
- type: String,
- required: true,
- trim: true,
- minlength: 4,
- maxlength: 140,
- },
- body: {
- type: String,
- required: true,
- trim: true,
- minlength: 20,
- maxlength: 4000,
- },
- category: {
- type: String,
- required: true,
- trim: true,
- maxlength: 60,
- },
- tags: {
- type: [
- {
- type: String,
- trim: true,
- maxlength: 30,
- },
- ],
- default: [],
- },
- authorId: {
- type: String,
- required: true,
- },
- authorName: {
- type: String,
- required: true,
- trim: true,
- },
- likes: {
- type: [
- {
- type: String,
- },
- ],
- default: [],
- },
- comments: {
- type: [CommentSchema],
- default: [],
- },
- },
- { timestamps: true }
-);
-
-module.exports = mongoose.model('Discussion', DiscussionSchema);
\ No newline at end of file
diff --git a/backend/package.json b/backend/package.json
index af00fdb4..74ab9dd7 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -14,7 +14,6 @@
"dependencies": {
"bcryptjs": "^2.4.3",
"body-parser": "^1.20.3",
- "connect-mongo": "^5.1.0",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.1",
diff --git a/backend/routes/auth.js b/backend/routes/auth.js
index 6395116a..7c2cda78 100644
--- a/backend/routes/auth.js
+++ b/backend/routes/auth.js
@@ -1,137 +1,15 @@
const express = require("express");
const passport = require("passport");
-const fs = require("fs/promises");
-const path = require("path");
-const bcrypt = require("bcryptjs");
-const crypto = require("crypto");
const User = require("../models/User");
const { signupSchema, loginSchema } = require("../validators/authValidator");
const { validateRequest } = require("../validators/validationRequest");
const router = express.Router();
-const getUseMongoAuth = () => typeof global.mongooseConnected !== "undefined" ? global.mongooseConnected : Boolean(process.env.MONGO_URI);
-const dataDir = path.join(__dirname, "..", "data");
-const usersFile = path.join(dataDir, "users.json");
-const usersLockFile = `${usersFile}.lock`;
-
-const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
-
-const acquireUsersLock = async () => {
- const retries = 80;
- const delayMs = 25;
-
- for (let attempt = 0; attempt < retries; attempt += 1) {
- try {
- const handle = await fs.open(usersLockFile, "wx");
- return handle;
- } catch (error) {
- if (error.code !== "EEXIST") {
- throw error;
- }
-
- await sleep(delayMs);
- }
- }
-
- throw new Error("Could not acquire users file lock");
-};
-
-const withUsersLock = async (callback) => {
- const lockHandle = await acquireUsersLock();
-
- try {
- return await callback();
- } finally {
- await lockHandle.close();
- await fs.unlink(usersLockFile).catch(() => {});
- }
-};
-
-const ensureUsersFileUnlocked = async () => {
- await fs.mkdir(dataDir, { recursive: true });
-
- try {
- await fs.access(usersFile);
- } catch {
- await fs.writeFile(usersFile, JSON.stringify({ users: [] }, null, 2), "utf8");
- }
-};
-
-const readUsersUnlocked = async () => {
- await ensureUsersFileUnlocked();
- const raw = await fs.readFile(usersFile, "utf8");
-
- try {
- const parsed = JSON.parse(raw);
- return Array.isArray(parsed.users) ? parsed.users : [];
- } catch (error) {
- throw new Error(`Failed to parse users file at ${usersFile}: ${error.message}`);
- }
-};
-
-const writeUsersUnlocked = async (users) => {
- await ensureUsersFileUnlocked();
- const tempFile = `${usersFile}.${process.pid}.${Date.now()}.tmp`;
-
- // Write to a temporary file first, then atomically replace the target file.
- await fs.writeFile(tempFile, JSON.stringify({ users }, null, 2), "utf8");
- await fs.rename(tempFile, usersFile);
-};
-
-const readUsersWithLock = async () => withUsersLock(readUsersUnlocked);
-
-const createUserIfNotExistsWithLock = async (newUser) => withUsersLock(async () => {
- const users = await readUsersUnlocked();
- const existingUser = users.find((user) => user.email === newUser.email || user.username === newUser.username);
-
- if (existingUser) {
- return false;
- }
-
- users.push(newUser);
- await writeUsersUnlocked(users);
- return true;
-});
-
-const createSessionUser = (req, user) => {
- req.session.authUser = {
- id: user.id,
- username: user.username,
- email: user.email,
- };
-
- req.user = req.session.authUser;
-};
-
// Signup route
router.post("/signup", validateRequest(signupSchema), async (req, res) => {
const { username, email, password } = req.body;
- if (!getUseMongoAuth()) {
- try {
- const salt = await bcrypt.genSalt(10);
- const hashedPassword = await bcrypt.hash(password, salt);
-
- const newUser = {
- id: crypto.randomUUID(),
- username,
- email,
- password: hashedPassword,
- };
-
- const created = await createUserIfNotExistsWithLock(newUser);
-
- if (!created) {
- return res.status(400).json({ message: 'User already exists' });
- }
-
- return res.status(201).json({ message: 'User created successfully' });
- } catch (err) {
- return res.status(500).json({ message: 'Error creating user', error: err.message });
- }
- }
-
try {
const existingUser = await User.findOne({
$or: [{ email }, { username }],
@@ -152,81 +30,20 @@ router.post("/signup", validateRequest(signupSchema), async (req, res) => {
}
});
-// Session status route
-router.get("/me", (req, res) => {
- const isAuthenticated = typeof req.isAuthenticated === "function" && req.isAuthenticated();
-
- if (!isAuthenticated) {
- return res.status(200).json({ authenticated: false, user: null });
- }
-
- return res.status(200).json({ authenticated: true, user: req.user });
-});
-
// Login route
-router.post("/login", validateRequest(loginSchema), async (req, res, next) => {
- if (!getUseMongoAuth()) {
- try {
- const { email, password } = req.body;
- const users = await readUsersWithLock();
- const user = users.find((item) => item.email === email);
-
- if (!user) {
- return res.status(401).json({ message: 'Invalid email or password' });
- }
-
- const isMatch = await bcrypt.compare(password, user.password);
- if (!isMatch) {
- return res.status(401).json({ message: 'Invalid email or password' });
- }
-
- req.session.regenerate((err) => {
- if (err) {
- return res.status(500).json({ message: 'Login failed', error: err.message });
- }
-
- createSessionUser(req, user);
-
- return res.status(200).json({
- message: 'Login successful',
- user: req.user,
- });
- });
- } catch (err) {
- return res.status(500).json({ message: 'Login failed', error: err.message });
- }
- }
-
- return passport.authenticate('local', (err, user, info) => {
- if (err) {
- return next(err);
- }
-
- if (!user) {
- return res.status(401).json({ message: info?.message || 'Invalid credentials' });
- }
-
- req.logIn(user, (loginErr) => {
- if (loginErr) {
- return next(loginErr);
- }
-
- return res.status(200).json({ message: 'Login successful', user: req.user });
- });
- })(req, res, next);
+router.post("/login", validateRequest(loginSchema), passport.authenticate('local'), (req, res) => {
+ res.status(200).json( { message: 'Login successful', user: req.user } );
});
// Logout route
-router.post("/logout", (req, res) => {
+router.get("/logout", (req, res) => {
+
req.logout((err) => {
+
if (err)
return res.status(500).json({ message: 'Logout failed', error: err.message });
- req.session.destroy((destroyErr) => {
- if (destroyErr)
- return res.status(500).json({ message: 'Session cleanup failed', error: destroyErr.message });
- res.clearCookie('connect.sid');
+ else
res.status(200).json({ message: 'Logged out successfully' });
- });
});
});
diff --git a/backend/routes/discussions.js b/backend/routes/discussions.js
deleted file mode 100644
index 8c981697..00000000
--- a/backend/routes/discussions.js
+++ /dev/null
@@ -1,351 +0,0 @@
-const express = require('express');
-const fs = require('fs/promises');
-const path = require('path');
-const crypto = require('crypto');
-const { discussionSchema, commentSchema } = require('../validators/discussionValidator');
-const { validateRequest } = require('../validators/validationRequest');
-
-const router = express.Router();
-
-const dataDir = path.join(__dirname, '..', 'data');
-const dataFile = path.join(dataDir, 'discussions.json');
-let storeMutex = Promise.resolve();
-
-const ensureDataFile = async () => {
- await fs.mkdir(dataDir, { recursive: true });
-
- try {
- await fs.access(dataFile);
- } catch {
- await fs.writeFile(dataFile, JSON.stringify({ discussions: [] }, null, 2), 'utf8');
- }
-};
-
-const readStore = async () => {
- await ensureDataFile();
- const raw = await fs.readFile(dataFile, 'utf8');
-
- try {
- const parsed = JSON.parse(raw);
- return Array.isArray(parsed.discussions) ? parsed.discussions : [];
- } catch (error) {
- throw new Error(`Invalid discussions store JSON in ${dataFile}: ${error.message}`);
- }
-};
-
-const writeStore = async (discussions) => {
- await ensureDataFile();
- const tempFile = `${dataFile}.${process.pid}.${Date.now()}.tmp`;
-
- // Write to a temporary file first, then atomically replace the target file.
- await fs.writeFile(tempFile, JSON.stringify({ discussions }, null, 2), 'utf8');
- await fs.rename(tempFile, dataFile);
-};
-
-const updateStore = async (updater) => {
- let releaseLock;
- const waitForTurn = storeMutex;
- storeMutex = new Promise((resolve) => {
- releaseLock = resolve;
- });
-
- await waitForTurn;
-
- try {
- const discussions = await readStore();
- const result = await updater(discussions);
- const nextDiscussions = Array.isArray(result?.discussions) ? result.discussions : discussions;
-
- if (result?.persist !== false) {
- await writeStore(nextDiscussions);
- }
-
- return { ...result, discussions: nextDiscussions };
- } finally {
- releaseLock();
- }
-};
-
-const normalizeTags = (tags = []) =>
- tags
- .map((tag) => String(tag).trim())
- .filter(Boolean)
- .map((tag) => tag.replace(/^#?/, '').toLowerCase());
-
-const requireAuth = (req, res, next) => {
- if (!req.user) {
- return res.status(401).json({ message: 'Authentication required' });
- }
-
- return next();
-};
-
-const getAuthenticatedIdentity = (req) => ({
- id: req.user._id?.toString?.() || req.user.id || req.user.email || 'user',
- name: req.user.username || req.user.email || 'Member',
-});
-
-const getCommunityIdentity = (req) => {
- if (req.user) {
- return {
- id: req.user._id?.toString?.() || req.user.id || req.user.email || 'user',
- name: req.user.username || req.user.email || 'Member',
- };
- }
-
- if (!req.session.communityUserId) {
- req.session.communityUserId = crypto.randomUUID();
- }
-
- if (!req.session.communityUserName) {
- req.session.communityUserName = 'Guest';
- }
-
- return {
- id: req.session.communityUserId,
- name: req.session.communityUserName,
- };
-};
-
-const toPublicDiscussion = (discussion, currentUserId) => ({
- id: discussion.id,
- title: discussion.title,
- body: discussion.body,
- category: discussion.category,
- tags: discussion.tags,
- author: {
- id: discussion.authorId,
- name: discussion.authorName,
- },
- likesCount: discussion.likes.length,
- commentsCount: discussion.comments.length,
- likedByCurrentUser: currentUserId ? discussion.likes.includes(currentUserId) : false,
- canEdit: currentUserId ? String(discussion.authorId) === String(currentUserId) : false,
- comments: discussion.comments.map((comment) => ({
- id: comment.id,
- text: comment.text,
- author: {
- id: comment.authorId,
- name: comment.authorName,
- },
- createdAt: comment.createdAt,
- })),
- createdAt: discussion.createdAt,
- updatedAt: discussion.updatedAt,
-});
-
-router.get('/', async (req, res) => {
- try {
- const discussions = await readStore();
- const { search = '', category = '', tag = '', sort = 'recent' } = req.query;
- const currentUserId = req.user ? getCommunityIdentity(req).id : (req.session?.communityUserId || null);
-
- let filtered = discussions;
-
- if (category) {
- filtered = filtered.filter((discussion) => discussion.category === category);
- }
-
- if (tag) {
- const searchTag = String(tag).toLowerCase();
- filtered = filtered.filter((discussion) => discussion.tags.includes(searchTag));
- }
-
- if (search) {
- const term = String(search).toLowerCase();
- filtered = filtered.filter((discussion) => {
- const haystack = [discussion.title, discussion.body, discussion.category, discussion.tags.join(' ')].join(' ').toLowerCase();
- return haystack.includes(term);
- });
- }
-
- filtered = filtered.sort((left, right) => {
- if (sort === 'trending') {
- const leftScore = left.likes.length * 2 + left.comments.length;
- const rightScore = right.likes.length * 2 + right.comments.length;
-
- if (rightScore !== leftScore) {
- return rightScore - leftScore;
- }
- }
-
- return new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime();
- });
-
- return res.status(200).json({
- items: filtered.map((discussion) => toPublicDiscussion(discussion, currentUserId)),
- categories: Array.from(new Set(filtered.map((discussion) => discussion.category))).sort(),
- isAuthenticated: !!req.user,
- });
- } catch (error) {
- return res.status(500).json({ message: 'Unable to load discussions', error: error.message });
- }
-});
-
-router.post('/', requireAuth, validateRequest(discussionSchema), async (req, res) => {
- try {
- const identity = getAuthenticatedIdentity(req);
- const now = new Date().toISOString();
-
- const discussion = {
- id: crypto.randomUUID(),
- title: String(req.body.title).trim(),
- body: String(req.body.body).trim(),
- category: String(req.body.category).trim(),
- tags: normalizeTags(req.body.tags || []),
- authorId: identity.id,
- authorName: identity.name,
- likes: [],
- comments: [],
- createdAt: now,
- updatedAt: now,
- };
-
- await updateStore((discussions) => {
- discussions.unshift(discussion);
- return { discussions };
- });
-
- return res.status(201).json({
- message: 'Discussion created successfully',
- discussion: toPublicDiscussion(discussion, identity.id),
- });
- } catch (error) {
- return res.status(500).json({ message: 'Unable to create discussion', error: error.message });
- }
-});
-
-router.put('/:id', requireAuth, validateRequest(discussionSchema), async (req, res) => {
- try {
- const identity = getAuthenticatedIdentity(req);
- const result = await updateStore((discussions) => {
- const discussion = discussions.find((item) => item.id === req.params.id);
-
- if (!discussion) {
- return { status: 404, message: 'Discussion not found', persist: false };
- }
-
- if (String(discussion.authorId) !== String(identity.id)) {
- return { status: 403, message: 'You can only edit your own discussion', persist: false };
- }
-
- discussion.title = String(req.body.title).trim();
- discussion.body = String(req.body.body).trim();
- discussion.category = String(req.body.category).trim();
- discussion.tags = normalizeTags(req.body.tags || []);
- discussion.updatedAt = new Date().toISOString();
-
- return { discussions, discussion };
- });
-
- if (result.status) {
- return res.status(result.status).json({ message: result.message });
- }
-
- return res.status(200).json({
- message: 'Discussion updated successfully',
- discussion: toPublicDiscussion(result.discussion, identity.id),
- });
- } catch (error) {
- return res.status(500).json({ message: 'Unable to update discussion', error: error.message });
- }
-});
-
-router.delete('/:id', requireAuth, async (req, res) => {
- try {
- const identity = getAuthenticatedIdentity(req);
- const result = await updateStore((discussions) => {
- const discussionIndex = discussions.findIndex((item) => item.id === req.params.id);
-
- if (discussionIndex === -1) {
- return { status: 404, message: 'Discussion not found', persist: false };
- }
-
- if (String(discussions[discussionIndex].authorId) !== String(identity.id)) {
- return { status: 403, message: 'You can only delete your own discussion', persist: false };
- }
-
- discussions.splice(discussionIndex, 1);
- return { discussions };
- });
-
- if (result.status) {
- return res.status(result.status).json({ message: result.message });
- }
-
- return res.status(200).json({ message: 'Discussion deleted successfully' });
- } catch (error) {
- return res.status(500).json({ message: 'Unable to delete discussion', error: error.message });
- }
-});
-
-router.post('/:id/likes', requireAuth, async (req, res) => {
- try {
- const identity = getAuthenticatedIdentity(req);
- const result = await updateStore((discussions) => {
- const discussion = discussions.find((item) => item.id === req.params.id);
-
- if (!discussion) {
- return { status: 404, message: 'Discussion not found', persist: false };
- }
-
- const alreadyLiked = discussion.likes.includes(identity.id);
- discussion.likes = alreadyLiked
- ? discussion.likes.filter((likeId) => String(likeId) !== String(identity.id))
- : [...discussion.likes, identity.id];
- discussion.updatedAt = new Date().toISOString();
-
- return { discussions, discussion, alreadyLiked };
- });
-
- if (result.status) {
- return res.status(result.status).json({ message: result.message });
- }
-
- return res.status(200).json({
- message: result.alreadyLiked ? 'Discussion unliked' : 'Discussion liked',
- discussion: toPublicDiscussion(result.discussion, identity.id),
- });
- } catch (error) {
- return res.status(500).json({ message: 'Unable to update like state', error: error.message });
- }
-});
-
-router.post('/:id/comments', requireAuth, validateRequest(commentSchema), async (req, res) => {
- try {
- const commentText = req.body.text;
-
- const identity = getAuthenticatedIdentity(req);
- const result = await updateStore((discussions) => {
- const discussion = discussions.find((item) => item.id === req.params.id);
-
- if (!discussion) {
- return { status: 404, message: 'Discussion not found', persist: false };
- }
-
- discussion.comments.push({
- id: crypto.randomUUID(),
- text: commentText,
- authorId: identity.id,
- authorName: identity.name,
- createdAt: new Date().toISOString(),
- });
- discussion.updatedAt = new Date().toISOString();
-
- return { discussions, discussion };
- });
-
- if (result.status) {
- return res.status(result.status).json({ message: result.message });
- }
-
- return res.status(201).json({
- message: 'Comment added successfully',
- discussion: toPublicDiscussion(result.discussion, identity.id),
- });
- } catch (error) {
- return res.status(500).json({ message: 'Unable to add comment', error: error.message });
- }
-});
-
-module.exports = router;
\ No newline at end of file
diff --git a/backend/server.js b/backend/server.js
index d44003ae..48d6ccfb 100644
--- a/backend/server.js
+++ b/backend/server.js
@@ -5,7 +5,6 @@ const passport = require('passport');
const bodyParser = require('body-parser');
require('dotenv').config();
const cors = require('cors');
-const { createSessionConfig } = require('./config/session');
// Passport configuration
require('./config/passportConfig');
@@ -14,16 +13,13 @@ const logger = require('./logger');
const app = express();
-// Enable trust proxy
-app.set('trust proxy', 1);
-
// CORS configuration
-const allowedOrigins = ['http://localhost:5173', 'https://github-spy.netlify.app']; // there was a typo error in the url, it is fixed now.
+const allowedOrigins = ['http://localhost:5173', 'https://github-spy.etlify.app'];
app.use(cors({
origin: function (origin, callback) {
if (!origin || allowedOrigins.indexOf(origin) !== -1) {
callback(null, true);
- } else {
+ } else{
callback(new Error('Blocked by CORS policy'));
}
},
@@ -32,64 +28,26 @@ app.use(cors({
// Middleware
app.use(bodyParser.json());
-const isProduction = process.env.NODE_ENV === 'production';
-
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
- cookie: {
- httpOnly: true,
- secure: isProduction,
- sameSite: 'strict',
- maxAge: 24 * 60 * 60 * 1000,
- },
}));
app.use(passport.initialize());
app.use(passport.session());
-global.mongooseConnected = false;
-
-app.use((req, res, next) => {
- if (!global.mongooseConnected) {
- if (req.session?.authUser) {
- req.user = req.session.authUser;
- }
- }
-
- next();
-});
-
// Routes
const authRoutes = require('./routes/auth');
-const discussionRoutes = require('./routes/discussions');
app.use('/api/auth', authRoutes);
-app.use('/api/discussions', discussionRoutes);
-const startServer = () => {
- const port = process.env.PORT || 5000;
+// Connect to MongoDB
+mongoose.connect(process.env.MONGO_URI, {}).then(() => {
+ logger.info('Connected to MongoDB');
- app.listen(port, () => {
- logger.info(`Server running on port ${port}`);
+ const PORT = process.env.PORT || 5000;
+ app.listen(PORT, () => {
+ logger.info(`Server running on port ${PORT}`);
});
-};
-
-// Connect to MongoDB when available, but do not block community discussions if it is not.
-if (process.env.MONGO_URI) {
- mongoose.connect(process.env.MONGO_URI, {})
- .then(() => {
- logger.info('Connected to MongoDB');
- global.mongooseConnected = true;
- startServer();
- })
- .catch((err) => {
- logger.error('MongoDB connection error', err);
- logger.warn('Starting without MongoDB; falling back to JSON file-backed authentication');
- global.mongooseConnected = false;
- startServer();
- });
-} else {
- logger.warn('MONGO_URI is not set; starting without MongoDB');
- global.mongooseConnected = false;
- startServer();
-}
+}).catch((err) => {
+ logger.error('MongoDB connection error', err);
+});
diff --git a/backend/validators/discussionValidator.js b/backend/validators/discussionValidator.js
deleted file mode 100644
index 2beb637a..00000000
--- a/backend/validators/discussionValidator.js
+++ /dev/null
@@ -1,30 +0,0 @@
-const { z } = require('zod');
-
-const discussionSchema = z.object({
- title: z
- .string()
- .trim()
- .min(4, 'Title must be at least 4 characters long')
- .max(140, 'Title must be at most 140 characters long'),
- body: z
- .string()
- .trim()
- .min(20, 'Post body must be at least 20 characters long')
- .max(4000, 'Post body must be at most 4000 characters long'),
- category: z
- .string()
- .trim()
- .min(2, 'Category is required')
- .max(60, 'Category must be at most 60 characters long'),
- tags: z.array(z.string().trim().min(1).max(30)).max(6).default([]),
-});
-
-const commentSchema = z.object({
- text: z
- .string()
- .trim()
- .min(2, 'Comment must be at least 2 characters long')
- .max(1000, 'Comment must be at most 1000 characters long'),
-});
-
-module.exports = { discussionSchema, commentSchema };
\ No newline at end of file
diff --git a/index.html b/index.html
index 28db3f13..b6d940d0 100644
--- a/index.html
+++ b/index.html
@@ -4,15 +4,7 @@
-
Github Tracker - Track and Analyze GitHub User Activity
-
-
-
-
-
-
-
-
+ Github Tracker
diff --git a/package.json b/package.json
index 26a614ff..43ad31cc 100644
--- a/package.json
+++ b/package.json
@@ -3,27 +3,26 @@
"private": true,
"version": "0.0.0",
"type": "module",
+
"scripts": {
"dev": "vite --host",
"build": "vite build",
"lint": "eslint .",
"test": "vitest",
- "test:client": "vitest",
"test:backend": "jasmine spec/**/*.spec.cjs",
"preview": "vite preview",
"docker:dev": "docker compose --profile dev up --build",
"docker:prod": "docker compose --profile prod up -d --build"
},
+
"dependencies": {
"@emotion/react": "^11.11.3",
"@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.15.6",
"@mui/material": "^5.15.6",
- "@octokit/core": "^7.0.6",
"@primer/octicons-react": "^19.25.0",
"@vitejs/plugin-react": "^4.3.3",
"axios": "^1.7.7",
- "connect-mongo": "^5.1.0",
"express": "^5.2.1",
"framer-motion": "^12.23.12",
"lucide-react": "^0.525.0",
@@ -32,40 +31,51 @@
"postcss": "^8.4.47",
"react": "^18.3.1",
"react-dom": "^18.3.1",
- "react-github-calendar": "^5.0.6",
"react-hot-toast": "^2.4.1",
"react-icons": "^5.3.0",
"react-router-dom": "^6.28.0",
"recharts": "^3.8.1",
"tailwindcss": "^3.4.14"
},
+
"devDependencies": {
"@eslint/js": "^9.13.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
- "@types/jasmine": "^5.1.15",
+ "@types/jasmine": "^5.1.8",
"@types/node": "^22.10.1",
"@types/react": "^18.3.23",
"@types/react-dom": "^18.3.7",
"@types/react-redux": "^7.1.34",
"@types/react-router-dom": "^5.3.3",
+
"@vitejs/plugin-react-swc": "^3.5.0",
+
"autoprefixer": "^10.4.20",
"bcryptjs": "^3.0.3",
+
"eslint": "^9.13.0",
"eslint-plugin-react": "^7.37.2",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.14",
+
"express-session": "^1.18.2",
+
"globals": "^15.11.0",
+
"jasmine": "^5.13.0",
"jasmine-spec-reporter": "^7.0.0",
+
"jsdom": "^29.1.1",
+
"passport": "^0.7.0",
"passport-local": "^1.0.0",
+
"supertest": "^7.2.2",
+
"typescript-eslint": "^8.59.3",
+
"vite": "^5.4.10",
"vitest": "^4.1.6"
}
diff --git a/public/_redirects b/public/_redirects
index b1b05574..4e31746d 100644
--- a/public/_redirects
+++ b/public/_redirects
@@ -1,10 +1,2 @@
-# Don't redirect robots.txt and other static files
-/robots.txt /robots.txt 200
-/favicon.ico /favicon.ico 200
-/crl.png /crl.png 200
-/crl-icon.png /crl-icon.png 200
-/vite.svg /vite.svg 200
-
-# Catch-all SPA route handler
/* /index.html 200
diff --git a/public/robots.txt b/public/robots.txt
deleted file mode 100644
index 2f30bf85..00000000
--- a/public/robots.txt
+++ /dev/null
@@ -1,7 +0,0 @@
-User-agent: *
-Allow: /
-Disallow: /api/
-Disallow: /admin/
-Crawl-delay: 1
-
-+Sitemap: https://github-spy.netlify.app/sitemap.xml
diff --git a/spec/session.config.spec.cjs b/spec/session.config.spec.cjs
deleted file mode 100644
index 7722904a..00000000
--- a/spec/session.config.spec.cjs
+++ /dev/null
@@ -1,115 +0,0 @@
-const express = require('express');
-const request = require('supertest');
-const session = require('express-session');
-
-const { SESSION_TTL_SECONDS, createSessionConfig } = require('../backend/config/session');
-
-class TestSessionStore extends session.Store {
- constructor() {
- super();
- this.sessions = new Map();
- }
-
- get(sid, callback) {
- const sessionData = this.sessions.get(sid);
- callback(null, sessionData ? JSON.parse(sessionData) : null);
- }
-
- set(sid, sessionData, callback) {
- this.sessions.set(sid, JSON.stringify(sessionData));
- callback(null);
- }
-
- destroy(sid, callback) {
- this.sessions.delete(sid);
- callback(null);
- }
-}
-
-function createStoreFactory(store = new TestSessionStore()) {
- const calls = [];
-
- return {
- calls,
- store,
- create(options) {
- calls.push(options);
- return store;
- },
- };
-}
-
-describe('Session configuration', () => {
- it('initializes connect-mongo with the configured Mongo URL and TTL', () => {
- const storeFactory = createStoreFactory();
-
- const config = createSessionConfig({
- mongoUrl: 'mongodb://127.0.0.1:27017/github_tracker_test',
- sessionSecret: 'test-secret',
- storeFactory,
- });
-
- expect(storeFactory.calls).toEqual([{
- mongoUrl: 'mongodb://127.0.0.1:27017/github_tracker_test',
- ttl: SESSION_TTL_SECONDS,
- }]);
- expect(config.store).toBe(storeFactory.store);
- expect(SESSION_TTL_SECONDS).toBe(14 * 24 * 60 * 60);
- });
-
- it('does not fall back to express-session MemoryStore', () => {
- const config = createSessionConfig({
- mongoUrl: 'mongodb://127.0.0.1:27017/github_tracker_test',
- sessionSecret: 'test-secret',
- storeFactory: createStoreFactory(),
- });
-
- expect(config.store).toBeDefined();
- expect(config.store instanceof session.MemoryStore).toBeFalse();
- });
-
- it('uses secure cookies in production only', () => {
- const productionConfig = createSessionConfig({
- mongoUrl: 'mongodb://127.0.0.1:27017/github_tracker_test',
- sessionSecret: 'test-secret',
- nodeEnv: 'production',
- storeFactory: createStoreFactory(),
- });
- const developmentConfig = createSessionConfig({
- mongoUrl: 'mongodb://127.0.0.1:27017/github_tracker_test',
- sessionSecret: 'test-secret',
- nodeEnv: 'development',
- storeFactory: createStoreFactory(),
- });
-
- expect(productionConfig.cookie.secure).toBeTrue();
- expect(developmentConfig.cookie.secure).toBeFalse();
- });
-
- it('requires MongoDB configuration for persistent sessions', () => {
- expect(() => createSessionConfig({
- mongoUrl: '',
- sessionSecret: 'test-secret',
- storeFactory: createStoreFactory(),
- })).toThrowError(/MONGO_URI/);
- });
-
- it('persists session data through the configured store', async () => {
- const app = express();
-
- app.use(session(createSessionConfig({
- mongoUrl: 'mongodb://127.0.0.1:27017/github_tracker_test',
- sessionSecret: 'test-secret',
- storeFactory: createStoreFactory(),
- })));
- app.get('/count', (req, res) => {
- req.session.views = (req.session.views || 0) + 1;
- res.json({ views: req.session.views });
- });
-
- const agent = request.agent(app);
-
- await agent.get('/count').expect(200, { views: 1 });
- await agent.get('/count').expect(200, { views: 2 });
- });
-});
diff --git a/src/App.css b/src/App.css
index 1d145ec2..b9d355df 100644
--- a/src/App.css
+++ b/src/App.css
@@ -11,11 +11,9 @@
will-change: filter;
transition: filter 300ms;
}
-
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
-
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@@ -24,7 +22,6 @@
from {
transform: rotate(0deg);
}
-
to {
transform: rotate(360deg);
}
@@ -43,11 +40,3 @@
.read-the-docs {
color: #888;
}
-
-.calendar-container svg text {
- fill: #1f2937 !important;
-}
-
-.dark .calendar-container svg text {
- fill: #d1d5db !important;
-}
\ No newline at end of file
diff --git a/src/App.tsx b/src/App.tsx
index 16a29fdb..8eafb448 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -2,11 +2,8 @@ import { useLocation } from "react-router-dom";
import Navbar from "./components/Navbar";
import Footer from "./components/Footer";
import ScrollProgressBar from "./components/ScrollProgressBar";
-import ScrollNavigator from "./components/ScrollNavigator";
import { Toaster } from "react-hot-toast";
import Router from "./Routes/Router";
-import ThemeWrapper from "./context/ThemeContext";
-import ChatbotWidget from "./components/Chatbot/ChatbotWidget";
const FULLSCREEN_ROUTES = ["/signup", "/login"];
@@ -19,16 +16,13 @@ function App() {
{!isFullscreen && }
{!isFullscreen && }
-
-
+
{!isFullscreen && }
-
-
{
return (
@@ -23,16 +21,10 @@ const Router = () => {
} />
} />
} />
- } />
} />
- } />
- } />
{/* Privacy Policy page route */}
} />
-
- {/* 404 Not Found Catch-All Route */}
- } />
);
};
diff --git a/src/components/ActivityFeed.test.tsx b/src/components/ActivityFeed.test.tsx
deleted file mode 100644
index 077e179d..00000000
--- a/src/components/ActivityFeed.test.tsx
+++ /dev/null
@@ -1,71 +0,0 @@
-import { render, screen, waitFor } from '@testing-library/react';
-import { vi } from 'vitest';
-import ActivityFeed from './ActivityFeed';
-
-// 1. Capture the original global fetch to prevent side effects
-const originalFetch = global.fetch;
-
-const mockEvents = [
- {
- id: '12345',
- type: 'PushEvent',
- created_at: new Date().toISOString(),
- repo: { name: 'GitMetricsLab/github_tracker' }
- }
-];
-
-// Helper to generate a full Response-like object to satisfy TypeScript
-const createMockResponse = (data: any): Partial => ({
- ok: true,
- status: 200,
- statusText: 'OK',
- json: async () => data,
-});
-
-describe('ActivityFeed Component', () => {
- beforeAll(() => {
- // Mock fetch before the suite runs
- global.fetch = vi.fn();
- });
-
- afterEach(() => {
- // Clear mock history between individual tests
- vi.clearAllMocks();
- });
-
- afterAll(() => {
- // 2. Restore original fetch after the suite finishes to prevent leaks
- global.fetch = originalFetch;
- });
-
- it('displays the loading state initially', () => {
- vi.mocked(global.fetch).mockResolvedValueOnce(createMockResponse([]) as Response);
-
- render();
-
- expect(screen.getByText('Loading...')).toBeInTheDocument();
- });
-
- it('renders activity events after successful fetch', async () => {
- vi.mocked(global.fetch).mockResolvedValueOnce(createMockResponse(mockEvents) as Response);
-
- render();
-
- await waitFor(() => {
- expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
- });
-
- expect(screen.getByText('๐ Commit pushed')).toBeInTheDocument();
- expect(screen.getByText(/GitMetricsLab\/github_tracker/)).toBeInTheDocument();
- });
-
- it('displays a fallback message when no activity is found', async () => {
- vi.mocked(global.fetch).mockResolvedValueOnce(createMockResponse([]) as Response);
-
- render();
-
- await waitFor(() => {
- expect(screen.getByText('No activity found')).toBeInTheDocument();
- });
- });
-});
diff --git a/src/components/ActivityFeed.tsx b/src/components/ActivityFeed.tsx
index 4773f810..d770dfee 100644
--- a/src/components/ActivityFeed.tsx
+++ b/src/components/ActivityFeed.tsx
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
-import EmptyState from "./EmptyState";
+
interface EventType {
id: string;
type: string;
@@ -12,8 +12,8 @@ interface EventType {
export default function ActivityFeed({ username }: { username: string }) {
const [events, setEvents] = useState([]);
const [loading, setLoading] = useState(true);
- const [error, setError] = useState("");
+ // ๐ time ago function
const getTimeAgo = (dateString: string) => {
const diff = Math.floor(
(Date.now() - new Date(dateString).getTime()) / 1000
@@ -27,50 +27,18 @@ export default function ActivityFeed({ username }: { username: string }) {
useEffect(() => {
const fetchEvents = async () => {
- if (!username.trim()) {
- setEvents([]);
- setError("Please enter a GitHub username to get started.");
- setLoading(false);
- return;
- }
-
try {
setLoading(true);
- setError("");
const res = await fetch(
`https://api.github.com/users/${username}/events`
);
-
- if (!res.ok) {
- let message = "Unable to load activity. Please try again.";
- if (res.status === 404) {
- message = "GitHub user not found. Please check the username.";
- } else if (res.status === 403) {
- message =
- "GitHub rate limit exceeded. Wait a moment and try again.";
- }
- setEvents([]);
- setError(message);
- setLoading(false);
- return;
- }
-
const data = await res.json();
- if (!Array.isArray(data)) {
- setError("Unexpected response from GitHub. Please try again.");
- setEvents([]);
- setLoading(false);
- return;
- }
-
setEvents(data);
+ setLoading(false);
} catch (err) {
console.error(err);
- setError("Unable to fetch activity. Check your connection and try again.");
- setEvents([]);
- } finally {
setLoading(false);
}
};
@@ -81,59 +49,40 @@ export default function ActivityFeed({ username }: { username: string }) {
return () => clearInterval(interval);
}, [username]);
- const currentEvents = events.slice(0, 10);
-
return (
-
-
-
-
Activity Feed
-
- Tracking {username}
-
-
-
- Refreshes every 30s
-
-
+
+
+ Activity Feed
+
{loading ? (
-
- Loading GitHub activity...
-
- ) : error ? (
-
- {error}
-
- ) : currentEvents.length === 0 ? (
-
- No recent public activity found for this user.
-
+
Loading...
+ ) : events.length === 0 ? (
+
No activity found
) : (
-
- {currentEvents.map((event) => (
-
-
- {event.type === "PushEvent" && "๐ Commit pushed"}
- {event.type === "PullRequestEvent" && "๐ Pull request event"}
- {event.type === "IssuesEvent" && "๐ Issue event"}
- {event.type === "WatchEvent" && "โญ Starred repository"}
- {![
- "PushEvent",
- "PullRequestEvent",
- "IssuesEvent",
- "WatchEvent",
- ].includes(event.type) && event.type}
-
-
- {event.repo?.name || "Unknown repository"} โข {getTimeAgo(event.created_at)}
-
-
- ))}
-
+ events.slice(0, 10).map((event) => (
+
+
+ {event.type === "PushEvent" && "๐ Commit pushed"}
+ {event.type === "PullRequestEvent" && "๐ Pull Request"}
+ {event.type === "IssuesEvent" && "๐ Issue"}
+ {event.type === "WatchEvent" && "โญ Starred repo"}
+ {![
+ "PushEvent",
+ "PullRequestEvent",
+ "IssuesEvent",
+ "WatchEvent",
+ ].includes(event.type) && event.type}
+
+
+
+ {event.repo?.name} โข {getTimeAgo(event.created_at)}
+
+
+ ))
)}
);
diff --git a/src/components/AuthShell.tsx b/src/components/AuthShell.tsx
deleted file mode 100644
index afe9cd3f..00000000
--- a/src/components/AuthShell.tsx
+++ /dev/null
@@ -1,101 +0,0 @@
-import React, { ReactNode } from "react";
-
-type Highlight = {
- title: string;
- description: string;
-};
-
-interface AuthShellProps {
- mode: "dark" | "light";
- badge: string;
- title: string;
- subtitle: string;
- highlights: Highlight[];
- children: ReactNode;
- footer: ReactNode;
-}
-
-const AuthShell: React.FC
= ({ mode, badge, title, subtitle, highlights, children, footer }) => {
- const surfaceClass =
- mode === "dark"
- ? "bg-slate-950 text-white"
- : "bg-[radial-gradient(circle_at_top,_rgba(255,255,255,0.92),_rgba(226,232,240,0.92))] text-slate-900";
-
- const panelClass =
- mode === "dark"
- ? "border-white/10 bg-white/10 text-white shadow-[0_24px_80px_rgba(15,23,42,0.45)]"
- : "border-slate-200/80 bg-white/85 text-slate-900 shadow-[0_24px_80px_rgba(15,23,42,0.12)]";
-
- return (
-
-
-
-
-
-
-
-
- {children}
-
{footer}
-
-
-
-
- );
-};
-
-export default AuthShell;
\ No newline at end of file
diff --git a/src/components/Chatbot/ChatbotWidget.tsx b/src/components/Chatbot/ChatbotWidget.tsx
deleted file mode 100644
index c6339fc8..00000000
--- a/src/components/Chatbot/ChatbotWidget.tsx
+++ /dev/null
@@ -1,511 +0,0 @@
-import { useState, useRef, useEffect, ReactNode } from "react";
-import { getBotResponse, defaultMessage } from "./chatbotData";
-
-interface Message {
- id: number;
- role: "bot" | "user";
- text: string;
- time: string;
-}
-
-function getTime() {
- return new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
-}
-
-function renderMarkdown(text: string): ReactNode {
- const lines = text.split("\n");
- const elements: ReactNode[] = [];
- let key = 0;
-
- for (let i = 0; i < lines.length; i++) {
- const line = lines[i];
-
- // Code block start
- if (line.startsWith("```")) {
- const codeLines: string[] = [];
- i++;
- while (i < lines.length && !lines[i].startsWith("```")) {
- codeLines.push(lines[i]);
- i++;
- }
- elements.push(
-
- {codeLines.join("\n")}
-
- );
- continue;
- }
-
- // Table row
- if (line.startsWith("|")) {
- const cells = line.split("|").filter((c) => c.trim() !== "");
- if (cells.every((c) => c.trim().replace(/-/g, "") === "")) continue; // separator row
- elements.push(
-
- {cells.map((cell, ci) => (
-
- {cell.trim()}
-
- ))}
-
- );
- continue;
- }
-
- // List item
- if (line.match(/^[-*โข]\s/) || line.match(/^\d+\.\s/)) {
- elements.push(
-
- โบ
- {inlineFormat(line.replace(/^[-*โข]\s/, "").replace(/^\d+\.\s/, ""))}
-
- );
- continue;
- }
-
- // Empty line โ spacer
- if (line.trim() === "") {
- elements.push();
- continue;
- }
-
- // Normal line
- elements.push(
-
- {inlineFormat(line)}
-
- );
- }
-
- return <>{elements}>;
-}
-
-function inlineFormat(text: string): ReactNode[] {
- // Handles **bold**, `inline code`, [link](url), โ
emoji checkboxes
- const parts: (JSX.Element | string)[] = [];
- const regex = /\*\*(.+?)\*\*|`([^`]+)`|\[([^\]]+)\]\(([^)]+)\)/g;
- let last = 0;
- let match;
- let key = 0;
-
- while ((match = regex.exec(text)) !== null) {
- if (match.index > last) parts.push(text.slice(last, match.index));
- if (match[1]) {
- parts.push({match[1]});
- } else if (match[2]) {
- parts.push(
-
- {match[2]}
-
- );
- } else if (match[3] && match[4]) {
- const href = match[4];
- const isSafe = href.startsWith("http://") || href.startsWith("https://") || href.startsWith("/");
- if (!isSafe) {
- parts.push({match[3]});
- } else {
- parts.push(
-
- {match[3]}
-
- );
- }
- }
- last = match.index + match[0].length;
- }
- if (last < text.length) parts.push(text.slice(last));
- return parts;
-}
-
-export default function ChatbotWidget() {
- const [open, setOpen] = useState(false);
- const nextId = useRef(2);
- const [messages, setMessages] = useState([
- { id: 1, role: "bot", text: defaultMessage, time: getTime() },
- ]);
- const [input, setInput] = useState("");
- const [typing, setTyping] = useState(false);
- const bottomRef = useRef(null);
-
- useEffect(() => {
- bottomRef.current?.scrollIntoView({ behavior: "smooth" });
- }, [messages, typing, open]);
-
- const sendMessage = () => {
- const trimmed = input.trim();
- if (!trimmed) return;
-
- const userMsg: Message = { id: nextId.current++, role: "user", text: trimmed, time: getTime() };
- setMessages((prev) => [...prev, userMsg]);
- setInput("");
- setTyping(true);
-
- setTimeout(() => {
- const botReply = getBotResponse(trimmed);
- setMessages((prev) => [
- ...prev,
- { id: nextId.current++, role: "bot", text: botReply, time: getTime() },
- ]);
- setTyping(false);
- }, 700);
- };
-
- const handleKey = (e: React.KeyboardEvent) => {
- if (e.key === "Enter" && !e.nativeEvent.isComposing) sendMessage();
-};
-
- const quickQuestions = [
- "How do I get a GitHub token?",
- "How do I set up the project?",
- "What does this app do?",
- "How do I fix MongoDB errors?",
- ];
-
- return (
- <>
- {/* Floating bubble */}
-
-
- {/* Chat window */}
- {open && (
-
-
-
- {/* Header */}
-
-
๐ค
-
-
GitHub Tracker Assistant
-
Always here to help
-
-
-
-
- {/* Messages */}
-
- {messages.map((msg) => (
-
- {/* Avatar */}
-
- {msg.role === "bot" ? "๐ค" : "๐ค"}
-
-
- {/* Bubble */}
-
-
- {msg.role === "bot" ? renderMarkdown(msg.text) : {msg.text}}
-
-
- {msg.time}
-
-
-
- ))}
-
- {/* Typing indicator */}
- {typing && (
-
-
- ๐ค
-
-
- {[0, 0.2, 0.4].map((delay, i) => (
-
- ))}
-
-
- )}
-
-
-
- {/* Quick questions */}
- {messages.length <= 1 && (
-
- {quickQuestions.map((q) => (
-
- ))}
-
- )}
-
- {/* Input bar */}
-
- setInput(e.target.value)}
- onKeyDown={handleKey}
- onFocus={(e) => { e.currentTarget.style.boxShadow = "0 0 0 2px rgba(99,102,241,0.6)"; }}
- onBlur={(e) => { e.currentTarget.style.boxShadow = "none"; }}
- placeholder="Ask me anything..."
- style={{
- flex: 1,
- background: "rgba(255,255,255,0.06)",
- border: "1px solid rgba(99,102,241,0.3)",
- borderRadius: "10px",
- padding: "9px 13px",
- color: "#e2e8f0",
- fontSize: "13px",
- outline: "none",
- }}
- />
-
-
-
- )}
- >
- );
-}
\ No newline at end of file
diff --git a/src/components/Chatbot/chatbotData.ts b/src/components/Chatbot/chatbotData.ts
deleted file mode 100644
index eee6a110..00000000
--- a/src/components/Chatbot/chatbotData.ts
+++ /dev/null
@@ -1,179 +0,0 @@
-export interface FAQ {
- keywords: string[];
- answer: string;
-}
-
-export const faqs: FAQ[] = [
- {
- keywords: ["token", "access token", "github token", "generate token", "personal access token", "pat"],
- answer: `๐ **How to get a GitHub Token:**
-
-1. Go to [GitHub Settings โ Developer Settings โ Tokens](https://github.com/settings/tokens)
-2. Click **"Generate new token (classic)"**
-3. Give it a name and set an expiration date
-4. Select scopes: โ
\`public_repo\` and โ
\`read:user\`
-5. Click **"Generate Token"**
-6. **Copy it immediately** โ you won't see it again!
-
-Then paste it into the input field when prompted in the app.`,
- },
- {
- keywords: ["install", "setup", "clone", "start", "run", "frontend", "npm", "how to use", "get started", "begin"],
- answer: `๐ **Frontend Setup:**
-
-\`\`\`bash
-git clone https://github.com/mehul-m-prajapati/github_tracker.git
-cd github_tracker
-npm install
-npm run dev
-\`\`\`
-
-Then open **http://localhost:5173** in your browser.`,
- },
- {
- keywords: ["backend", "server", "api", "express", "node"],
- answer: `๐ง **Backend Setup:**
-
-\`\`\`bash
-cd backend
-npm install
-npm start
-\`\`\`
-
-The backend runs at **http://localhost:5000**
-
-> Make sure you have a \`.env\` file in the \`backend/\` folder with \`MONGO_URI\` and \`SESSION_SECRET\` set.`,
- },
- {
- keywords: ["mongodb", "mongo", "database", "db", "connection", "connect"],
- answer: `๐๏ธ **MongoDB Setup:**
-
-1. Install [MongoDB Community](https://www.mongodb.com/try/download/community)
-2. Start it by running \`mongod\` in your terminal
-3. Create a \`backend/.env\` file:
-
-\`\`\`
-MONGO_URI=mongodb://127.0.0.1:27017/github_tracker
-SESSION_SECRET=anysecretstring
-\`\`\`
-
-If MongoDB keeps failing, you can still use the frontend features independently.`,
- },
- {
- keywords: ["test", "jasmine", "spec", "testing", "run test"],
- answer: `โ
**Running Tests:**
-
-\`\`\`bash
-mongod # Start MongoDB first
-npx jasmine # Run the tests
-\`\`\`
-
-Test files are in the \`spec/\` directory:
-- \`spec/user.model.spec.cjs\`
-- \`spec/auth.routes.spec.cjs\`
-
-If you see "No specs found", make sure files are named \`*.spec.js\` or \`*.spec.cjs\`.`,
- },
- {
- keywords: ["what is", "about", "purpose", "app", "tool", "tracker", "github tracker", "features", "does it do"],
- answer: `๐ **About GitHub Tracker:**
-
-GitHub Tracker is a full-stack web app to **track and analyze GitHub user activity**.
-
-**Features include:**
-- โญ Stars & Forks tracking
-- ๐ Issues monitoring
-- ๐ Open & Closed PRs
-- ๐ Activity Analytics
-- ๐ฅ Multi-User Tracking
-- ๐ Smart Search
-
-**Tech Stack:** React + Vite, Tailwind + MUI, Node.js + Express, MongoDB, Passport.js`,
- },
- {
- keywords: ["auth", "login", "passport", "authentication", "sign in", "signup"],
- answer: `๐ **Authentication:**
-
-The app uses **Passport.js** for backend authentication.
-
-- You need to be logged in to access certain features
-- Sessions are managed via MongoDB
-- Make sure the backend is running for login to work`,
- },
- {
- keywords: ["error", "issue", "problem", "not working", "broken", "crash", "fail"],
- answer: `๐ ๏ธ **Common Fixes:**
-
-**MongoDB not connecting?**
-โ Run \`mongod\` in a separate terminal and check your \`.env\` file.
-
-**"No specs found" in tests?**
-โ Ensure test files are in \`spec/\` and named \`*.spec.cjs\`.
-
-**Frontend not loading?**
-โ Run \`npm install\` then \`npm run dev\` from the root folder.
-
-**Still stuck?** Open an issue on the [GitHub repo](https://github.com/mehul-m-prajapati/github_tracker/issues).`,
- },
- {
- keywords: ["tech", "stack", "technology", "react", "vite", "tailwind", "express", "built with"],
- answer: `โ๏ธ **Tech Stack:**
-
-| Layer | Technology |
-|-------|-----------|
-| Frontend | React + Vite + TypeScript |
-| Styling | Tailwind CSS + MUI |
-| HTTP | Axios |
-| Backend | Node.js + Express |
-| Database | MongoDB |
-| Auth | Passport.js |
-| Testing | Jasmine + Supertest |`,
- },
- {
- keywords: ["contribute", "contribution", "pr", "pull request", "open source", "gssoc"],
- answer: `๐ค **How to Contribute:**
-
-1. Fork the repo on GitHub
-2. Create a branch: \`git checkout -b feature/your-feature\`
-3. Make your changes
-4. Commit: \`git commit -m "feat: describe your change"\`
-5. Push: \`git push origin feature/your-feature\`
-6. Open a **Pull Request** and link the issue number
-
-This project is part of **GSSoC 2026** ๐`,
- },
-];
-
-export const defaultMessage = `๐ Hi! I'm the **GitHub Tracker Assistant**.
-
-I can help you with:
-- ๐ Getting a GitHub access token
-- ๐ Setting up the project
-- ๐๏ธ MongoDB configuration
-- โ
Running tests
-- ๐ Understanding the app features
-- ๐ค How to contribute
-
-Just type your question below!`;
-
-export function getBotResponse(userInput: string): string {
- const input = userInput.toLowerCase().trim();
-
- if (!input) return "Please type a question and I'll do my best to help!";
-
- for (const faq of faqs) {
- if (faq.keywords.some((kw) => input.includes(kw))) {
- return faq.answer;
- }
- }
-
- return `๐ค I'm not sure about that specific question. Try asking about:
-
-- **GitHub token** โ how to generate one
-- **Setup** โ how to install and run the project
-- **MongoDB** โ database connection issues
-- **Tests** โ how to run Jasmine tests
-- **Features** โ what the app does
-
-Or open an issue on [GitHub](https://github.com/mehul-m-prajapati/github_tracker/issues) for more help!`;
-}
diff --git a/src/components/CodingPersonaWidget.tsx b/src/components/CodingPersonaWidget.tsx
deleted file mode 100644
index 00b03d53..00000000
--- a/src/components/CodingPersonaWidget.tsx
+++ /dev/null
@@ -1,90 +0,0 @@
-import React from 'react';
-import { calculateCodingPersona, ContributionItem } from '../utils/persona';
-import { useTheme } from '@mui/material/styles';
-
-interface CodingPersonaWidgetProps {
- issues?: ContributionItem[];
- pullRequests?: ContributionItem[];
-}
-
-export function CodingPersonaWidget({ issues = [], pullRequests = [] }: CodingPersonaWidgetProps) {
- // ๐ Access the active Material UI theme context dynamically
- const theme = useTheme();
- const isDarkMode = theme.palette.mode === 'dark';
-
- const allContributions = [...issues, ...pullRequests];
- const { personaTitle, earlyBirdPercent, nightOwlPercent, totalCount } = calculateCodingPersona(allContributions);
-
- if (totalCount === 0) return null;
-
- return (
-
-
-
- {/* Left Side: Details Text Box */}
-
-
- Developer Persona Analyzer
-
-
- {personaTitle}
-
-
- Calculated across {totalCount} records
-
-
-
- {/* Right Side: Progress Meter Sliders */}
-
-
-
-
- Morning: {earlyBirdPercent}%
-
-
-
- Night: {nightOwlPercent}%
-
-
-
- {/* Outer Progress Track Backdrop */}
-
-
-
-
-
- );
-}
\ No newline at end of file
diff --git a/src/components/ContributionRecommender.tsx b/src/components/ContributionRecommender.tsx
deleted file mode 100644
index f8d4d6c6..00000000
--- a/src/components/ContributionRecommender.tsx
+++ /dev/null
@@ -1,686 +0,0 @@
-import React, { useEffect, useRef } from 'react';
-import { motion, AnimatePresence } from 'framer-motion';
-import {
- Box,
- Paper,
- Typography,
- Chip,
- Link,
- CircularProgress,
- Alert,
- Tooltip,
- IconButton,
-} from '@mui/material';
-import { useTheme } from '@mui/material/styles';
-import {
- Sparkles,
- RefreshCw,
- ExternalLink,
- Star,
- BrainCircuit,
- Search,
- Trophy,
- Zap,
-} from 'lucide-react';
-import { Octokit } from '@octokit/core';
-import {
- useContributionRecommender,
- type Recommendation,
- type SkillProfile,
-} from '../hooks/useContributionRecommender';
-
-// โโโ Language color map (mirrors Tracker.tsx) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-
-const LANGUAGE_COLORS: Record = {
- JavaScript: '#f1e05a',
- TypeScript: '#3178c6',
- Python: '#3572A5',
- Java: '#b07219',
- HTML: '#e34c26',
- CSS: '#563d7c',
- C: '#555555',
- 'C++': '#f34b7d',
- 'C#': '#178600',
- PHP: '#4F5D95',
- Ruby: '#701516',
- Go: '#00ADD8',
- Rust: '#dea584',
- Kotlin: '#A97BFF',
- Swift: '#F05138',
- Shell: '#89e051',
- Vue: '#41b883',
- Dart: '#00B4AB',
-};
-
-const getLangColor = (lang: string) => LANGUAGE_COLORS[lang] ?? '#9ca3af';
-
-// โโโ Agent step labels โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-
-const AGENT_STEPS = [
- { icon: BrainCircuit, label: 'Building skill profileโฆ' },
- { icon: Search, label: 'Scouting open issuesโฆ' },
- { icon: Trophy, label: 'Ranking by relevanceโฆ' },
-];
-
-// โโโ Sub-components โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-
-const SkillBadge: React.FC<{ lang: string }> = ({ lang }) => (
-
- }
- sx={{
- fontSize: '0.72rem',
- fontWeight: 600,
- height: 24,
- border: '1px solid',
- borderColor: 'divider',
- bgcolor: 'background.paper',
- '& .MuiChip-label': { pr: 1 },
- }}
- />
-);
-
-const DifficultyBadge: React.FC<{ difficulty: 'Beginner' | 'Intermediate' }> = ({
- difficulty,
-}) => (
-
-);
-
-interface RecommendationCardProps {
- rec: Recommendation;
- index: number;
-}
-
-const RecommendationCard: React.FC = ({ rec, index }) => {
- const theme = useTheme();
- const isDark = theme.palette.mode === 'dark';
-
- const accentColors = ['#6366f1', '#0ea5e9', '#10b981'];
- const accent = accentColors[index % accentColors.length];
-
- return (
-
-
- {/* Repo header */}
-
-
-
-
- {rec.repoName}
-
-
-
-
-
- {rec.repoStars >= 1000
- ? `${(rec.repoStars / 1000).toFixed(1)}k`
- : rec.repoStars}
-
-
-
-
- {/* Issue title */}
-
- #{rec.issueNumber} {rec.issueTitle}
-
-
- {/* Why it matches */}
-
-
-
- {rec.matchReason}
-
-
-
- {/* Labels + Difficulty + CTA */}
-
- {rec.labels.slice(0, 3).map((label) => (
-
- ))}
-
-
-
- View Issue
-
-
-
-
-
-
- );
-};
-
-// โโโ Loading skeleton โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-
-const LoadingSkeleton: React.FC<{ agentStep: 0 | 1 | 2 | 3 }> = ({ agentStep }) => {
- const theme = useTheme();
- const isDark = theme.palette.mode === 'dark';
- const stepIndex = Math.max(0, agentStep - 1);
- const StepIcon = agentStep > 0 ? AGENT_STEPS[stepIndex]?.icon : null;
- const stepLabel = agentStep > 0 ? AGENT_STEPS[stepIndex]?.label : 'Initializingโฆ';
-
- return (
-
- {/* Agent step indicator */}
-
-
- {StepIcon && }
-
- {stepLabel}
-
-
- {[1, 2, 3].map((step) => (
-
- ))}
-
-
-
- {/* Shimmer cards */}
- {[0, 1, 2].map((i) => (
-
-
-
-
-
-
-
-
-
- {[70, 90, 55].map((w) => (
-
- ))}
-
-
- ))}
-
- );
-};
-
-// โโโ Skill Profile Header โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-
-const SkillProfileRow: React.FC<{ profile: SkillProfile }> = ({ profile }) => {
- const ACTIVITY_COLORS: Record = {
- high: '#2ea44f',
- medium: '#b08800',
- low: '#cf222e',
- };
-
- return (
-
-
-
- Your skills:
-
- {profile.topLanguages.map((lang) => (
-
- ))}
-
-
-
- {profile.activityLevel} activity
-
-
-
-
- );
-};
-
-// โโโ Main Component โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-
-interface ContributionRecommenderProps {
- username: string;
- getOctokit: () => Octokit | null;
-}
-
-const ContributionRecommender: React.FC = ({
- username,
- getOctokit,
-}) => {
- const theme = useTheme();
- const isDark = theme.palette.mode === 'dark';
- const hasRunRef = useRef(false);
-
- const {
- recommendations,
- skillProfile,
- recommenderLoading,
- recommenderError,
- agentStep,
- runRecommender,
- } = useContributionRecommender(getOctokit);
-
- // Auto-run once when a username is available
- useEffect(() => {
- if (username && !hasRunRef.current) {
- hasRunRef.current = true;
- runRecommender(username);
- }
- }, [username, runRecommender]);
-
- // Reset auto-run flag if username changes
- useEffect(() => {
- hasRunRef.current = false;
- }, [username]);
-
- if (!username) return null;
-
- return (
-
- {/* Gradient top accent bar */}
-
-
-
- {/* Header */}
-
-
-
-
-
-
-
- AI Contribution Recommender
-
-
- Agentic ยท 3-step pipeline ยท GitHub Search API
-
-
-
-
-
-
- runRecommender(username, true)}
- sx={{
- color: 'text.secondary',
- '&:hover': { color: '#6366f1' },
- transition: 'color 0.2s',
- }}
- >
-
-
-
-
-
-
-
-
- {/* Skill profile */}
- {skillProfile && }
-
- {/* Loading state */}
-
- {recommenderLoading && (
-
-
-
- )}
-
-
- {/* Error state */}
- {!recommenderLoading && recommenderError && (
-
- runRecommender(username, true)}
- sx={{ fontSize: '0.78rem', fontWeight: 700 }}
- >
- Retry
-
- }
- >
- {recommenderError}
-
-
- )}
-
- {/* Recommendations */}
- {!recommenderLoading && recommendations.length > 0 && (
-
-
- Top {recommendations.length} Recommended Issues for You
-
-
-
- {recommendations.map((rec, i) => (
-
- ))}
-
-
-
-
- Powered by GitHub Search API ยท Ranked by skill match, recency & repo popularity
-
-
- )}
-
- {/* Empty state (after load, no error, no results) */}
- {!recommenderLoading && !recommenderError && recommendations.length === 0 && (
-
-
-
- Enter a GitHub username above and fetch your data to get AI-powered recommendations.
-
-
- )}
-
-
- );
-};
-
-export default ContributionRecommender;
diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx
index ea78da14..e28358f0 100644
--- a/src/components/Dashboard.tsx
+++ b/src/components/Dashboard.tsx
@@ -1,4 +1,3 @@
-import { CodingPersonaWidget } from './CodingPersonaWidget';
import React from 'react';
import {
PieChart,
@@ -26,37 +25,22 @@ interface GitHubItem {
}
interface DashboardProps {
+ totalIssues: number;
+ totalPrs: number;
data: GitHubItem[];
theme: Theme;
}
-const Dashboard: React.FC = ({ data, theme }) => {
+const Dashboard: React.FC = ({ totalIssues, totalPrs, data, theme }) => {
- // Count states for state distribution chart
- const openCount = data.filter(
- item => item.state === 'open'
- ).length;
-
- const mergedCount = data.filter(
- item => !!item.pull_request?.merged_at
- ).length;
-
- const closedCount = data.filter(
- item => item.state === 'closed' && !item.pull_request?.merged_at
- ).length;
-
// Data for Pie Chart
const pieData = [
- { name: 'Open', value: openCount, color: theme.palette.success.main },
- { name: 'Closed', value: closedCount, color: theme.palette.error.main },
+ { name: 'Issues', value: totalIssues },
+ { name: 'Pull Requests', value: totalPrs },
];
- if (mergedCount > 0) {
- pieData.push({ name: 'Merged', value: mergedCount, color: theme.palette.secondary.main });
- }
-
- // Filter out states with zero counts to avoid empty chart slices/labels
- const activePieData = pieData.filter(d => d.value > 0);
+ // Use theme-aware colors
+ const COLORS = [theme.palette.primary.main, theme.palette.secondary.main];
// Data for Bar Chart (Top 5 Repositories) - Improved safety
const repoCounts: { [key: string]: number } = {};
@@ -78,7 +62,7 @@ const Dashboard: React.FC = ({ data, theme }) => {
.sort((a, b) => b.count - a.count)
.slice(0, 5);
- const hasData = data.length > 0;
+ const hasData = totalIssues > 0 || totalPrs > 0;
if (!hasData) {
return (
@@ -90,29 +74,19 @@ const Dashboard: React.FC = ({ data, theme }) => {
);
}
- // ๐ Filter data items dynamically into separate buckets for our calculator widget
- const passingIssues = data.filter(item => !item.pull_request);
- const passingPrs = data.filter(item => !!item.pull_request);
-
return (
-
- {/* ๐ BRING IN OUR NEW CALCULATOR ROW (Placed cleanly right above the graphs grid container) */}
-
-
-
-
{/* Pie Chart: Issues vs PRs */}
- State Distribution (Filtered Results)
+ Contribution Mix (Total)
= ({ data, theme }) => {
dataKey="value"
label
>
- {activePieData.map((entry, index) => (
- |
+ {pieData.map((_entry, index) => (
+ |
))}
= ({ data, theme }) => {
- Top Repositories (Filtered Results)
+ Top Repositories (Current View)
{barData.length > 0 ? (
@@ -165,4 +139,4 @@ const Dashboard: React.FC = ({ data, theme }) => {
);
};
-export default Dashboard;
\ No newline at end of file
+export default Dashboard;
diff --git a/src/components/EmptyState.tsx b/src/components/EmptyState.tsx
deleted file mode 100644
index d9157d0b..00000000
--- a/src/components/EmptyState.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-interface EmptyStateProps {
- title: string;
- description?: string;
-}
-
-export default function EmptyState({
- title,
- description,
-}: EmptyStateProps) {
- return (
-
-
๐ญ
-
-
- {title}
-
-
- {description && (
-
- {description}
-
- )}
-
- );
-}
\ No newline at end of file
diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx
index 71ac587b..3ad55184 100644
--- a/src/components/Footer.tsx
+++ b/src/components/Footer.tsx
@@ -1,91 +1,189 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
-import { FaGithub, FaTwitter, FaDiscord, FaArrowRight, FaEnvelope } from 'react-icons/fa';
+import {
+ FaGithub,
+ FaTwitter,
+ FaDiscord,
+ FaArrowRight,
+ FaEnvelope,
+ FaInfoCircle,
+ FaShieldAlt, // โ
Added Privacy Icon
+} from 'react-icons/fa';
function Footer() {
const [email, setEmail] = useState('');
const handleSubscribe = (e: React.FormEvent) => {
e.preventDefault();
- // TODO: wire up to backend
- alert('Thanks โ subscription received!');
+
+ // Replace with API call
+ alert('Thank you for subscribing!');
+
setEmail('');
};
return (
-