Build a DSA Practice Platform
Build a full-stack DSA platform with TF-IDF grouping and contest tracking.
Introduction
30 Second Summary
If you practice coding problems, you probably jump between Codeforces, LeetCode, and other sites every day. Each platform has its own interface, its own filters, and no way to connect your progress across them.
In this project, you will build a full-stack DSA practice platform that pulls problems from multiple competitive programming sites, groups similar problems using a TF-IDF algorithm, and helps you track your study with rich notes and a live contest calendar.
What You'll Build
A full-stack web application with a React frontend, an Express.js REST API, and a PostgreSQL database hosted on Neon that aggregates coding problems, computes similarity scores, and supports authenticated note-taking.
By the end of this project, you'll have:
- A problem browser that aggregates Codeforces and LeetCode problems with filtering by platform, difficulty, rating, and topics.
- A TF-IDF similarity engine that clusters related problems together so you can practice concepts in groups.
- A rich text note editor powered by Tiptap with problem linking, JWT authentication, and a contest calendar with countdown timers.
- Secret Mission: Add a "Problem of the Day" feature that recommends a random problem based on your preferred difficulty and topics.
Are there any prerequisites?
You should be comfortable with basic JavaScript and familiar with using the terminal. No prior experience with React, Express, or PostgreSQL is required.
Set Up the Project and Neon Database
Every full-stack application needs a solid foundation. Your DSA platform has two halves: a React frontend for browsing problems and a Node.js backend that talks to a cloud database.
In this step, you'll create the project skeleton, provision a free Neon PostgreSQL database, and confirm your Express server can connect to it.
In this step, get ready to:
- Create the project directory with client and server subdirectories.
- Set up a free Neon PostgreSQL database and install backend dependencies.
- Build and verify the Express server's database connection.
Create the project structure
You need two separate directories inside one project root. The client folder holds your React frontend built with Vite. The server folder holds your Express API.
- Open the integrated terminal in VS Code by pressing Ctrl+` (macOS and Windows).
- Move to your Desktop by running this command:
cd ~/Desktop
- Create the project root and subdirectories by running these commands:
mkdir dsa-platform
cd dsa-platform
mkdir client server
- Confirm the folders were created by running:
ls
You should see client and server listed in the output.
- Scaffold the React frontend inside client by running these commands:
cd client
npm create vite@latest . -- --template react --no-interactive
What does this command do?
The npm create vite@latest . command scaffolds a new Vite project in the current directory (the dot means "here"). The -- --template react flag tells Vite to use the React template. The --no-interactive flag skips additional prompts so the scaffolding runs unattended.
- Install the client dependencies by running:
npm install
- Initialize the server. Move into the server directory and create a package.json by running:
cd ../server
npm init -y
You should see a confirmation that package.json was created in the server directory.
Seeing a permission error?
Make sure you're running the terminal as your normal user, not as root. If you see EACCES errors on macOS, check that your npm prefix is set to a user-owned directory.
Help me fix npm permission errors on my system.
Sign up for Neon and install backend dependencies
Neon gives you a cloud-hosted PostgreSQL database with zero local installation. The free tier includes 0.5 GB of storage and 100 compute hours per month.
- Open your browser and navigate to https://console.neon.tech/signup.
- Sign up with your email, GitHub, or Google account.
- After signing up, the Neon onboarding asks you to create a project. Enter a project name like dsa-platform and click Create Project.
- On the Project Dashboard, click the Connect button to reveal your connection string.
- Copy the connection string. It starts with postgresql:// and includes your username, password, hostname, and database name.
What is Neon?
Neon is a serverless PostgreSQL provider. It automatically suspends your database after 5 minutes of inactivity on the free tier, which means the first query after a pause takes 1-3 seconds. Subsequent queries are fast.
- Return to your terminal (which should still be in the server directory). Install the backend dependencies by running:
npm install express pg dotenv cors bcrypt jsonwebtoken
What are these packages?
- express is the web framework for handling HTTP routes.
- pg is the Node.js driver for connecting to PostgreSQL databases.
- dotenv loads environment variables from a .env file.
- cors enables cross-origin requests so the React frontend can talk to the Express backend.
- bcrypt hashes passwords for secure user authentication.
- jsonwebtoken creates and verifies JWT tokens for session management.
You should see npm download and add all six packages. Your server/package.json now lists them under dependencies.
- Create the .env file for your server's environment variables by running:
touch .env
- Open the dsa-platform folder in VS Code. Click File in the top menu bar.
- Click Open Folder.
- Select the dsa-platform folder on your Desktop, then click Open.
- In the Explorer sidebar, expand the server folder and click .env to open it.
- Paste the following into .env, replacing the DATABASE_URL placeholder with your Neon connection string:
DATABASE_URL=your-neon-connection-string-here
JWT_SECRET=your_jwt_secret_here
PORT=5000
What should I paste for DATABASE_URL?
Paste the full connection string you copied from the Neon dashboard. It starts with postgresql:// and includes your username, password, hostname, and database name.
Keep JWT_SECRET as any random string for now. You'll use it for authentication in a later step.
- Save .env.
Can't find the connection string?
In the Neon Console, open your project and click the Connect button on the Project Dashboard. Make sure you copy the full string including the ?sslmode=require portion at the end.
Help me find my Neon connection string.
Set up the Express server with a database connection
With your dependencies installed and connection string stored, you can now create the Express server that connects to Neon using the pg library's Pool class.
- In the Explorer sidebar, right-click the server folder and select New File.
- Name the file index.js and press Enter.
- Paste the following code into server/index.js:
const express = require('express');
const { Pool } = require('pg');
const cors = require('cors');
require('dotenv').config();
const app = express();
app.use(cors());
app.use(express.json());
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
ssl: { rejectUnauthorized: false }
});
pool.connect((err, client, release) => {
if (err) {
console.error('Error connecting to database:', err);
} else {
console.log('Connected to Neon PostgreSQL');
release();
}
});
const PORT = process.env.PORT || 5000;
app.listen(PORT, () => console.log(`Server running on port ${PORT}`));
module.exports = { pool };
What does this code do?
- The first four lines import Express, the PostgreSQL driver, CORS middleware, and load environment variables from .env.
- const pool = new Pool(...) creates a connection pool to your Neon database using the DATABASE_URL from your .env file.
- pool.connect(...) tests the connection immediately on startup and logs whether it succeeded or failed.
- The server listens on port 5000 and exports pool so other files can reuse the same database connection.
- Save server/index.js.
- Verify the database connection works by running the server. In your terminal, make sure you are in the server directory, then run:
node index.js
You should see two lines in your terminal: Server running on port 5000 and Connected to Neon PostgreSQL.
Why might the connection take a moment?
Neon's free tier auto-suspends your database after 5 minutes of inactivity. The first connection wakes it up, which can take 1-3 seconds. After that initial wake-up, queries run quickly.
- Press Ctrl+C to stop the server once you've confirmed the connection works.
Seeing an error connecting to the database?
Double-check that your DATABASE_URL in .env is correct. Make sure you copied the full Neon connection string with no extra spaces or line breaks.
If you see a MODULE_NOT_FOUND error, make sure you ran npm install inside the server directory.
Help me debug my Express server connection to Neon.
✔️ Awesome, I've got everything!
Great. Make sure you've saved both server/.env and server/index.js before moving on.
ⓧ I'd like to double check the full code
Here is the full server/index.js file:
const express = require('express');
const { Pool } = require('pg');
const cors = require('cors');
require('dotenv').config();
const app = express();
app.use(cors());
app.use(express.json());
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
ssl: { rejectUnauthorized: false }
});
pool.connect((err, client, release) => {
if (err) {
console.error('Error connecting to database:', err);
} else {
console.log('Connected to Neon PostgreSQL');
release();
}
});
const PORT = process.env.PORT || 5000;
app.listen(PORT, () => console.log(`Server running on port ${PORT}`));
module.exports = { pool };
And your server/.env file:
DATABASE_URL=your-neon-connection-string-here
JWT_SECRET=your_jwt_secret_here
PORT=5000
Your project foundation is solid. You have a React frontend skeleton, an Express backend connected to a live Neon PostgreSQL database, and all the packages you need for authentication and API building. Next up, you'll design the database schema and fill it with real competitive programming problems.
Design the Database Schema and Seed Data
Your Express server is connected to Neon PostgreSQL. The next challenge is deciding how to organize your data so that problems, topics, notes, and user accounts all relate to each other cleanly.
In this step, you will design a relational schema with proper foreign keys and junction tables, then populate it with real coding problems from Codeforces and LeetCode.
In this step, get ready to:
- Design and create PostgreSQL tables for users, problems, topics, notes, and groups.
- Build a seed script that fetches Codeforces problems from their public API.
- Load a LeetCode static dataset into the database.
Design and create the PostgreSQL tables
A relational database stores data across multiple tables linked by foreign keys. Your platform needs tables for users, problems, topics, notes, and groups that cluster similar problems.
- Inside your server folder, create a new folder called db by running this command:
mkdir db
- Create a file called schema.sql inside server/db/ by running:
touch db/schema.sql
- Open server/db/schema.sql in VS Code and paste the following SQL:
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
email VARCHAR(100) UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
preferred_difficulty_min INT DEFAULT 800,
preferred_difficulty_max INT DEFAULT 3500,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS problems (
id SERIAL PRIMARY KEY,
platform VARCHAR(20) NOT NULL,
external_id VARCHAR(100),
title TEXT NOT NULL,
rating INT,
difficulty VARCHAR(20),
url TEXT,
contest_id VARCHAR(50),
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS topics (
id SERIAL PRIMARY KEY,
name VARCHAR(100) UNIQUE NOT NULL
);
CREATE TABLE IF NOT EXISTS problem_topics (
problem_id INT REFERENCES problems(id) ON DELETE CASCADE,
topic_id INT REFERENCES topics(id) ON DELETE CASCADE,
PRIMARY KEY (problem_id, topic_id)
);
CREATE TABLE IF NOT EXISTS notes (
id SERIAL PRIMARY KEY,
user_id INT REFERENCES users(id) ON DELETE CASCADE,
problem_id INT REFERENCES problems(id) ON DELETE SET NULL,
title TEXT,
content JSONB,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS problem_groups (
id SERIAL PRIMARY KEY,
name TEXT,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS problem_group_members (
group_id INT REFERENCES problem_groups(id) ON DELETE CASCADE,
problem_id INT REFERENCES problems(id) ON DELETE CASCADE,
PRIMARY KEY (group_id, problem_id)
);
What does this schema do?
This schema defines seven tables that form the backbone of your platform:
- users stores account credentials and difficulty preferences for recommendations.
- problems holds every coding problem from both platforms, with fields for rating (Codeforces numeric) and difficulty (LeetCode Easy/Medium/Hard).
- topics and problem_topics form a many-to-many relationship, since one problem can have multiple tags and one tag applies to many problems.
- notes stores Tiptap JSON content linked to a user and optionally to a problem.
- problem_groups and problem_group_members will hold TF-IDF similarity clusters later.
Why a junction table for topics?
A junction table (problem_topics) is the standard way to model many-to-many relationships in SQL. Without it, you would need to store topic arrays as text, making queries and filtering much harder.
- Save schema.sql.
- Open the Neon Console in your browser and select your project.
- Click SQL Editor in the left sidebar.
- Select your branch and database from the dropdowns at the top.
- Copy the entire contents of schema.sql and paste it into the editor.
- Click Run.
You should see a success result for each CREATE TABLE statement. The Neon SQL Editor runs multiple statements at once and displays results in separate tabs.
Seeing a permission or syntax error?
- Make sure you selected the correct database in the dropdown (it should match the database name from your connection string).
- Check that every statement ends with a semicolon.
- If you see a "relation already exists" notice, that is fine. The IF NOT EXISTS clause prevents errors on re-runs.
Still stuck? Help me troubleshoot my schema creation in the Neon SQL Editor.
Create the seed script for Codeforces problems
With tables in place, you need data. The Codeforces public API provides thousands of competitive programming problems with ratings and tags. Your seed script will fetch these and insert them into the problems and topics tables.
- Install node-fetch (version 2, which works with CommonJS require()) by running this command from the server directory:
npm install node-fetch@2
Why version 2?
The node-fetch package version 3 is ESM-only and cannot be used with require(). Since this project uses CommonJS modules, version 2 is the correct choice.
- Create the seed file by running:
touch db/seed.js
- Open server/db/seed.js and paste the following code:
const { Pool } = require('pg');
const fetch = require('node-fetch');
require('dotenv').config();
const pool = new Pool({ connectionString: process.env.DATABASE_URL, ssl: { rejectUnauthorized: false } });
async function seedCodeforces() {
const res = await fetch('https://codeforces.com/api/problemset.problems');
const data = await res.json();
const problems = data.result.problems;
for (const p of problems.slice(0, 500)) {
const url = `https://codeforces.com/problemset/problem/${p.contestId}/${p.index}`;
const result = await pool.query(
`INSERT INTO problems (platform, external_id, title, rating, url, contest_id)
VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT DO NOTHING RETURNING id`,
['codeforces', `${p.contestId}${p.index}`, p.name, p.rating || null, url, String(p.contestId)]
);
if (result.rows.length > 0 && p.tags) {
const problemId = result.rows[0].id;
for (const tag of p.tags) {
await pool.query(`INSERT INTO topics (name) VALUES ($1) ON CONFLICT DO NOTHING`, [tag]);
const topicRes = await pool.query(`SELECT id FROM topics WHERE name = $1`, [tag]);
await pool.query(`INSERT INTO problem_topics VALUES ($1, $2) ON CONFLICT DO NOTHING`, [problemId, topicRes.rows[0].id]);
}
}
}
console.log('Codeforces seed done');
}
What does this code do?
- It fetches all problems from the Codeforces API at https://codeforces.com/api/problemset.problems.
- It slices the first 500 problems to keep seeding fast (the API returns thousands).
- For each problem, it builds the direct URL using the pattern https://codeforces.com/problemset/problem/{contestId}/{index}.
- ON CONFLICT DO NOTHING prevents duplicate inserts if you re-run the script.
- Each tag on the problem is inserted into the topics table and linked via problem_topics.
What is the Codeforces API rate limit?
The Codeforces API allows at most 1 request per 2 seconds. Since the seed script makes only one API call to problemset.problems (which returns all problems in a single response), you will not hit the rate limit.
- Save seed.js. Do not run it yet. You will add the LeetCode seeding function next before executing.
Load the LeetCode static dataset and run the seed
LeetCode does not have a public API for fetching problems. Instead, you will use a static JSON file containing problem data with titles, difficulty levels, topic tags, and URL slugs.
- Create the LeetCode data file by running:
touch db/leetcode_problems.json
- Open server/db/leetcode_problems.json and paste the following sample data:
[
{
"title": "Two Sum",
"titleSlug": "two-sum",
"difficulty": "Easy",
"topicTags": [{"name": "Array"}, {"name": "Hash Table"}]
},
{
"title": "Add Two Numbers",
"titleSlug": "add-two-numbers",
"difficulty": "Medium",
"topicTags": [{"name": "Linked List"}, {"name": "Math"}, {"name": "Recursion"}]
},
{
"title": "Longest Substring Without Repeating Characters",
"titleSlug": "longest-substring-without-repeating-characters",
"difficulty": "Medium",
"topicTags": [{"name": "Hash Table"}, {"name": "String"}, {"name": "Sliding Window"}]
},
{
"title": "Median of Two Sorted Arrays",
"titleSlug": "median-of-two-sorted-arrays",
"difficulty": "Hard",
"topicTags": [{"name": "Array"}, {"name": "Binary Search"}, {"name": "Divide and Conquer"}]
},
{
"title": "Longest Palindromic Substring",
"titleSlug": "longest-palindromic-substring",
"difficulty": "Medium",
"topicTags": [{"name": "String"}, {"name": "Dynamic Programming"}]
}
]
Expand this dataset later
This sample has 5 problems to verify the seeding works. You can expand it with hundreds or thousands of entries following the same format. Community-maintained LeetCode datasets are available on GitHub with full problem lists.
- Return to server/db/seed.js and add the LeetCode seed function and the main execution block below the seedCodeforces function:
async function seedLeetCode() {
const leetcodeData = require('./leetcode_problems.json');
for (const p of leetcodeData) {
const url = `https://leetcode.com/problems/${p.titleSlug}/`;
const result = await pool.query(
`INSERT INTO problems (platform, external_id, title, difficulty, url)
VALUES ($1, $2, $3, $4, $5) ON CONFLICT DO NOTHING RETURNING id`,
['leetcode', p.titleSlug, p.title, p.difficulty, url]
);
if (result.rows.length > 0 && p.topicTags) {
const problemId = result.rows[0].id;
for (const tag of p.topicTags) {
await pool.query(`INSERT INTO topics (name) VALUES ($1) ON CONFLICT DO NOTHING`, [tag.name]);
const topicRes = await pool.query(`SELECT id FROM topics WHERE name = $1`, [tag.name]);
await pool.query(`INSERT INTO problem_topics VALUES ($1, $2) ON CONFLICT DO NOTHING`, [problemId, topicRes.rows[0].id]);
}
}
}
console.log('LeetCode seed done');
}
(async () => { await seedCodeforces(); await seedLeetCode(); pool.end(); })();
What does this code do?
- seedLeetCode reads the local JSON file and inserts each problem with its difficulty and topic tags.
- The URL for each LeetCode problem follows the pattern https://leetcode.com/problems/{titleSlug}/.
- The IIFE at the bottom runs both seed functions in sequence, then closes the database connection with pool.end().
- Save seed.js.
- Run the seed script from the server directory by running:
node db/seed.js
You should see Codeforces seed done followed by LeetCode seed done printed in your terminal. The Codeforces fetch may take 10-30 seconds depending on your internet connection.
Why the initial delay?
Neon's free tier auto-suspends your database after 5 minutes of inactivity. The first query after suspension takes 1-3 seconds while the compute wakes up. Subsequent queries will be fast.
- Verify your data by going back to the Neon SQL Editor and running:
SELECT platform, COUNT(*) FROM problems GROUP BY platform;
You should see a row for codeforces with up to 500 entries and a row for leetcode with 5 entries (matching your JSON file).
Seeing a fetch error or empty results?
- If you see Cannot find module 'node-fetch', make sure you ran the install command from the server directory (not from server/db).
- If you see a network error, check your internet connection. The Codeforces API needs to be reachable.
- If the Codeforces count is 0, the API may be temporarily unavailable. Wait a few minutes and try again.
- If you see a relation "problems" does not exist error, you need to run the schema SQL in the Neon SQL Editor first (see the previous substep).
Still stuck? Help me debug my seed script for the DSA platform.
✔️ Awesome, I've got everything!
Great. Double check you have saved both schema.sql and seed.js in your server/db/ folder.
ⓧ I'd like to double check the full code
Here is the complete server/db/seed.js file:
const { Pool } = require('pg');
const fetch = require('node-fetch');
require('dotenv').config();
const pool = new Pool({ connectionString: process.env.DATABASE_URL, ssl: { rejectUnauthorized: false } });
async function seedCodeforces() {
const res = await fetch('https://codeforces.com/api/problemset.problems');
const data = await res.json();
const problems = data.result.problems;
for (const p of problems.slice(0, 500)) {
const url = `https://codeforces.com/problemset/problem/${p.contestId}/${p.index}`;
const result = await pool.query(
`INSERT INTO problems (platform, external_id, title, rating, url, contest_id)
VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT DO NOTHING RETURNING id`,
['codeforces', `${p.contestId}${p.index}`, p.name, p.rating || null, url, String(p.contestId)]
);
if (result.rows.length > 0 && p.tags) {
const problemId = result.rows[0].id;
for (const tag of p.tags) {
await pool.query(`INSERT INTO topics (name) VALUES ($1) ON CONFLICT DO NOTHING`, [tag]);
const topicRes = await pool.query(`SELECT id FROM topics WHERE name = $1`, [tag]);
await pool.query(`INSERT INTO problem_topics VALUES ($1, $2) ON CONFLICT DO NOTHING`, [problemId, topicRes.rows[0].id]);
}
}
}
console.log('Codeforces seed done');
}
async function seedLeetCode() {
const leetcodeData = require('./leetcode_problems.json');
for (const p of leetcodeData) {
const url = `https://leetcode.com/problems/${p.titleSlug}/`;
const result = await pool.query(
`INSERT INTO problems (platform, external_id, title, difficulty, url)
VALUES ($1, $2, $3, $4, $5) ON CONFLICT DO NOTHING RETURNING id`,
['leetcode', p.titleSlug, p.title, p.difficulty, url]
);
if (result.rows.length > 0 && p.topicTags) {
const problemId = result.rows[0].id;
for (const tag of p.topicTags) {
await pool.query(`INSERT INTO topics (name) VALUES ($1) ON CONFLICT DO NOTHING`, [tag.name]);
const topicRes = await pool.query(`SELECT id FROM topics WHERE name = $1`, [tag.name]);
await pool.query(`INSERT INTO problem_topics VALUES ($1, $2) ON CONFLICT DO NOTHING`, [problemId, topicRes.rows[0].id]);
}
}
}
console.log('LeetCode seed done');
}
(async () => { await seedCodeforces(); await seedLeetCode(); pool.end(); })();
And here is the complete server/db/schema.sql file:
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
email VARCHAR(100) UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
preferred_difficulty_min INT DEFAULT 800,
preferred_difficulty_max INT DEFAULT 3500,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS problems (
id SERIAL PRIMARY KEY,
platform VARCHAR(20) NOT NULL,
external_id VARCHAR(100),
title TEXT NOT NULL,
rating INT,
difficulty VARCHAR(20),
url TEXT,
contest_id VARCHAR(50),
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS topics (
id SERIAL PRIMARY KEY,
name VARCHAR(100) UNIQUE NOT NULL
);
CREATE TABLE IF NOT EXISTS problem_topics (
problem_id INT REFERENCES problems(id) ON DELETE CASCADE,
topic_id INT REFERENCES topics(id) ON DELETE CASCADE,
PRIMARY KEY (problem_id, topic_id)
);
CREATE TABLE IF NOT EXISTS notes (
id SERIAL PRIMARY KEY,
user_id INT REFERENCES users(id) ON DELETE CASCADE,
problem_id INT REFERENCES problems(id) ON DELETE SET NULL,
title TEXT,
content JSONB,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS problem_groups (
id SERIAL PRIMARY KEY,
name TEXT,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS problem_group_members (
group_id INT REFERENCES problem_groups(id) ON DELETE CASCADE,
problem_id INT REFERENCES problems(id) ON DELETE CASCADE,
PRIMARY KEY (group_id, problem_id)
);
Your database is populated with real competitive programming problems. Next up, you will build the REST API endpoints that let the frontend query, filter, and paginate through these problems.
Build the Problems REST API
Your database is seeded with hundreds of problems from Codeforces and LeetCode. Now you need a way for the React frontend to access that data.
In this step, you'll build the REST API endpoints that let the frontend query problems with filters, fetch individual problem details, and list all available topics.
In this step, get ready to:
- Build a filtered problems list endpoint with pagination and sorting.
- Build a single problem detail endpoint and a topics list endpoint.
- Test the API endpoints with curl to confirm filtering works.
Build the GET /api/problems endpoint
The problems endpoint is the backbone of your platform. It needs to accept query parameters for platform, difficulty, rating range, topic, sort order, and pagination so the frontend can build a flexible filter UI.
- Create a routes directory inside your server folder by running this command:
mkdir server/routes
- Create a new file called problems.js inside server/routes/ and paste the following code:
const express = require('express');
const router = express.Router();
const { pool } = require('../index');
router.get('/', async (req, res) => {
// Extract filter parameters from the query string
const { platform, difficulty, ratingMin, ratingMax, topic, sort = 'id', page = 1, limit = 20 } = req.query;
const offset = (page - 1) * limit;
let conditions = [];
let params = [];
let idx = 1;
// Build dynamic WHERE conditions based on provided filters
if (platform) { conditions.push(`p.platform = $${idx++}`); params.push(platform); }
if (difficulty) { conditions.push(`p.difficulty = $${idx++}`); params.push(difficulty); }
if (ratingMin) { conditions.push(`p.rating >= $${idx++}`); params.push(Number(ratingMin)); }
if (ratingMax) { conditions.push(`p.rating <= $${idx++}`); params.push(Number(ratingMax)); }
if (topic) {
conditions.push(`p.id IN (SELECT pt.problem_id FROM problem_topics pt JOIN topics t ON pt.topic_id = t.id WHERE t.name = $${idx++})`);
params.push(topic);
}
const where = conditions.length ? 'WHERE ' + conditions.join(' AND ') : '';
// Validate the sort column to prevent SQL injection
const allowedSorts = ['id', 'rating', 'title', 'platform'];
const sortCol = allowedSorts.includes(sort) ? sort : 'id';
// Append pagination params
params.push(Number(limit), Number(offset));
// Aggregate topic names alongside each problem
const query = `SELECT p.*, ARRAY_AGG(DISTINCT t.name) FILTER (WHERE t.name IS NOT NULL) AS topics
FROM problems p
LEFT JOIN problem_topics pt ON p.id = pt.problem_id
LEFT JOIN topics t ON pt.topic_id = t.id
${where}
GROUP BY p.id
ORDER BY p.${sortCol}
LIMIT $${idx++} OFFSET $${idx++}`;
const result = await pool.query(query, params);
res.json(result.rows);
});
module.exports = router;
What does this code do?
This route handler dynamically builds a SQL query based on whatever filters the client sends. Each filter is optional, so the query adapts to whatever combination of parameters arrives.
The ARRAY_AGG function collects all topic names for each problem into a single array. The FILTER (WHERE t.name IS NOT NULL) clause prevents null values from appearing when a problem has no tags.
The allowedSorts array is a safeguard against SQL injection. Only pre-approved column names can be used for sorting.
- Save server/routes/problems.js.
Seeing a module not found error?
If your server crashes when you restart, make sure problems.js lives in server/routes/ (not the project root). Also check that require('../index') points up one level to your server/index.js file.
help me fix a module not found error in my Express routes file
Build the problem detail and topics endpoints
The frontend also needs to fetch a single problem by ID (for the detail page) and list all topics (for the filter dropdown). These are two small endpoints split across two files.
- In server/routes/problems.js, find the line that says module.exports = router; and add the following code above it:
router.get('/:id', async (req, res) => {
// Fetch a single problem with its associated topics
const result = await pool.query(
`SELECT p.*, ARRAY_AGG(DISTINCT t.name) FILTER (WHERE t.name IS NOT NULL) AS topics
FROM problems p
LEFT JOIN problem_topics pt ON p.id = pt.problem_id
LEFT JOIN topics t ON pt.topic_id = t.id
WHERE p.id = $1
GROUP BY p.id`,
[req.params.id]
);
if (!result.rows.length) return res.status(404).json({ error: 'Problem not found' });
res.json(result.rows[0]);
});
What does this code do?
This endpoint returns a single problem object with its topics array. It uses the same ARRAY_AGG pattern as the list endpoint so the frontend gets a consistent shape.
If no problem matches the provided ID, the handler returns a 404 status with an error message instead of an empty result.
- Save server/routes/problems.js.
- Create a new file called topics.js inside server/routes/ and paste the following code:
const express = require('express');
const router = express.Router();
const { pool } = require('../index');
router.get('/', async (req, res) => {
// Return all topics sorted alphabetically for the filter UI
const result = await pool.query('SELECT * FROM topics ORDER BY name');
res.json(result.rows);
});
module.exports = router;
What does this code do?
This endpoint returns every topic in the database sorted alphabetically. The React frontend will use this list to populate a dropdown filter so users can browse problems by tag.
- Save server/routes/topics.js.
Now you need to mount both route files in your main Express app so they respond to requests.
- In server/index.js, find the closing }); of the pool.connect block.
- Add these two lines below it:
app.use('/api/problems', require('./routes/problems'));
app.use('/api/topics', require('./routes/topics'));
What does app.use do here?
The app.use calls mount each router at a URL prefix. Any request to /api/problems gets forwarded to the problems router, and /api/topics goes to the topics router.
Inside each router, paths are relative to the prefix. So router.get('/:id') in the problems router actually handles GET /api/problems/:id.
- Save server/index.js.
Server crashing on save?
Make sure both route files exist at server/routes/problems.js and server/routes/topics.js before restarting. Express will throw an error if a require path doesn't resolve to an existing file.
help me fix my Express server crashing when I mount route files
✔️ Awesome, I've got everything!
Great. Double check you've saved all three files before moving on.
ⓧ I'd like to double check the full code
Here is server/routes/problems.js:
const express = require('express');
const router = express.Router();
const { pool } = require('../index');
router.get('/', async (req, res) => {
const { platform, difficulty, ratingMin, ratingMax, topic, sort = 'id', page = 1, limit = 20 } = req.query;
const offset = (page - 1) * limit;
let conditions = [];
let params = [];
let idx = 1;
if (platform) { conditions.push(`p.platform = $${idx++}`); params.push(platform); }
if (difficulty) { conditions.push(`p.difficulty = $${idx++}`); params.push(difficulty); }
if (ratingMin) { conditions.push(`p.rating >= $${idx++}`); params.push(Number(ratingMin)); }
if (ratingMax) { conditions.push(`p.rating <= $${idx++}`); params.push(Number(ratingMax)); }
if (topic) {
conditions.push(`p.id IN (SELECT pt.problem_id FROM problem_topics pt JOIN topics t ON pt.topic_id = t.id WHERE t.name = $${idx++})`);
params.push(topic);
}
const where = conditions.length ? 'WHERE ' + conditions.join(' AND ') : '';
const allowedSorts = ['id', 'rating', 'title', 'platform'];
const sortCol = allowedSorts.includes(sort) ? sort : 'id';
params.push(Number(limit), Number(offset));
const query = `SELECT p.*, ARRAY_AGG(DISTINCT t.name) FILTER (WHERE t.name IS NOT NULL) AS topics
FROM problems p
LEFT JOIN problem_topics pt ON p.id = pt.problem_id
LEFT JOIN topics t ON pt.topic_id = t.id
${where}
GROUP BY p.id
ORDER BY p.${sortCol}
LIMIT $${idx++} OFFSET $${idx++}`;
const result = await pool.query(query, params);
res.json(result.rows);
});
router.get('/:id', async (req, res) => {
const result = await pool.query(
`SELECT p.*, ARRAY_AGG(DISTINCT t.name) FILTER (WHERE t.name IS NOT NULL) AS topics
FROM problems p
LEFT JOIN problem_topics pt ON p.id = pt.problem_id
LEFT JOIN topics t ON pt.topic_id = t.id
WHERE p.id = $1
GROUP BY p.id`,
[req.params.id]
);
if (!result.rows.length) return res.status(404).json({ error: 'Problem not found' });
res.json(result.rows[0]);
});
module.exports = router;
Here is server/routes/topics.js:
const express = require('express');
const router = express.Router();
const { pool } = require('../index');
router.get('/', async (req, res) => {
const result = await pool.query('SELECT * FROM topics ORDER BY name');
res.json(result.rows);
});
module.exports = router;
Here is server/index.js:
const express = require('express');
const { Pool } = require('pg');
const cors = require('cors');
require('dotenv').config();
const app = express();
app.use(cors());
app.use(express.json());
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
ssl: { rejectUnauthorized: false }
});
pool.connect((err, client, release) => {
if (err) {
console.error('Error connecting to database:', err);
} else {
console.log('Connected to Neon PostgreSQL');
release();
}
});
app.use('/api/problems', require('./routes/problems'));
app.use('/api/topics', require('./routes/topics'));
const PORT = process.env.PORT || 5000;
app.listen(PORT, () => console.log(`Server running on port ${PORT}`));
module.exports = { pool };
Test the API endpoints
With all three endpoints wired up, it's time to confirm they respond correctly. You'll use curl in your terminal to hit each endpoint and inspect the JSON output.
- Restart your server by running:
node server/index.js
- You should see Connected to Neon PostgreSQL and Server running on port 5000 in your terminal.
- Open a second terminal tab.
- Test the problems list endpoint by running:
curl http://localhost:5000/api/problems?limit=3
You should see a JSON array with 3 problem objects. Each object includes fields like id, title, platform, and a topics array.
- Test filtering by platform by running:
curl http://localhost:5000/api/problems?platform=codeforces&limit=3
Every result should show "platform":"codeforces" in the output.
- Test the rating filter by running:
curl "http://localhost:5000/api/problems?ratingMin=1200&ratingMax=1500&limit=3"
Each returned problem should have a rating value between 1200 and 1500.
- Test the single problem endpoint by running:
curl http://localhost:5000/api/problems/1
You should see a single JSON object with all the problem fields and its topics array.
- Test the topics endpoint by running:
curl http://localhost:5000/api/topics
You should see an alphabetically sorted array of topic objects with id and name fields.
What about Neon cold starts?
If your first request takes 1-3 seconds to respond, that's normal. Neon's free tier auto-suspends after 5 minutes of inactivity, so the first query after a pause triggers a cold start. Subsequent requests will be fast.
Getting an empty array or error?
If you get [] back, confirm your seed script ran successfully in the previous step. Run node server/db/seed.js again if needed.
If you see a connection error, check that your .env file contains the correct DATABASE_URL and that the Neon project is active in your dashboard.
help me debug my Express API returning empty results or connection errors
Your REST API is serving problems with full filtering, pagination, and sorting. Next up, you'll add user authentication so learners can save personal notes and preferences.
Add User Authentication with JWT and bcrypt
Your API is wide open right now. Anyone who hits your endpoints can read every problem in the database. That's fine for public data, but you're about to build features like personal notes that belong to individual users.
To protect private data, you need user accounts. In this step, you'll add registration, login, and token-based authentication using two industry-standard tools: bcrypt for password hashing and JWT (JSON Web Tokens) for session management.
In this step, get ready to:
- Build a registration endpoint that hashes passwords and stores users.
- Build a login endpoint that verifies credentials and returns a signed JWT.
- Create an auth middleware that protects routes by verifying tokens.
Build the registration route
When a new user signs up, you need to store their credentials safely. Storing raw passwords is a critical security flaw. Instead, you'll hash each password with bcrypt before saving it.
- Create a new file at server/routes/auth.js by running:
touch server/routes/auth.js
- Open server/routes/auth.js in VS Code and paste the following code:
const express = require('express');
const router = express.Router();
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { pool } = require('../index');
// Register a new user
router.post('/register', async (req, res) => {
const { username, email, password } = req.body;
if (!username || !email || !password) return res.status(400).json({ error: 'All fields required' });
// Hash the password with 10 salt rounds
const passwordHash = await bcrypt.hash(password, 10);
try {
const result = await pool.query(
'INSERT INTO users (username, email, password_hash) VALUES ($1, $2, $3) RETURNING id, username, email',
[username, email, passwordHash]
);
res.status(201).json(result.rows[0]);
} catch (err) {
res.status(400).json({ error: 'Username or email already exists' });
}
});
module.exports = router;
What does this code do?
The register route extracts username, email, and password from the request body. It validates that all three are present before proceeding.
bcrypt.hash(password, 10) runs the password through 10 rounds of salted hashing. This makes stored passwords unreadable even if the database is compromised. The route then inserts the hashed password (never the raw one) into the users table.
The RETURNING id, username, email clause sends back the new user's info without including the password hash in the response.
- Save server/routes/auth.js.
Seeing a module not found error?
If you see an error about bcrypt or jsonwebtoken not being found, confirm you installed them in Step 1. You can check by looking in your server/package.json under dependencies.
help me debug a module not found error for bcrypt or jsonwebtoken in my Express server
Build the login route
Registration stores credentials. Login verifies them. When a user logs in successfully, you return a JWT token that they include in future requests to prove their identity.
Before adding the login route, you need a secret key for signing tokens. This goes in your environment variables so it never leaks into source control.
- Open your .env file in the server folder and add this line below your DATABASE_URL:
JWT_SECRET=your-super-secret-key-change-this
What is JWT_SECRET?
JWT_SECRET is the private key used to sign and verify tokens. Anyone who knows this key can forge valid tokens, so it must stay secret. In production you would use a long random string. For local development, any value works.
- Return to server/routes/auth.js. Find this line near the bottom:
module.exports = router;
- Add the login route directly above module.exports = router; so it looks like this:
// Verify credentials and return a JWT
router.post('/login', async (req, res) => {
const { email, password } = req.body;
const result = await pool.query('SELECT * FROM users WHERE email = $1', [email]);
if (!result.rows.length) return res.status(401).json({ error: 'Invalid credentials' });
const user = result.rows[0];
// Compare the plain password against the stored hash
const valid = await bcrypt.compare(password, user.password_hash);
if (!valid) return res.status(401).json({ error: 'Invalid credentials' });
// Sign a token that expires in 7 days
const token = jwt.sign({ id: user.id, username: user.username }, process.env.JWT_SECRET, { expiresIn: '7d' });
res.json({ token, user: { id: user.id, username: user.username, email: user.email } });
});
module.exports = router;
What does the login route do?
The route looks up the user by email. If no user exists, it returns a generic "Invalid credentials" error. This avoids revealing whether an email is registered.
bcrypt.compare() checks the plain password against the stored hash without ever decrypting it. If valid, jwt.sign() creates a token containing the user's ID and username. The token expires after 7 days.
- Save server/routes/auth.js.
Now mount the auth routes in your server so Express knows where to send requests.
- Open server/index.js. Find this line:
app.use('/api/topics', require('./routes/topics'));
- Add this line directly below it:
app.use('/api/auth', require('./routes/auth'));
- Save server/index.js and restart your server.
- Test the register endpoint by running this in a new terminal:
curl -X POST http://localhost:5000/api/auth/register \
-H "Content-Type: application/json" \
-d '{"username":"testuser","email":"test@example.com","password":"password123"}'
- You should see a JSON response with id, username, and email for the new user.
- Now test the login endpoint by running:
curl -X POST http://localhost:5000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"test@example.com","password":"password123"}'
- You should see a JSON response containing a token string and a user object.
Getting a connection error or 401?
If you see a connection refused error, make sure your server is running with node index.js from inside the server folder. If login returns 401, double-check the email and password match exactly what you used during registration.
help me debug my auth register or login endpoint returning unexpected errors
✔️ Awesome, I've got everything!
Great. Double check you've saved both server/routes/auth.js and server/index.js.
ⓧ I'd like to double check the full code
const express = require('express');
const router = express.Router();
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { pool } = require('../index');
router.post('/register', async (req, res) => {
const { username, email, password } = req.body;
if (!username || !email || !password) return res.status(400).json({ error: 'All fields required' });
const passwordHash = await bcrypt.hash(password, 10);
try {
const result = await pool.query(
'INSERT INTO users (username, email, password_hash) VALUES ($1, $2, $3) RETURNING id, username, email',
[username, email, passwordHash]
);
res.status(201).json(result.rows[0]);
} catch (err) {
res.status(400).json({ error: 'Username or email already exists' });
}
});
router.post('/login', async (req, res) => {
const { email, password } = req.body;
const result = await pool.query('SELECT * FROM users WHERE email = $1', [email]);
if (!result.rows.length) return res.status(401).json({ error: 'Invalid credentials' });
const user = result.rows[0];
const valid = await bcrypt.compare(password, user.password_hash);
if (!valid) return res.status(401).json({ error: 'Invalid credentials' });
const token = jwt.sign({ id: user.id, username: user.username }, process.env.JWT_SECRET, { expiresIn: '7d' });
res.json({ token, user: { id: user.id, username: user.username, email: user.email } });
});
module.exports = router;
const express = require('express');
const { Pool } = require('pg');
const cors = require('cors');
require('dotenv').config();
const app = express();
app.use(cors());
app.use(express.json());
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
ssl: { rejectUnauthorized: false }
});
pool.connect((err, client, release) => {
if (err) {
console.error('Error connecting to database:', err);
} else {
console.log('Connected to Neon PostgreSQL');
release();
}
});
app.use('/api/problems', require('./routes/problems'));
app.use('/api/topics', require('./routes/topics'));
app.use('/api/auth', require('./routes/auth'));
const PORT = process.env.PORT || 5000;
app.listen(PORT, () => console.log(`Server running on port ${PORT}`));
module.exports = { pool };
Create the auth middleware
Tokens are useless if routes don't check them. A middleware function sits between incoming requests and your route handlers, rejecting anything without a valid token.
- Create the middleware directory and file by running:
mkdir server/middleware && touch server/middleware/auth.js
- Open server/middleware/auth.js and paste the following code:
const jwt = require('jsonwebtoken');
// Middleware that verifies JWT tokens on protected routes
module.exports = (req, res, next) => {
// Extract token from the Authorization header (format: "Bearer <token>")
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token) return res.status(401).json({ error: 'No token provided' });
try {
// Verify the token and attach user data to the request
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded;
next();
} catch (err) {
res.status(403).json({ error: 'Invalid token' });
}
};
What does this middleware do?
The middleware reads the Authorization header from each request. It expects the format Bearer <token>. If no token is present, the request is rejected with a 401.
jwt.verify() checks that the token was signed with your JWT_SECRET and hasn't expired. If valid, the decoded payload (user ID and username) is attached to req.user so route handlers can identify who's making the request. If invalid, a 403 is returned.
- Save server/middleware/auth.js.
- Test the middleware by making a request without a token to an endpoint that will use it. Run:
curl http://localhost:5000/api/auth/register
You'll use this middleware in future steps by importing it and passing it as the second argument to any route you want to protect. For now, it's ready and waiting.
Middleware not exporting correctly?
Make sure the file uses module.exports = (req, res, next) => { ... } as a direct function export. If you accidentally wrapped it in an object like module.exports = { auth: ... }, routes won't be able to use it as middleware.
help me understand why my auth middleware isn't working correctly in Express
✔️ Awesome, I've got everything!
Great. Make sure server/middleware/auth.js is saved.
ⓧ I'd like to double check the full code
const jwt = require('jsonwebtoken');
module.exports = (req, res, next) => {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token) return res.status(401).json({ error: 'No token provided' });
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded;
next();
} catch (err) {
res.status(403).json({ error: 'Invalid token' });
}
};
Your platform now has a full authentication system. Users can register, log in, and receive tokens. In the next step, you'll put these tokens to work by building the TF-IDF problem grouping system that clusters similar problems together.
Implement TF-IDF Problem Grouping
Your database is full of problems, but finding related ones means scrolling through hundreds of entries manually. You need a way to mathematically measure how similar two problems are based on their topics and titles.
In this step, you'll implement TF-IDF (Term Frequency-Inverse Document Frequency) from scratch. This algorithm converts each problem into a numerical vector based on its tags and title, then uses cosine similarity to find the closest matches.
In this step, get ready to:
- Implement the TF-IDF algorithm with term frequency and inverse document frequency calculations.
- Build cosine similarity to measure how closely related two problem vectors are.
- Create an API endpoint that returns the top 5 most similar problems for any given problem.
Build the tokenizer and term frequency function
TF-IDF starts by breaking text into individual words (tokens), then counting how often each word appears in a document. This "term frequency" is the first half of the algorithm.
- Create a new folder inside server called utils by running this command from your server directory:
mkdir utils
- Create a new file called tfidf.js inside server/utils/ and add the tokenizer and term frequency functions:
function tokenize(text) {
return text.toLowerCase().replace(/[^a-z0-9 ]/g, '').split(/\s+/).filter(Boolean);
}
function computeTF(tokens) {
const tf = {};
tokens.forEach(t => { tf[t] = (tf[t] || 0) + 1; });
const total = tokens.length;
Object.keys(tf).forEach(k => { tf[k] /= total; });
return tf;
}
What does this code do?
These two functions handle the first stage of TF-IDF.
- tokenize(text) lowercases the input, strips non-alphanumeric characters, and splits on whitespace. This turns "Binary Search Tree" into ["binary", "search", "tree"].
- computeTF(tokens) counts how often each word appears, then divides by the total number of tokens. This gives a ratio between 0 and 1 representing how important each term is within that single document.
- Save server/utils/tfidf.js.
File not saving in the right place?
Make sure the file is at server/utils/tfidf.js, not server/tfidf.js or utils/tfidf.js. The import path in later code depends on this exact location.
help me debug my file structure
Add the inverse document frequency calculation
Term frequency alone has a problem. Common words like "array" appear in almost every problem, making them poor signals for similarity. Inverse Document Frequency penalizes terms that appear in many documents, boosting rare terms that actually distinguish problems from each other.
- In server/utils/tfidf.js, add the computeIDF function below computeTF:
function computeIDF(corpus) {
const idf = {};
const N = corpus.length;
corpus.forEach(doc => {
const unique = new Set(doc);
unique.forEach(term => { idf[term] = (idf[term] || 0) + 1; });
});
Object.keys(idf).forEach(term => { idf[term] = Math.log(N / idf[term]); });
return idf;
}
What does this code do?
- The corpus is an array of token arrays. Each inner array represents one problem's "document" (its tags and title words).
- For each unique term across all documents, it counts how many documents contain that term.
- It then applies Math.log(N / count). Terms appearing in every document get a low IDF score. Rare terms get a high score.
- Save server/utils/tfidf.js.
- You won't see a change yet. The next chunk uses these functions to compute actual similarity scores.
Build cosine similarity and the corpus builder
With TF and IDF ready, you need two more pieces. First, a function that multiplies them together to get TF-IDF vectors. Second, a way to measure how "close" two vectors are using cosine similarity.
- In server/utils/tfidf.js, add these three functions below computeIDF:
function computeTFIDF(tf, idf) {
const tfidf = {};
Object.keys(tf).forEach(term => { tfidf[term] = tf[term] * (idf[term] || 0); });
return tfidf;
}
function cosineSimilarity(vecA, vecB) {
const allTerms = new Set([...Object.keys(vecA), ...Object.keys(vecB)]);
let dot = 0, magA = 0, magB = 0;
allTerms.forEach(term => {
const a = vecA[term] || 0;
const b = vecB[term] || 0;
dot += a * b;
magA += a * a;
magB += b * b;
});
if (magA === 0 || magB === 0) return 0;
return dot / (Math.sqrt(magA) * Math.sqrt(magB));
}
function buildCorpus(problems) {
return problems.map(p => tokenize(`${p.title} ${(p.topics || []).join(' ')}`));
}
What does this code do?
- computeTFIDF(tf, idf) multiplies each term's frequency by its inverse document frequency. This produces a single vector where high values mean "this term is important to this specific document."
- cosineSimilarity(vecA, vecB) measures the angle between two TF-IDF vectors. A score of 1 means identical. A score of 0 means completely unrelated. It handles the edge case where a vector has zero magnitude (an empty document).
- buildCorpus(problems) creates the "document" for each problem by combining its title and topic tags into a single tokenized array. This is what TF-IDF operates on.
Why cosine similarity instead of Euclidean distance?
Cosine similarity measures the angle between vectors, not the distance. Two problems with many tags produce longer vectors than problems with few tags. Euclidean distance would say they're far apart even if their content overlaps. Cosine similarity ignores vector length and focuses purely on direction, which better captures topic overlap.
- Add the module.exports at the bottom of server/utils/tfidf.js:
module.exports = { buildCorpus, computeTF, computeIDF, computeTFIDF, cosineSimilarity, tokenize };
- Save server/utils/tfidf.js.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
function tokenize(text) {
return text.toLowerCase().replace(/[^a-z0-9 ]/g, '').split(/\s+/).filter(Boolean);
}
function computeTF(tokens) {
const tf = {};
tokens.forEach(t => { tf[t] = (tf[t] || 0) + 1; });
const total = tokens.length;
Object.keys(tf).forEach(k => { tf[k] /= total; });
return tf;
}
function computeIDF(corpus) {
const idf = {};
const N = corpus.length;
corpus.forEach(doc => {
const unique = new Set(doc);
unique.forEach(term => { idf[term] = (idf[term] || 0) + 1; });
});
Object.keys(idf).forEach(term => { idf[term] = Math.log(N / idf[term]); });
return idf;
}
function computeTFIDF(tf, idf) {
const tfidf = {};
Object.keys(tf).forEach(term => { tfidf[term] = tf[term] * (idf[term] || 0); });
return tfidf;
}
function cosineSimilarity(vecA, vecB) {
const allTerms = new Set([...Object.keys(vecA), ...Object.keys(vecB)]);
let dot = 0, magA = 0, magB = 0;
allTerms.forEach(term => {
const a = vecA[term] || 0;
const b = vecB[term] || 0;
dot += a * b;
magA += a * a;
magB += b * b;
});
if (magA === 0 || magB === 0) return 0;
return dot / (Math.sqrt(magA) * Math.sqrt(magB));
}
function buildCorpus(problems) {
return problems.map(p => tokenize(`${p.title} ${(p.topics || []).join(' ')}`));
}
module.exports = { buildCorpus, computeTF, computeIDF, computeTFIDF, cosineSimilarity, tokenize };
Not sure if your functions are correct?
Check that each function name matches exactly: tokenize, computeTF, computeIDF, computeTFIDF, cosineSimilarity, buildCorpus. The module.exports must list all six.
help me check my TF-IDF implementation
Create the similar problems endpoint
The TF-IDF functions are ready but not connected to any route. You need an endpoint that takes a problem ID, computes similarity scores against all other problems, and returns the top 5 matches.
- In server/routes/problems.js, find this line at the top:
const { pool } = require('../index');
- Add this import directly below it:
const { buildCorpus, computeTF, computeIDF, computeTFIDF, cosineSimilarity, tokenize } = require('../utils/tfidf');
- Now find the router.get('/:id' route in the file. Add the following /:id/similar route ABOVE it:
router.get('/:id/similar', async (req, res) => {
const allProblems = await pool.query(
`SELECT p.id, p.title, p.platform, p.rating, p.difficulty, p.url,
ARRAY_AGG(DISTINCT t.name) FILTER (WHERE t.name IS NOT NULL) AS topics
FROM problems p
LEFT JOIN problem_topics pt ON p.id = pt.problem_id
LEFT JOIN topics t ON pt.topic_id = t.id
GROUP BY p.id LIMIT 1000`
);
const problems = allProblems.rows;
const targetIdx = problems.findIndex(p => p.id === Number(req.params.id));
if (targetIdx === -1) return res.status(404).json({ error: 'Problem not found' });
const corpus = buildCorpus(problems);
const idf = computeIDF(corpus);
const vectors = corpus.map(tokens => computeTFIDF(computeTF(tokens), idf));
const targetVec = vectors[targetIdx];
const similarities = problems
.map((p, i) => ({ ...p, score: i === targetIdx ? -1 : cosineSimilarity(targetVec, vectors[i]) }))
.sort((a, b) => b.score - a.score)
.slice(0, 5);
res.json(similarities);
});
What does this endpoint do?
- It fetches up to 1000 problems with their topics from the database.
- It finds the target problem by ID, builds the entire corpus of token arrays, and computes IDF scores across all documents.
- It creates a TF-IDF vector for every problem, then calculates cosine similarity between the target and all others.
- The target problem itself gets a score of -1 so it never appears in its own results. The top 5 highest-scoring problems are returned.
Why must this route come before the /:id route?
Express matches routes in order. If /:id came first, a request to /api/problems/5/similar would match /:id with the value 5 and the /similar part would be ignored. Placing the more specific route first ensures it matches correctly.
- Save server/routes/problems.js.
- Restart your server, then test the endpoint by running:
curl http://localhost:5000/api/problems/1/similar
- You should see a JSON array of 5 problems, each with a score field between 0 and 1. Higher scores mean more similarity to problem 1.
Getting an empty array or 404?
- If you see {"error":"Problem not found"}, try a different ID. Your database might not have a problem with ID 1. Run curl http://localhost:5000/api/problems?limit=3 to see which IDs exist.
- If you see a module not found error, verify the import path in problems.js matches ../utils/tfidf and that your file is at server/utils/tfidf.js.
- If scores are all 0, check that your problems have topics associated in the problem_topics table. Problems with no topics produce empty token arrays.
help me debug my similar problems endpoint
✔️ Awesome, I've got everything!
Great. Double check you've saved both files before moving on.
ⓧ I'd like to double check the full code
const express = require('express');
const router = express.Router();
const { pool } = require('../index');
const { buildCorpus, computeTF, computeIDF, computeTFIDF, cosineSimilarity, tokenize } = require('../utils/tfidf');
router.get('/', async (req, res) => {
const { platform, difficulty, ratingMin, ratingMax, topic, sort = 'id', page = 1, limit = 20 } = req.query;
const offset = (page - 1) * limit;
let conditions = [];
let params = [];
let idx = 1;
if (platform) { conditions.push(`p.platform = $${idx++}`); params.push(platform); }
if (difficulty) { conditions.push(`p.difficulty = $${idx++}`); params.push(difficulty); }
if (ratingMin) { conditions.push(`p.rating >= $${idx++}`); params.push(Number(ratingMin)); }
if (ratingMax) { conditions.push(`p.rating <= $${idx++}`); params.push(Number(ratingMax)); }
if (topic) {
conditions.push(`p.id IN (SELECT pt.problem_id FROM problem_topics pt JOIN topics t ON pt.topic_id = t.id WHERE t.name = $${idx++})`);
params.push(topic);
}
const where = conditions.length ? 'WHERE ' + conditions.join(' AND ') : '';
const allowedSorts = ['id', 'rating', 'title', 'platform'];
const sortCol = allowedSorts.includes(sort) ? sort : 'id';
params.push(Number(limit), Number(offset));
const query = `SELECT p.*, ARRAY_AGG(DISTINCT t.name) FILTER (WHERE t.name IS NOT NULL) AS topics
FROM problems p
LEFT JOIN problem_topics pt ON p.id = pt.problem_id
LEFT JOIN topics t ON pt.topic_id = t.id
${where}
GROUP BY p.id
ORDER BY p.${sortCol}
LIMIT $${idx++} OFFSET $${idx++}`;
const result = await pool.query(query, params);
res.json(result.rows);
});
router.get('/:id/similar', async (req, res) => {
const allProblems = await pool.query(
`SELECT p.id, p.title, p.platform, p.rating, p.difficulty, p.url,
ARRAY_AGG(DISTINCT t.name) FILTER (WHERE t.name IS NOT NULL) AS topics
FROM problems p
LEFT JOIN problem_topics pt ON p.id = pt.problem_id
LEFT JOIN topics t ON pt.topic_id = t.id
GROUP BY p.id LIMIT 1000`
);
const problems = allProblems.rows;
const targetIdx = problems.findIndex(p => p.id === Number(req.params.id));
if (targetIdx === -1) return res.status(404).json({ error: 'Problem not found' });
const corpus = buildCorpus(problems);
const idf = computeIDF(corpus);
const vectors = corpus.map(tokens => computeTFIDF(computeTF(tokens), idf));
const targetVec = vectors[targetIdx];
const similarities = problems
.map((p, i) => ({ ...p, score: i === targetIdx ? -1 : cosineSimilarity(targetVec, vectors[i]) }))
.sort((a, b) => b.score - a.score)
.slice(0, 5);
res.json(similarities);
});
router.get('/:id', async (req, res) => {
const result = await pool.query(
`SELECT p.*, ARRAY_AGG(DISTINCT t.name) FILTER (WHERE t.name IS NOT NULL) AS topics
FROM problems p
LEFT JOIN problem_topics pt ON p.id = pt.problem_id
LEFT JOIN topics t ON pt.topic_id = t.id
WHERE p.id = $1
GROUP BY p.id`,
[req.params.id]
);
if (!result.rows.length) return res.status(404).json({ error: 'Problem not found' });
res.json(result.rows[0]);
});
module.exports = router;
Your platform can now mathematically surface related problems. Next up, you'll build a rich text note editor with Tiptap so users can take study notes and link them directly to problems.
Build Notes with Tiptap and Problem Linking
Your problems are browsable and searchable, but studying DSA effectively means taking notes on your solutions, tracking patterns you've noticed, and linking those insights back to specific problems.
In this step, you'll build a rich text note-taking system using Tiptap, a modern editor framework for React. You'll also build a full CRUD API for notes and add a feature that automatically links notes to problems when you paste a Codeforces or LeetCode URL.
In this step, get ready to:
- Install Tiptap and create a rich text editor component.
- Build a full CRUD notes API protected by JWT authentication.
- Add problem linking by parsing Codeforces and LeetCode URLs.
Install Tiptap and create the NoteEditor component
Tiptap is a headless rich text editor built on ProseMirror. It stores content as JSON instead of raw HTML, which makes it easy to persist in a database and render consistently.
- Install the Tiptap packages in your client/ directory by running this command:
cd client
npm install @tiptap/react @tiptap/pm @tiptap/starter-kit
What are these packages?
@tiptap/react provides the React hooks and components for the editor. @tiptap/pm is the ProseMirror core that Tiptap builds on.
@tiptap/starter-kit bundles the most common extensions (bold, italic, headings, lists, code blocks) so you don't have to install them individually.
- Create the components folder and the editor file by running:
mkdir -p src/components
touch src/components/NoteEditor.jsx
- Confirm the file exists by running:
ls src/components
You should see NoteEditor.jsx in the output.
- Open client/src/components/NoteEditor.jsx in VS Code and add the following code:
import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
export default function NoteEditor({ content, onChange }) {
const editor = useEditor({
extensions: [StarterKit],
content: content || '',
onUpdate: ({ editor }) => {
onChange(editor.getJSON());
},
});
return (
<div className="note-editor">
<div className="toolbar">
<button onClick={() => editor.chain().focus().toggleBold().run()}>Bold</button>
<button onClick={() => editor.chain().focus().toggleItalic().run()}>Italic</button>
<button onClick={() => editor.chain().focus().toggleBulletList().run()}>List</button>
<button onClick={() => editor.chain().focus().toggleCodeBlock().run()}>Code</button>
</div>
<EditorContent editor={editor} />
</div>
);
}
What does this code do?
useEditor creates a Tiptap editor instance with the StarterKit extensions. The onUpdate callback fires every time the content changes and sends the editor's JSON representation to the parent component via onChange.
The toolbar buttons use Tiptap's chaining API. Each button calls editor.chain().focus().toggle*().run() to toggle formatting without losing cursor focus.
- Save NoteEditor.jsx.
Seeing import errors in VS Code?
Make sure you ran the npm install command from inside the client/ directory, not the project root.
Check that your client/package.json lists @tiptap/react, @tiptap/pm, and @tiptap/starter-kit under dependencies.
Still stuck? Help me debug Tiptap import errors in my React project.
✔️ Awesome, I've got everything!
Great. Make sure you've saved NoteEditor.jsx before moving on.
ⓧ I'd like to double check the full code
import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
export default function NoteEditor({ content, onChange }) {
const editor = useEditor({
extensions: [StarterKit],
content: content || '',
onUpdate: ({ editor }) => {
onChange(editor.getJSON());
},
});
return (
<div className="note-editor">
<div className="toolbar">
<button onClick={() => editor.chain().focus().toggleBold().run()}>Bold</button>
<button onClick={() => editor.chain().focus().toggleItalic().run()}>Italic</button>
<button onClick={() => editor.chain().focus().toggleBulletList().run()}>List</button>
<button onClick={() => editor.chain().focus().toggleCodeBlock().run()}>Code</button>
</div>
<EditorContent editor={editor} />
</div>
);
}
Build the notes REST API
The notes API needs full CRUD operations so users can create, read, update, and delete their study notes. Every endpoint is protected by the auth middleware you built earlier, ensuring users can only access their own notes.
- Navigate back to the server directory by running:
cd ../server
- Create the notes route file by running:
touch routes/notes.js
- Open server/routes/notes.js and add the route setup and the GET, PUT, and DELETE endpoints:
const express = require('express');
const router = express.Router();
const { pool } = require('../index');
const authMiddleware = require('../middleware/auth');
// GET all notes for the authenticated user
router.get('/', authMiddleware, async (req, res) => {
const result = await pool.query('SELECT * FROM notes WHERE user_id = $1 ORDER BY updated_at DESC', [req.user.id]);
res.json(result.rows);
});
// UPDATE a note by ID (only if it belongs to the user)
router.put('/:id', authMiddleware, async (req, res) => {
const { title, content } = req.body;
const result = await pool.query(
'UPDATE notes SET title = $1, content = $2, updated_at = NOW() WHERE id = $3 AND user_id = $4 RETURNING *',
[title, JSON.stringify(content), req.params.id, req.user.id]
);
if (!result.rows.length) return res.status(404).json({ error: 'Note not found' });
res.json(result.rows[0]);
});
// DELETE a note by ID (only if it belongs to the user)
router.delete('/:id', authMiddleware, async (req, res) => {
await pool.query('DELETE FROM notes WHERE id = $1 AND user_id = $2', [req.params.id, req.user.id]);
res.json({ message: 'Note deleted' });
});
module.exports = router;
What does this code do?
Every route uses authMiddleware as the first handler. This verifies the JWT token and attaches the user's ID to req.user, so each query filters by user_id.
The PUT endpoint serializes the Tiptap JSON content with JSON.stringify(content) before storing it in the database. The WHERE clause checks both id and user_id so users cannot modify each other's notes.
- Save notes.js.
Now mount the notes routes in your main server file.
- In server/index.js, find this line:
app.use('/api/auth', require('./routes/auth'));
- Add the notes route directly below it, so the line now reads:
app.use('/api/auth', require('./routes/auth'));
app.use('/api/notes', require('./routes/notes'));
- Save index.js.
- Restart your server and verify it starts without errors. You should see Connected to Neon PostgreSQL in the terminal.
Server crashing on startup?
Check that server/routes/notes.js ends with module.exports = router; on its own line.
Verify the require path in index.js matches the actual file location: ./routes/notes.
Still stuck? Help me debug why my Express server crashes when I add the notes route.
Add the Link Problem feature
When a user creates a note, they should be able to paste a Codeforces or LeetCode problem URL and have the note automatically linked to that problem in the database. The backend needs to parse the URL, identify the problem, and either find it in the database or create a new entry.
How does URL parsing work here?
Codeforces problem URLs follow the pattern codeforces.com/problemset/problem/{contestId}/{index}. LeetCode URLs follow leetcode.com/problems/{titleSlug}/.
The backend uses regex to extract identifiers from these patterns. If the problem already exists in your database, it links to that record. If not, it creates a new entry so you can track it.
- In server/routes/notes.js, find this line:
const authMiddleware = require('../middleware/auth');
- Add the URL parser function directly below it:
// Parse Codeforces and LeetCode URLs to extract problem identifiers
function parseProblemUrl(url) {
if (!url) return null;
const cfMatch = url.match(/codeforces\.com\/problemset\/problem\/(\d+)\/([A-Z0-9]+)/i);
if (cfMatch) return { platform: 'codeforces', contestId: cfMatch[1], index: cfMatch[2], externalId: `${cfMatch[1]}${cfMatch[2]}`, urlFull: url };
const lcMatch = url.match(/leetcode\.com\/problems\/([\w-]+)/i);
if (lcMatch) return { platform: 'leetcode', externalId: lcMatch[1], urlFull: url };
return null;
}
What does this function do?
parseProblemUrl takes a URL string and tries to match it against known patterns. For Codeforces, it extracts the contest ID and problem index (like 1920 and A). For LeetCode, it extracts the title slug (like two-sum).
If the URL doesn't match either pattern, the function returns null and the note is created without a problem link.
- Now find the GET route (the line that starts with router.get('/', authMiddleware) and add the POST route above it:
// CREATE a note with optional problem linking via URL
router.post('/', authMiddleware, async (req, res) => {
const { title, content, problemUrl } = req.body;
let problemId = null;
if (problemUrl) {
const parsed = parseProblemUrl(problemUrl);
if (parsed) {
let existing = await pool.query('SELECT id FROM problems WHERE external_id = $1 AND platform = $2', [parsed.externalId, parsed.platform]);
if (existing.rows.length) {
problemId = existing.rows[0].id;
} else {
const inserted = await pool.query(
'INSERT INTO problems (platform, external_id, title, url) VALUES ($1, $2, $3, $4) RETURNING id',
[parsed.platform, parsed.externalId, parsed.externalId, parsed.urlFull]
);
problemId = inserted.rows[0].id;
}
}
}
const result = await pool.query(
'INSERT INTO notes (user_id, problem_id, title, content) VALUES ($1, $2, $3, $4) RETURNING *',
[req.user.id, problemId, title, JSON.stringify(content)]
);
res.status(201).json(result.rows[0]);
});
What does the POST route do?
When a user creates a note with a problemUrl in the request body, the route first parses the URL to identify the platform and problem ID.
It then checks if that problem already exists in the database using external_id and platform. If it does, it uses the existing record's ID. If not, it inserts a new problem entry and uses the newly created ID.
Finally, the note is inserted with the resolved problem_id (or null if no URL was provided or it didn't match a known pattern).
- Save notes.js.
- Restart your server and test the POST endpoint by running this curl command:
curl -X POST http://localhost:5000/api/notes \
-H "Content-Type: application/json" \
-H "Authorization: Bearer your-jwt-token-here" \
-d '{"title": "Two Sum notes", "content": {"type": "doc"}, "problemUrl": "https://leetcode.com/problems/two-sum/"}'
You should see a JSON response with the created note, including a problem_id value linking it to the two-sum problem.
Getting a 401 or 403 error?
Replace your-jwt-token-here with an actual token from the login endpoint. You can get one by calling POST /api/auth/login with valid credentials.
If you see a database error about the notes table not existing, confirm you created all tables in Step 2. The notes table must have columns for user_id, problem_id, title, content, and updated_at.
Still stuck? Help me debug my notes POST endpoint.
✔️ Awesome, I've got everything!
Great. Double check you've saved both notes.js and index.js.
ⓧ I'd like to double check the full code
const express = require('express');
const router = express.Router();
const { pool } = require('../index');
const authMiddleware = require('../middleware/auth');
function parseProblemUrl(url) {
if (!url) return null;
const cfMatch = url.match(/codeforces\.com\/problemset\/problem\/(\d+)\/([A-Z0-9]+)/i);
if (cfMatch) return { platform: 'codeforces', contestId: cfMatch[1], index: cfMatch[2], externalId: `${cfMatch[1]}${cfMatch[2]}`, urlFull: url };
const lcMatch = url.match(/leetcode\.com\/problems\/([\w-]+)/i);
if (lcMatch) return { platform: 'leetcode', externalId: lcMatch[1], urlFull: url };
return null;
}
router.post('/', authMiddleware, async (req, res) => {
const { title, content, problemUrl } = req.body;
let problemId = null;
if (problemUrl) {
const parsed = parseProblemUrl(problemUrl);
if (parsed) {
let existing = await pool.query('SELECT id FROM problems WHERE external_id = $1 AND platform = $2', [parsed.externalId, parsed.platform]);
if (existing.rows.length) {
problemId = existing.rows[0].id;
} else {
const inserted = await pool.query(
'INSERT INTO problems (platform, external_id, title, url) VALUES ($1, $2, $3, $4) RETURNING id',
[parsed.platform, parsed.externalId, parsed.externalId, parsed.urlFull]
);
problemId = inserted.rows[0].id;
}
}
}
const result = await pool.query(
'INSERT INTO notes (user_id, problem_id, title, content) VALUES ($1, $2, $3, $4) RETURNING *',
[req.user.id, problemId, title, JSON.stringify(content)]
);
res.status(201).json(result.rows[0]);
});
router.get('/', authMiddleware, async (req, res) => {
const result = await pool.query('SELECT * FROM notes WHERE user_id = $1 ORDER BY updated_at DESC', [req.user.id]);
res.json(result.rows);
});
router.put('/:id', authMiddleware, async (req, res) => {
const { title, content } = req.body;
const result = await pool.query(
'UPDATE notes SET title = $1, content = $2, updated_at = NOW() WHERE id = $3 AND user_id = $4 RETURNING *',
[title, JSON.stringify(content), req.params.id, req.user.id]
);
if (!result.rows.length) return res.status(404).json({ error: 'Note not found' });
res.json(result.rows[0]);
});
router.delete('/:id', authMiddleware, async (req, res) => {
await pool.query('DELETE FROM notes WHERE id = $1 AND user_id = $2', [req.params.id, req.user.id]);
res.json({ message: 'Note deleted' });
});
module.exports = router;
const express = require('express');
const { Pool } = require('pg');
const cors = require('cors');
require('dotenv').config();
const app = express();
app.use(cors());
app.use(express.json());
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
ssl: { rejectUnauthorized: false }
});
pool.connect((err, client, release) => {
if (err) {
console.error('Error connecting to database:', err);
} else {
console.log('Connected to Neon PostgreSQL');
release();
}
});
app.use('/api/problems', require('./routes/problems'));
app.use('/api/topics', require('./routes/topics'));
app.use('/api/auth', require('./routes/auth'));
app.use('/api/notes', require('./routes/notes'));
const PORT = process.env.PORT || 5000;
app.listen(PORT, () => console.log(`Server running on port ${PORT}`));
module.exports = { pool };
Your notes system is fully functional with rich text editing, CRUD operations, and intelligent problem linking. Next up, you'll build the contest calendar that pulls live data from Codeforces and shows countdown timers for upcoming competitions.
Build the Contest Calendar
Your platform now helps users find problems, group similar ones with TF-IDF, and take rich notes. The next piece of the puzzle is time-sensitive information.
Competitive programmers need to know when contests are happening across platforms. In this step, you'll build an API endpoint that pulls upcoming contests from the Codeforces API and merges them with a calculated LeetCode schedule, then display everything in a React component with live countdown timers.
In this step, get ready to:
- Build a contests API endpoint that fetches upcoming Codeforces contests and generates a static LeetCode contest schedule.
- Create a React Contest Calendar component with countdown timers.
- Add a Remind Me button using the browser Notification API.
Build the contests API endpoint
The Codeforces API provides a contest.list method that returns every contest ever held, plus upcoming ones. LeetCode has no public contest API, so you'll generate a predictable weekly/biweekly schedule instead.
- Install node-fetch (version 2, which supports CommonJS require) by running this command in your server directory:
npm install node-fetch@2
Why version 2?
Version 3 of node-fetch is ESM-only and cannot be used with require(). Since your Express project uses CommonJS modules, version 2 is the correct choice.
- Create a new file called server/routes/contests.js and add the LeetCode schedule configuration and helper function:
const express = require('express');
const router = express.Router();
const fetch = require('node-fetch');
// Static LeetCode contest schedule (no public API available)
const LEETCODE_CONTESTS = [
{ name: 'LeetCode Weekly Contest', platform: 'leetcode', dayOfWeek: 0, hour: 2, minute: 30, durationSeconds: 5400 },
{ name: 'LeetCode Biweekly Contest', platform: 'leetcode', dayOfWeek: 6, hour: 14, minute: 30, durationSeconds: 5400, biweekly: true }
];
// Calculate the next occurrence of each LeetCode contest
function getNextLeetCodeContests() {
const now = new Date();
return LEETCODE_CONTESTS.map(c => {
const next = new Date(now);
const diff = (c.dayOfWeek - now.getDay() + 7) % 7 || 7;
next.setDate(now.getDate() + diff);
next.setHours(c.hour, c.minute, 0, 0);
return { name: c.name, platform: c.platform, startTimeSeconds: Math.floor(next.getTime() / 1000), durationSeconds: c.durationSeconds, phase: 'BEFORE' };
});
}
What does this code do?
LEETCODE_CONTESTS defines the recurring schedule. Weekly contests run every Sunday at 2:30 AM UTC. Biweekly contests run every other Saturday at 2:30 PM UTC.
getNextLeetCodeContests() calculates the next occurrence of each contest from today's date, returning the same data shape as Codeforces contests (with startTimeSeconds as a Unix timestamp).
- Below the getNextLeetCodeContests function, add the route handler that fetches Codeforces contests and merges them with the LeetCode schedule:
// Fetch upcoming contests from Codeforces and merge with LeetCode schedule
router.get('/', async (req, res) => {
try {
const cfRes = await fetch('https://codeforces.com/api/contest.list');
const cfData = await cfRes.json();
const cfContests = cfData.result
.filter(c => c.phase === 'BEFORE')
.map(c => ({ name: c.name, platform: 'codeforces', startTimeSeconds: c.startTimeSeconds, durationSeconds: c.durationSeconds, phase: c.phase }));
const lcContests = getNextLeetCodeContests();
const all = [...cfContests, ...lcContests].sort((a, b) => a.startTimeSeconds - b.startTimeSeconds);
res.json(all);
} catch (err) {
res.status(500).json({ error: 'Failed to fetch contests' });
}
});
module.exports = router;
What does this endpoint do?
It calls the Codeforces contest.list API and filters for contests where phase equals BEFORE (upcoming contests that haven't started yet).
It then merges the Codeforces results with the calculated LeetCode contests and sorts everything chronologically by start time.
- Save server/routes/contests.js.
Now mount this route in your Express server.
- In server/index.js, find this line:
app.use('/api/notes', require('./routes/notes'));
- Add the contests route directly below it, so the line now reads:
app.use('/api/notes', require('./routes/notes'));
app.use('/api/contests', require('./routes/contests'));
- Save server/index.js and restart your server.
- Test the endpoint by opening http://localhost:5000/api/contests in your browser. You should see a JSON array of upcoming contests with name, platform, startTimeSeconds, and durationSeconds fields.
Getting a 500 error or empty response?
The Codeforces API has a rate limit of 1 request per 2 seconds. If you refresh too quickly, the request may fail. Wait a few seconds and try again.
If you see a Cannot find module error, make sure you installed node-fetch in the server directory (not the project root).
Help me debug my contests endpoint
Build the Contest Calendar React component
With contest data flowing from the backend, you need a frontend component that displays it in a useful way. The most valuable feature for competitive programmers is a live countdown showing exactly how long until each contest starts.
- Create a new file called client/src/components/ContestCalendar.jsx and add the imports and Countdown helper component:
import { useEffect, useState } from 'react';
import axios from 'axios';
// Helper component that displays a live countdown to a contest start time
function Countdown({ startTimeSeconds }) {
const [timeLeft, setTimeLeft] = useState('');
useEffect(() => {
const update = () => {
const diff = startTimeSeconds * 1000 - Date.now();
if (diff <= 0) { setTimeLeft('Starting now'); return; }
const h = Math.floor(diff / 3600000);
const m = Math.floor((diff % 3600000) / 60000);
const s = Math.floor((diff % 60000) / 1000);
setTimeLeft(`${h}h ${m}m ${s}s`);
};
update();
const interval = setInterval(update, 1000);
return () => clearInterval(interval);
}, [startTimeSeconds]);
return <span>{timeLeft}</span>;
}
What does the Countdown component do?
It takes a startTimeSeconds Unix timestamp, calculates the difference from Date.now(), and formats it as hours, minutes, and seconds.
The setInterval runs every second so the countdown ticks in real time. The cleanup function (return () => clearInterval(interval)) prevents memory leaks when the component unmounts.
- Below the Countdown component in the same file, add the main ContestCalendar component that fetches and displays contests:
export default function ContestCalendar() {
const [contests, setContests] = useState([]);
// Fetch upcoming contests from the backend on mount
useEffect(() => {
axios.get('http://localhost:5000/api/contests').then(res => setContests(res.data));
}, []);
return (
<div>
<h2>Upcoming Contests</h2>
{contests.map((c, i) => (
<div key={i} style={{ border: '1px solid #ccc', margin: 8, padding: 12 }}>
<strong>{c.name}</strong> [{c.platform}]<br />
Start: {new Date(c.startTimeSeconds * 1000).toLocaleString()}<br />
Duration: {Math.floor(c.durationSeconds / 3600)}h {Math.floor((c.durationSeconds % 3600) / 60)}m<br />
Countdown: <Countdown startTimeSeconds={c.startTimeSeconds} /><br />
</div>
))}
</div>
);
}
What does this component do?
On mount, it fetches the merged contest list from your /api/contests endpoint. Each contest card shows the name, platform, start time converted to the user's local timezone via toLocaleString(), duration in hours and minutes, and a live countdown.
- Save ContestCalendar.jsx.
- If you import and render this component in your app, you should see a list of upcoming contests with countdown timers that tick every second.
Not seeing any contests?
Make sure your Express server is running and the /api/contests endpoint returns data in the browser. Check your browser's developer console for CORS errors or network failures.
Help me troubleshoot my Contest Calendar component
Add the Remind Me notification button
A countdown is helpful, but it only works while the page is open. The browser Notification API lets you schedule an alert that fires even if the user has switched tabs. You'll add a button that schedules a notification 30 minutes before each contest starts.
- In ContestCalendar.jsx, find the closing ]); of the useEffect block. Add the handleRemind function directly below it (before the return statement):
const handleRemind = (contest) => {
if (!('Notification' in window)) return alert('Notifications not supported');
Notification.requestPermission().then(perm => {
if (perm === 'granted') {
const delay = contest.startTimeSeconds * 1000 - Date.now() - 30 * 60 * 1000;
if (delay > 0) setTimeout(() => new Notification(`${contest.name} starts in 30 minutes!`), delay);
else alert('Contest starts in less than 30 minutes!');
}
});
};
What does handleRemind do?
It first checks if the browser supports notifications. Then it requests permission from the user.
If permission is granted, it calculates how many milliseconds until 30 minutes before the contest starts and sets a setTimeout to fire a notification at that time. If the contest is already less than 30 minutes away, it shows an immediate alert instead.
- Now add the Remind Me button to each contest card. Find the <Countdown startTimeSeconds={c.startTimeSeconds} /><br /> line and add this button directly below it (before the closing </div>):
<button onClick={() => handleRemind(c)}>Remind Me</button>
- Save ContestCalendar.jsx.
- Click the **Remind Me** button next to any contest. Your browser should ask for notification permission. After granting it, the reminder is scheduled.
Notification permission not appearing?
Some browsers block notification requests unless triggered by a user gesture (like a click). Make sure you're clicking the button, not calling handleRemind programmatically.
If you previously denied notifications for localhost, open your browser's site settings and reset the notification permission.
Help me get browser notifications working
✔️ Awesome, I've got everything!
Great. Double check you've saved both server/routes/contests.js and client/src/components/ContestCalendar.jsx.
ⓧ I'd like to double check the full code
Here is the complete server/routes/contests.js:
const express = require('express');
const router = express.Router();
const fetch = require('node-fetch');
const LEETCODE_CONTESTS = [
{ name: 'LeetCode Weekly Contest', platform: 'leetcode', dayOfWeek: 0, hour: 2, minute: 30, durationSeconds: 5400 },
{ name: 'LeetCode Biweekly Contest', platform: 'leetcode', dayOfWeek: 6, hour: 14, minute: 30, durationSeconds: 5400, biweekly: true }
];
function getNextLeetCodeContests() {
const now = new Date();
return LEETCODE_CONTESTS.map(c => {
const next = new Date(now);
const diff = (c.dayOfWeek - now.getDay() + 7) % 7 || 7;
next.setDate(now.getDate() + diff);
next.setHours(c.hour, c.minute, 0, 0);
return { name: c.name, platform: c.platform, startTimeSeconds: Math.floor(next.getTime() / 1000), durationSeconds: c.durationSeconds, phase: 'BEFORE' };
});
}
router.get('/', async (req, res) => {
try {
const cfRes = await fetch('https://codeforces.com/api/contest.list');
const cfData = await cfRes.json();
const cfContests = cfData.result
.filter(c => c.phase === 'BEFORE')
.map(c => ({ name: c.name, platform: 'codeforces', startTimeSeconds: c.startTimeSeconds, durationSeconds: c.durationSeconds, phase: c.phase }));
const lcContests = getNextLeetCodeContests();
const all = [...cfContests, ...lcContests].sort((a, b) => a.startTimeSeconds - b.startTimeSeconds);
res.json(all);
} catch (err) {
res.status(500).json({ error: 'Failed to fetch contests' });
}
});
module.exports = router;
Here is the complete server/index.js:
const express = require('express');
const { Pool } = require('pg');
const cors = require('cors');
require('dotenv').config();
const app = express();
app.use(cors());
app.use(express.json());
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
ssl: { rejectUnauthorized: false }
});
pool.connect((err, client, release) => {
if (err) {
console.error('Error connecting to database:', err);
} else {
console.log('Connected to Neon PostgreSQL');
release();
}
});
app.use('/api/problems', require('./routes/problems'));
app.use('/api/topics', require('./routes/topics'));
app.use('/api/auth', require('./routes/auth'));
app.use('/api/notes', require('./routes/notes'));
app.use('/api/contests', require('./routes/contests'));
const PORT = process.env.PORT || 5000;
app.listen(PORT, () => console.log(`Server running on port ${PORT}`));
module.exports = { pool };
Here is the complete client/src/components/ContestCalendar.jsx:
import { useEffect, useState } from 'react';
import axios from 'axios';
function Countdown({ startTimeSeconds }) {
const [timeLeft, setTimeLeft] = useState('');
useEffect(() => {
const update = () => {
const diff = startTimeSeconds * 1000 - Date.now();
if (diff <= 0) { setTimeLeft('Starting now'); return; }
const h = Math.floor(diff / 3600000);
const m = Math.floor((diff % 3600000) / 60000);
const s = Math.floor((diff % 60000) / 1000);
setTimeLeft(`${h}h ${m}m ${s}s`);
};
update();
const interval = setInterval(update, 1000);
return () => clearInterval(interval);
}, [startTimeSeconds]);
return <span>{timeLeft}</span>;
}
export default function ContestCalendar() {
const [contests, setContests] = useState([]);
useEffect(() => {
axios.get('http://localhost:5000/api/contests').then(res => setContests(res.data));
}, []);
const handleRemind = (contest) => {
if (!('Notification' in window)) return alert('Notifications not supported');
Notification.requestPermission().then(perm => {
if (perm === 'granted') {
const delay = contest.startTimeSeconds * 1000 - Date.now() - 30 * 60 * 1000;
if (delay > 0) setTimeout(() => new Notification(`${contest.name} starts in 30 minutes!`), delay);
else alert('Contest starts in less than 30 minutes!');
}
});
};
return (
<div>
<h2>Upcoming Contests</h2>
{contests.map((c, i) => (
<div key={i} style={{ border: '1px solid #ccc', margin: 8, padding: 12 }}>
<strong>{c.name}</strong> [{c.platform}]<br />
Start: {new Date(c.startTimeSeconds * 1000).toLocaleString()}<br />
Duration: {Math.floor(c.durationSeconds / 3600)}h {Math.floor((c.durationSeconds % 3600) / 60)}m<br />
Countdown: <Countdown startTimeSeconds={c.startTimeSeconds} /><br />
<button onClick={() => handleRemind(c)}>Remind Me</button>
</div>
))}
</div>
);
}
Your platform now shows users exactly when to compete. Next up, you'll wire all these components together with React Router to build the complete frontend experience.
Build the React Frontend
Your backend API is complete with endpoints for problems, authentication, notes, TF-IDF similarity, and contests. Now you need a way for users to actually interact with all of that.
In this step, you will build the React frontend that connects to your Express API. You will set up client-side routing so users can navigate between pages, build a filterable problem browser, and create a problem detail page that surfaces TF-IDF similar problems.
In this step, get ready to:
- Set up React Router with pages for all features (Home, Problems, Notes, Contests, Login, Register).
- Build the Problems page with a filter sidebar and sortable problem table.
- Build the Problem Detail page with similar problems and note-linking.
Set up React Router and page structure
React Router enables client-side navigation. Instead of reloading the entire page every time users move between Problems, Notes, and Contests, the browser updates the URL and swaps out only the relevant component.
- Install the routing and HTTP client dependencies in the client/ folder by running:
cd client
npm install react-router-dom axios
What do these packages do?
react-router-dom provides the BrowserRouter, Routes, Route, and Link components for mapping URLs to page components.
axios is an HTTP client that makes API calls to your Express backend. You already use it in your existing components.
- Create the pages directory inside client/src/ by running:
mkdir src/pages
- Replace the contents of client/src/main.jsx with this clean entry point:
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
What does this code do?
This is the application entry point. It renders the App component inside React's StrictMode wrapper, which helps catch potential issues during development.
- Create client/src/App.jsx with the following routing configuration:
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import Home from './pages/Home';
import Problems from './pages/Problems';
import ProblemDetail from './pages/ProblemDetail';
import Notes from './pages/Notes';
import Contests from './pages/Contests';
import Login from './pages/Login';
import Register from './pages/Register';
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/problems" element={<Problems />} />
<Route path="/problems/:id" element={<ProblemDetail />} />
<Route path="/notes" element={<Notes />} />
<Route path="/contests" element={<Contests />} />
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
</Routes>
</BrowserRouter>
);
}
What does this code do?
BrowserRouter wraps the app and enables client-side routing. Each Route maps a URL path to a page component.
The /problems/:id path uses a dynamic segment. When a user visits /problems/42, React Router passes 42 as a URL parameter that the component can read.
- Create client/src/pages/Home.jsx with a navigation menu:
import { Link } from 'react-router-dom';
export default function Home() {
return (
<div>
<h1>DSA Practice Platform</h1>
<nav>
<Link to="/problems">Problems</Link> |
<Link to="/notes">Notes</Link> |
<Link to="/contests">Contests</Link> |
<Link to="/login">Login</Link> |
<Link to="/register">Register</Link>
</nav>
</div>
);
}
- Create client/src/pages/Contests.jsx which wraps the component you already built:
import ContestCalendar from '../components/ContestCalendar';
export default function Contests() { return <div><h1>Contest Calendar</h1><ContestCalendar /></div>; }
What do these pages do?
Home.jsx uses Link components instead of <a> tags. Link performs client-side navigation without a full page reload.
Contests.jsx simply renders the ContestCalendar component you built in a previous step, wrapped in a page layout.
- Create client/src/pages/Login.jsx with the authentication form:
import { useState } from 'react';
import axios from 'axios';
import { useNavigate } from 'react-router-dom';
export default function Login() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const navigate = useNavigate();
const handleLogin = async () => {
const r = await axios.post('http://localhost:5000/api/auth/login', { email, password });
localStorage.setItem('token', r.data.token);
navigate('/');
};
return (
<div>
<h2>Login</h2>
<input placeholder="Email" value={email} onChange={e => setEmail(e.target.value)} />
<input type="password" placeholder="Password" value={password} onChange={e => setPassword(e.target.value)} />
<button onClick={handleLogin}>Login</button>
</div>
);
}
What does this code do?
The login form sends credentials to your /api/auth/login endpoint. On success, it stores the JWT token in localStorage so future API calls can include it as an authorization header.
useNavigate is a React Router hook that lets you programmatically redirect the user after login.
- Create client/src/pages/Register.jsx with the registration form:
import { useState } from 'react';
import axios from 'axios';
import { useNavigate } from 'react-router-dom';
export default function Register() {
const [username, setUsername] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const navigate = useNavigate();
const handleRegister = async () => {
await axios.post('http://localhost:5000/api/auth/register', { username, email, password });
navigate('/login');
};
return (
<div>
<h2>Register</h2>
<input placeholder="Username" value={username} onChange={e => setUsername(e.target.value)} />
<input placeholder="Email" value={email} onChange={e => setEmail(e.target.value)} />
<input type="password" placeholder="Password" value={password} onChange={e => setPassword(e.target.value)} />
<button onClick={handleRegister}>Register</button>
</div>
);
}
What does this code do?
The register form collects a username, email, and password, then posts them to /api/auth/register. On success it redirects to the login page so the user can sign in with their new account.
- Create client/src/pages/Notes.jsx with the notes list and editor:
import { useState, useEffect } from 'react';
import axios from 'axios';
import NoteEditor from '../components/NoteEditor';
export default function Notes() {
const [notes, setNotes] = useState([]);
const [title, setTitle] = useState('');
const [content, setContent] = useState(null);
const [problemUrl, setProblemUrl] = useState('');
const token = localStorage.getItem('token');
useEffect(() => {
if (token) axios.get('http://localhost:5000/api/notes', { headers: { Authorization: `Bearer ${token}` } }).then(r => setNotes(r.data));
}, [token]);
const handleSave = async () => {
await axios.post('http://localhost:5000/api/notes', { title, content, problemUrl }, { headers: { Authorization: `Bearer ${token}` } });
setTitle(''); setContent(null); setProblemUrl('');
const r = await axios.get('http://localhost:5000/api/notes', { headers: { Authorization: `Bearer ${token}` } });
setNotes(r.data);
};
return (
<div>
<h2>My Notes</h2>
<input placeholder="Note title" value={title} onChange={e => setTitle(e.target.value)} />
<input placeholder="Link a problem URL (optional)" value={problemUrl} onChange={e => setProblemUrl(e.target.value)} />
<NoteEditor content={content} onChange={setContent} />
<button onClick={handleSave}>Save Note</button>
<hr />
{notes.map(n => <div key={n.id}><strong>{n.title}</strong><p>{new Date(n.created_at).toLocaleDateString()}</p></div>)}
</div>
);
}
What does this code do?
This page reads the JWT from localStorage and attaches it as a Bearer token on every notes API call. This is how the auth middleware on the backend identifies who is making the request.
The handleSave function posts the note title, Tiptap JSON content, and an optional problem URL to the API. After saving, it clears the form and re-fetches all notes to display the updated list.
- Save all the files you just created.
- Return to the terminal in client/ and start the dev server by running:
npm run dev
- Open http://localhost:5173 in your browser. You should see the "DSA Practice Platform" heading with navigation links.
- Click the Problems link. The page should change without a full reload (you will see a blank page since Problems.jsx does not exist yet).
Seeing a blank screen or errors?
Check that all your import paths match the file names exactly. React is case-sensitive, so ./pages/Home must match a file named Home.jsx.
If you see a module not found error for react-router-dom, make sure you ran the install command from inside the client/ directory.
help me fix my React Router setup
Build the Problems page with filters and table
The Problems page is the core browsing experience. Users need to filter problems by platform, difficulty, and topic, then sort through results in a table format.
- Create client/src/pages/Problems.jsx with the following code:
import { useEffect, useState } from 'react';
import axios from 'axios';
import { Link } from 'react-router-dom';
export default function Problems() {
const [problems, setProblems] = useState([]);
const [topics, setTopics] = useState([]);
const [filters, setFilters] = useState({ platform: '', difficulty: '', topic: '', ratingMin: '', ratingMax: '', sort: 'id', page: 1 });
useEffect(() => {
axios.get('http://localhost:5000/api/topics').then(r => setTopics(r.data));
}, []);
useEffect(() => {
const params = Object.fromEntries(Object.entries(filters).filter(([, v]) => v !== ''));
axios.get('http://localhost:5000/api/problems', { params }).then(r => setProblems(r.data));
}, [filters]);
return (
<div style={{ display: 'flex' }}>
<aside style={{ width: 200, padding: 16 }}>
<h3>Filters</h3>
<select onChange={e => setFilters(f => ({ ...f, platform: e.target.value }))}>
<option value="">All Platforms</option>
<option value="codeforces">Codeforces</option>
<option value="leetcode">LeetCode</option>
</select>
<select onChange={e => setFilters(f => ({ ...f, difficulty: e.target.value }))}>
<option value="">All Difficulties</option>
<option value="Easy">Easy</option>
<option value="Medium">Medium</option>
<option value="Hard">Hard</option>
</select>
<select onChange={e => setFilters(f => ({ ...f, topic: e.target.value }))}>
<option value="">All Topics</option>
{topics.map(t => <option key={t.id} value={t.name}>{t.name}</option>)}
</select>
<input placeholder="Min Rating" onChange={e => setFilters(f => ({ ...f, ratingMin: e.target.value }))} />
<input placeholder="Max Rating" onChange={e => setFilters(f => ({ ...f, ratingMax: e.target.value }))} />
</aside>
<main style={{ flex: 1, padding: 16 }}>
<h2>Problems</h2>
<table>
<thead><tr><th>Title</th><th>Platform</th><th>Rating/Difficulty</th><th>Topics</th><th>Link</th></tr></thead>
<tbody>
{problems.map(p => (
<tr key={p.id}>
<td><Link to={`/problems/${p.id}`}>{p.title}</Link></td>
<td>{p.platform}</td>
<td>{p.rating || p.difficulty}</td>
<td>{(p.topics || []).join(', ')}</td>
<td><a href={p.url} target="_blank" rel="noreferrer">Open</a></td>
</tr>
))}
</tbody>
</table>
</main>
</div>
);
}
What does this code do?
The filter sidebar holds three <select> dropdowns (platform, difficulty, topic) and two text inputs for rating range. Every time any filter value changes, the filters state updates, which triggers the second useEffect to re-fetch problems with the new query parameters.
The table renders each problem with a Link to its detail page (client-side) and an <a> tag that opens the problem on Codeforces or LeetCode (external).
The Object.fromEntries(Object.entries(filters).filter(...)) line strips out empty filter values so the API only receives parameters the user actually set.
- Save Problems.jsx and return to your browser.
- Navigate to http://localhost:5173/problems. You should see a filter sidebar on the left and a table of problems on the right.
- Try selecting a platform filter. The table should update automatically without a page reload.
Table shows no problems?
Make sure your Express server is still running on port 5000. The React app fetches data from http://localhost:5000/api/problems.
Open your browser DevTools Network tab and check if the API call returns data. If you see a CORS error, verify that your server has app.use(cors()) in server/index.js.
help me debug why my Problems page shows no data
Build the Problem Detail page with similar problems
When a user clicks a problem title in the table, they should see the full problem information along with TF-IDF similar problems. This is where your algorithm work from earlier becomes visible.
- Create client/src/pages/ProblemDetail.jsx with the following code:
import { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import axios from 'axios';
export default function ProblemDetail() {
const { id } = useParams();
const navigate = useNavigate();
const [problem, setProblem] = useState(null);
const [similar, setSimilar] = useState([]);
useEffect(() => {
axios.get(`http://localhost:5000/api/problems/${id}`).then(r => setProblem(r.data));
axios.get(`http://localhost:5000/api/problems/${id}/similar`).then(r => setSimilar(r.data));
}, [id]);
if (!problem) return <div>Loading...</div>;
return (
<div>
<h2>{problem.title}</h2>
<p>Platform: {problem.platform} | Rating: {problem.rating || problem.difficulty}</p>
<p>Topics: {(problem.topics || []).join(', ')}</p>
<a href={problem.url} target="_blank" rel="noreferrer">Solve on {problem.platform}</a>
<button onClick={() => navigate(`/notes?problemId=${id}`)}>Create Note for this Problem</button>
<h3>Similar Problems</h3>
<ul>{similar.map(s => <li key={s.id}><a href={s.url} target="_blank" rel="noreferrer">{s.title}</a> (score: {s.score?.toFixed(3)})</li>)}</ul>
</div>
);
}
What does this code do?
useParams() extracts the id from the URL (the :id segment defined in App.jsx). The component fetches both the problem details and its similar problems from the TF-IDF endpoint simultaneously.
The "Create Note for this Problem" button uses useNavigate to redirect to the Notes page with a problemId query parameter, letting the Notes page pre-link the note to this problem.
The similar problems list shows each problem's title with a cosine similarity score from the TF-IDF algorithm you implemented earlier.
- Save ProblemDetail.jsx.
- In your browser, navigate to the Problems page and click any problem title in the table.
- You should see the problem details, a link to solve it on Codeforces or LeetCode, the "Create Note for this Problem" button, and a "Similar Problems" list below.
Similar problems list is empty?
The /api/problems/:id/similar endpoint relies on pre-computed TF-IDF scores in your database. If you haven't run the similarity computation seed script, this list will be empty.
Check the browser DevTools console for errors. If the endpoint returns a 500, check your server terminal for the specific database error.
help me debug my TF-IDF similar problems endpoint
✔️ Awesome, I've got everything!
Great. Double check you have saved all files in client/src/pages/ and client/src/App.jsx.
ⓧ I'd like to double check the full code
Here is the full code for each file. Compare your code against these:
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import Home from './pages/Home';
import Problems from './pages/Problems';
import ProblemDetail from './pages/ProblemDetail';
import Notes from './pages/Notes';
import Contests from './pages/Contests';
import Login from './pages/Login';
import Register from './pages/Register';
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/problems" element={<Problems />} />
<Route path="/problems/:id" element={<ProblemDetail />} />
<Route path="/notes" element={<Notes />} />
<Route path="/contests" element={<Contests />} />
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
</Routes>
</BrowserRouter>
);
}
import { Link } from 'react-router-dom';
export default function Home() {
return (
<div>
<h1>DSA Practice Platform</h1>
<nav>
<Link to="/problems">Problems</Link> |
<Link to="/notes">Notes</Link> |
<Link to="/contests">Contests</Link> |
<Link to="/login">Login</Link> |
<Link to="/register">Register</Link>
</nav>
</div>
);
}
import { useEffect, useState } from 'react';
import axios from 'axios';
import { Link } from 'react-router-dom';
export default function Problems() {
const [problems, setProblems] = useState([]);
const [topics, setTopics] = useState([]);
const [filters, setFilters] = useState({ platform: '', difficulty: '', topic: '', ratingMin: '', ratingMax: '', sort: 'id', page: 1 });
useEffect(() => {
axios.get('http://localhost:5000/api/topics').then(r => setTopics(r.data));
}, []);
useEffect(() => {
const params = Object.fromEntries(Object.entries(filters).filter(([, v]) => v !== ''));
axios.get('http://localhost:5000/api/problems', { params }).then(r => setProblems(r.data));
}, [filters]);
return (
<div style={{ display: 'flex' }}>
<aside style={{ width: 200, padding: 16 }}>
<h3>Filters</h3>
<select onChange={e => setFilters(f => ({ ...f, platform: e.target.value }))}>
<option value="">All Platforms</option>
<option value="codeforces">Codeforces</option>
<option value="leetcode">LeetCode</option>
</select>
<select onChange={e => setFilters(f => ({ ...f, difficulty: e.target.value }))}>
<option value="">All Difficulties</option>
<option value="Easy">Easy</option>
<option value="Medium">Medium</option>
<option value="Hard">Hard</option>
</select>
<select onChange={e => setFilters(f => ({ ...f, topic: e.target.value }))}>
<option value="">All Topics</option>
{topics.map(t => <option key={t.id} value={t.name}>{t.name}</option>)}
</select>
<input placeholder="Min Rating" onChange={e => setFilters(f => ({ ...f, ratingMin: e.target.value }))} />
<input placeholder="Max Rating" onChange={e => setFilters(f => ({ ...f, ratingMax: e.target.value }))} />
</aside>
<main style={{ flex: 1, padding: 16 }}>
<h2>Problems</h2>
<table>
<thead><tr><th>Title</th><th>Platform</th><th>Rating/Difficulty</th><th>Topics</th><th>Link</th></tr></thead>
<tbody>
{problems.map(p => (
<tr key={p.id}>
<td><Link to={`/problems/${p.id}`}>{p.title}</Link></td>
<td>{p.platform}</td>
<td>{p.rating || p.difficulty}</td>
<td>{(p.topics || []).join(', ')}</td>
<td><a href={p.url} target="_blank" rel="noreferrer">Open</a></td>
</tr>
))}
</tbody>
</table>
</main>
</div>
);
}
import { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import axios from 'axios';
export default function ProblemDetail() {
const { id } = useParams();
const navigate = useNavigate();
const [problem, setProblem] = useState(null);
const [similar, setSimilar] = useState([]);
useEffect(() => {
axios.get(`http://localhost:5000/api/problems/${id}`).then(r => setProblem(r.data));
axios.get(`http://localhost:5000/api/problems/${id}/similar`).then(r => setSimilar(r.data));
}, [id]);
if (!problem) return <div>Loading...</div>;
return (
<div>
<h2>{problem.title}</h2>
<p>Platform: {problem.platform} | Rating: {problem.rating || problem.difficulty}</p>
<p>Topics: {(problem.topics || []).join(', ')}</p>
<a href={problem.url} target="_blank" rel="noreferrer">Solve on {problem.platform}</a>
<button onClick={() => navigate(`/notes?problemId=${id}`)}>Create Note for this Problem</button>
<h3>Similar Problems</h3>
<ul>{similar.map(s => <li key={s.id}><a href={s.url} target="_blank" rel="noreferrer">{s.title}</a> (score: {s.score?.toFixed(3)})</li>)}</ul>
</div>
);
}
import { useState, useEffect } from 'react';
import axios from 'axios';
import NoteEditor from '../components/NoteEditor';
export default function Notes() {
const [notes, setNotes] = useState([]);
const [title, setTitle] = useState('');
const [content, setContent] = useState(null);
const [problemUrl, setProblemUrl] = useState('');
const token = localStorage.getItem('token');
useEffect(() => {
if (token) axios.get('http://localhost:5000/api/notes', { headers: { Authorization: `Bearer ${token}` } }).then(r => setNotes(r.data));
}, [token]);
const handleSave = async () => {
await axios.post('http://localhost:5000/api/notes', { title, content, problemUrl }, { headers: { Authorization: `Bearer ${token}` } });
setTitle(''); setContent(null); setProblemUrl('');
const r = await axios.get('http://localhost:5000/api/notes', { headers: { Authorization: `Bearer ${token}` } });
setNotes(r.data);
};
return (
<div>
<h2>My Notes</h2>
<input placeholder="Note title" value={title} onChange={e => setTitle(e.target.value)} />
<input placeholder="Link a problem URL (optional)" value={problemUrl} onChange={e => setProblemUrl(e.target.value)} />
<NoteEditor content={content} onChange={setContent} />
<button onClick={handleSave}>Save Note</button>
<hr />
{notes.map(n => <div key={n.id}><strong>{n.title}</strong><p>{new Date(n.created_at).toLocaleDateString()}</p></div>)}
</div>
);
}
import { useState } from 'react';
import axios from 'axios';
import { useNavigate } from 'react-router-dom';
export default function Login() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const navigate = useNavigate();
const handleLogin = async () => {
const r = await axios.post('http://localhost:5000/api/auth/login', { email, password });
localStorage.setItem('token', r.data.token);
navigate('/');
};
return (
<div>
<h2>Login</h2>
<input placeholder="Email" value={email} onChange={e => setEmail(e.target.value)} />
<input type="password" placeholder="Password" value={password} onChange={e => setPassword(e.target.value)} />
<button onClick={handleLogin}>Login</button>
</div>
);
}
import { useState } from 'react';
import axios from 'axios';
import { useNavigate } from 'react-router-dom';
export default function Register() {
const [username, setUsername] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const navigate = useNavigate();
const handleRegister = async () => {
await axios.post('http://localhost:5000/api/auth/register', { username, email, password });
navigate('/login');
};
return (
<div>
<h2>Register</h2>
<input placeholder="Username" value={username} onChange={e => setUsername(e.target.value)} />
<input placeholder="Email" value={email} onChange={e => setEmail(e.target.value)} />
<input type="password" placeholder="Password" value={password} onChange={e => setPassword(e.target.value)} />
<button onClick={handleRegister}>Register</button>
</div>
);
}
import ContestCalendar from '../components/ContestCalendar';
export default function Contests() { return <div><h1>Contest Calendar</h1><ContestCalendar /></div>; }
Your full-stack application now has a working frontend. Users can browse problems with filters, view similar problems powered by your TF-IDF algorithm, take notes with the Tiptap editor, and check the contest calendar. Next up, the Secret Mission will add a personalized "Problem of the Day" recommendation system.
Secret mission
Add a Problem of the Day Feature
Your platform already helps users find problems manually. But what if it could recommend one problem each day based on their preferences? Build a personalized Problem of the Day system with streak tracking to keep users coming back.
Clean Up Your Resources
Clean Up Your Resources
This project runs locally with a Neon PostgreSQL database in the cloud. Neon's free tier auto-suspends after 5 minutes of inactivity, so there are no ongoing costs while idle. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Local project files (client/ and server/ directories on your Desktop).
- Neon PostgreSQL project (cloud database storing your problems, users, notes, and topics).
- Neon account (created during Step 1).
Keep everything running
No action needed. Choose this if you are still actively building or want to come back and extend the platform further.
- Your local files remain on your Desktop.
- Your Neon database auto-suspends after 5 minutes of inactivity. It wakes up on the next connection with a 1-3 second cold start.
- No charges accrue on the Neon free tier while suspended.
Pause - I'll come back to this later
Shut down running processes to free up terminal sessions and memory. Your files and database stay intact for when you return.
- In your terminal running the Express server, press Ctrl+C to stop the backend.
- In your terminal running the Vite dev server, press Ctrl+C to stop the frontend.
- Your Neon database will auto-suspend after 5 minutes of inactivity. No action required.
Delete - I don't want to use this again
Remove all project resources and start fresh. This deletes your local code and your cloud database permanently.
- Stop any running servers by pressing Ctrl+C in each terminal.
- Delete your local project folder by running the following command in your terminal:
macOS
cd ~/Desktop
rm -rf client server
Windows
cd ~\Desktop
rmdir /s /q client
rmdir /s /q server
- Delete your Neon project to remove the cloud database and all its data:
- Navigate to the Neon Console at console.neon.tech.
- Select the project you created for this platform.
- Click Settings in the sidebar.
- Scroll to the bottom and click Delete.
- Confirm the deletion in the dialog.
- To delete your Neon account entirely (optional):
- In the Neon Console, click your profile avatar in the top-right corner.
- Select Account settings.
- Scroll to the Delete section and follow the prompts.
- You can reactivate your account within 30 days by logging in again.
Nice Work!
Nice Work!
You just built a full-stack DSA practice platform from scratch. This is a serious piece of engineering that pulls together frontend, backend, database design, algorithm implementation, and third-party API integration into one cohesive application.
Here's what you accomplished:
- Built a React + Express + Neon PostgreSQL full-stack application that aggregates coding problems from Codeforces and LeetCode with filtering, pagination, and sorting.
- Implemented the TF-IDF algorithm from scratch to compute cosine similarity between problems and surface related challenges through intelligent grouping.
- Created a rich text note editor with Tiptap, JWT authentication with bcrypt password hashing, and a live contest calendar with countdown timers and browser notifications.
- Added a personalized Problem of the Day recommendation system that filters by user preferences and tracks daily visit streaks.
Ready to quiz yourself?