# Project export: Poof!

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: TreeHacks 2025
- Tagline: Poof simplifies exchange planning process by letting you search for equivalent courses, compare schools, and bookmark options, all in one place.
- Devpost: https://devpost.com/software/poof-3qshno
- GitHub: https://github.com/tongliu999/TreeHacks
- Video: https://www.youtube.com/embed/Fk0n4rqkcy8?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 3 GitHub contributor(s) — eggy (46 commits), SuperZooper3 (27 commits), tongliu999 (12 commits)

## Devpost submission (written by the team)

### Inspiration

Going on exchange should be one of the most exciting and fun experiences of a student's life. But too often, the process becomes tedious and stressful, triple-checking eligibility, verifying course equivalencies, and juggling countless open tabs. We hope to simplify this process and allow students to find all of this info in one page.

### What it does

Our platform lets you search for equivalent courses and compare schools—all in one place. Simply input a course from your home school, select your desired exchange school, and instantly see relevant course matches. Plus, you can bookmark courses to compare entire schools and find the best fit for you! After you select a few courses that interest you, Poof can help you decide on a final course load, simplifying the painstakingly difficult process of ensuring that the courses are right for you.

### How we built it

We decided pretty quickly that combing through dozens and dozens of pages of information was going to be pretty difficult and we decided to use Perplexity to try to simplify this process. Using Perplexity’s Sonar pro model, we were able to create a few requests that could pull relevant information such as university courses, course descriptions, and the similarity between different courses. Then using Flask, we were then able to create a few apis that could then utilize the Perplexity API calls. We also used MongoDB to store existing queries for courses, courses that the user has bookmarked, and also equivalent courses for previously queried courses. Keeping this in mind, we were able to heavily reduce the number of tokens that we used and we also were able to reduce the loading time significantly by reducing the total queries made. We then used React to build the frontend, sprinkling in features such as login, infinite comparisons, and search by course code.

### Challenges we ran into

One of the earliest challenges we ran into was dealing with the Perplexity queries as oftentimes Sonar Pro would hallucinate, resulting in inaccurate information being sent back from the request. We then focused on reducing the size of the queries and making sure that the new segmented queries returned accurate and useful information. Another issue that we ran into was dealing with the increasingly long times of the Perplexity queries. At one point, one of our features had to query 16 different times to Perplexity. A single query would take 16+ seconds to finish and would take far too long to offer a good experience to the user. We then decided to start caching results that our users make, ensuring a quick way to access common queries and we would then be able to quickly return information to ensure a good user experience.

### Accomplishments we're proud of

We’re proud of how seamless the user experience is and the accuracy of our results. During testing, our platform consistently delivered results that matched what we would have found through manual research. By combining thoughtful UI decisions with precise and efficient results, we’ve created a product we’d confidently use ourselves.

### What we learned

We learned the importance of properly fleshing out all the features and flows before we started developing the project. While we were in the middle of designing and developing we realized that one of our flows wasn't properly thought out so we had to pivot and change some functions. Although we were aligned at the end and we reached an efficient user flow, it ultimately added more work. Additionally, we learned the importance of strong UI/UX design. A well-designed, approachable interface makes a huge difference—turning what could have been a stressful, information-heavy experience into one that feels intuitive and inviting for users.

### What's next

From the very beginning, we aimed to build a product that would be applicable to our daily lives - and we hope to continue expanding and making it a diverse platform. We hope to continue to polish and add features that can allow our peers to use this during their own exchange process. We aim to add features like auto drafting an email so that students can easily send them to their advisors and a 3D map which will allow students to get an overall picture of all the available and most compatible schools for them.

## README (from the GitHub repository)

# Poof!

Find equivalent courses at exchange host universities by entering your course codes from home.

[![Demo](https://github.com/user-attachments/assets/e5450dab-6b46-4bae-84f9-cbe4ab671f88)](https://youtu.be/Fk0n4rqkcy8)

[Demo video: https://youtu.be/Fk0n4rqkcy8](https://youtu.be/Fk0n4rqkcy8)

Devpost: https://devpost.com/software/poof-3qshno

## Setup

Change the database URL in `server/mongo.py` to your own MongoDB URL.

Add `server/.env` and populate it with your perplexity API key and MongoDB password:

```
PERPLEXITY_KEY=pplx-
MONGO_PASSWORD=ABC
```

Install Python dependencies and start the server:

```
python -m venv venv
source venv/bin/activate
pip install -r requirements.txt
python app.py
```

Run the frontend in `frontend`:

```
yarn
yarn dev
```

(if you're using npm, you may have to force install)

Other README can be found in the frontend and server folders.

## Usage

1. Select your host university under "Your School"
2. Enter a course code that is a degree requirement you need to fulfill over exchange
3. Press "Search"
4. Check that the fetched course matches and press the bookmark
   a. Repeat with any required courses
5. Select your exchange university under "Schools Abroad"
6. Press "Find Courses"
7. Review the matched courses
   - The number indicates the confidence in the odds that the target course can substitute for the original course (0-10)
   - Press the bookmark to save it for later
8. Add any number
9. Go to "My Courses" to plan courses
10. Select a university under "Compare School"
11. Review the auto-populated course selection, adjusting them with the course dropdown as necessary
12. Continue adding schools to compare them with the "+" button

## Gallery

![image](https://github.com/user-attachments/assets/8b2361eb-d9ec-45d2-a8cb-b5c4f1ad14c3)
![image](https://github.com/user-attachments/assets/6d232453-6df1-4069-b621-50eb20923ed1)
![image](https://github.com/user-attachments/assets/94153902-8767-4686-9ea1-2a4e46969b37)


## Detected evidence (automated analysis)

Indexed codebase: 32 recognized source files, 71 KB.
- CSS (language) — detected in the code
- Flask (technology) — detected in the code
- OpenAI (technology) — detected in the code
- Python (language) — detected in the code
- React (technology) — detected in the code
- Tailwind CSS (technology) — detected in the code
- TypeScript (language) — detected in the code

## Codebase structure (from repository index)

### Files (40 of 40)

```
.gitignore
frontend/.dockerignore
frontend/.gitignore
frontend/app/app.css
frontend/app/components/AbroadSearch.tsx
frontend/app/components/BlankCourseCard.tsx
frontend/app/components/CompareSchool.tsx
frontend/app/components/Container.tsx
frontend/app/components/CourseCard.tsx
frontend/app/components/EmailPopup.tsx
frontend/app/components/form/FormInput.tsx
frontend/app/components/navbar.tsx
frontend/app/components/SavedCourseCard.tsx
frontend/app/components/SavedCourseList.tsx
frontend/app/components/SavedDropdownCourseCard.tsx
frontend/app/components/SchoolSearch.tsx
frontend/app/components/Signin.tsx
frontend/app/constants.ts
frontend/app/context.ts
frontend/app/queries/queries.ts
frontend/app/root.tsx
frontend/app/routes.ts
frontend/app/routes/home.tsx
frontend/app/routes/my.tsx
frontend/Dockerfile
frontend/package.json
frontend/react-router.config.ts
frontend/README.md
frontend/tsconfig.json
frontend/vite-env.d.ts
frontend/vite.config.ts
package.json
README.md
server/app.py
server/course_search.py
server/mongo.py
server/README.md
server/requirements.txt
server/users.py
server/utils.py
```

### Dependencies

- frontend/package.json: @react-router/dev@^7.1.5, @react-router/node@^7.1.5, @react-router/serve@^7.1.5, @tailwindcss/vite@^4.0.0, @types/node@^20, @types/react@^19.0.1, @types/react-dom@^19.0.1, axios@^1.7.9, isbot@^5.1.17, react@^19.0.0, react-dom@^19.0.0, react-hook-form@^7.54.2, react-query@^3.39.3, react-router@^7.1.5, react-router-devtools@^1.1.0, tailwindcss@^4.0.0, typescript@^5.7.2, vite-plugin-svgr@^4.3.0, vite-tsconfig-paths@^5.1.4
- package.json: vite-plugin-svgr@^4.3.0
- server/requirements.txt: annotated-types@==0.7.0, anyio@==4.8.0, blinker@==1.9.0, certifi@==2025.1.31, click@==8.1.8, distro@==1.9.0, dnspython@==1.16.0, exceptiongroup@==1.2.2, Flask@==3.1.0, h11@==0.14.0, httpcore@==1.0.7, httpx@==0.28.1, idna@==3.10, itsdangerous@==2.2.0, Jinja2@==3.1.5, jiter@==0.8.2, MarkupSafe@==3.0.2, openai@==1.63.0, pydantic@==2.10.6, pydantic_core@==2.27.2, pymongo@==3.12.0, python-dotenv@==1.0.1, sniffio@==1.3.1, tqdm@==4.67.1, typing_extensions@==4.12.2, Werkzeug@==3.1.3

### Recent commits (newest first)

- chore: update readme
- chore: doc
- Update README.md
- chore: docs
- chore: revert mongo
- chore: rename env
- chore: fix readme
- Update README.md
- fix: broken bookmark
- fix: add back similarity score
- chore: rename
- chore: remove label
- fix: grammar
- feat: poof!
- fix: no infinite loop
- chore: add delpht
- feat: treehacs
- feat: DEEP RESEARCH
- fix: hide popup shit
- feat: localstorage

## Key source files (fetched from GitHub, selected and truncated for size)

### package.json

```
{
  "dependencies": {
    "vite-plugin-svgr": "^4.3.0"
  }
}

```

### server/requirements.txt

```
annotated-types==0.7.0
anyio==4.8.0
blinker==1.9.0
certifi==2025.1.31
click==8.1.8
distro==1.9.0
dnspython==1.16.0
exceptiongroup==1.2.2
Flask==3.1.0
h11==0.14.0
httpcore==1.0.7
httpx==0.28.1
idna==3.10
itsdangerous==2.2.0
Jinja2==3.1.5
jiter==0.8.2
MarkupSafe==3.0.2
openai==1.63.0
pydantic==2.10.6
pydantic_core==2.27.2
pymongo==3.12.0
python-dotenv==1.0.1
sniffio==1.3.1
tqdm==4.67.1
typing_extensions==4.12.2
Werkzeug==3.1.3

```

### frontend/Dockerfile

```
FROM node:20-alpine AS development-dependencies-env
COPY . /app
WORKDIR /app
RUN npm ci

FROM node:20-alpine AS production-dependencies-env
COPY ./package.json package-lock.json /app/
WORKDIR /app
RUN npm ci --omit=dev

FROM node:20-alpine AS build-env
COPY . /app/
COPY --from=development-dependencies-env /app/node_modules /app/node_modules
WORKDIR /app
RUN npm run build

FROM node:20-alpine
COPY ./package.json package-lock.json /app/
COPY --from=production-dependencies-env /app/node_modules /app/node_modules
COPY --from=build-env /app/build /app/build
WORKDIR /app
CMD ["npm", "run", "start"]
```

### frontend/package.json

```
{
  "name": "frontend",
  "private": true,
  "type": "module",
  "scripts": {
    "build": "react-router build",
    "dev": "react-router dev",
    "start": "react-router-serve ./build/server/index.js",
    "typecheck": "react-router typegen && tsc"
  },
  "dependencies": {
    "@react-router/node": "^7.1.5",
    "@react-router/serve": "^7.1.5",
    "axios": "^1.7.9",
    "isbot": "^5.1.17",
    "react": "^19.0.0",
    "react-dom": "^19.0.0",
    "react-hook-form": "^7.54.2",
    "react-query": "^3.39.3",
    "react-router": "^7.1.5"
  },
  "devDependencies": {
    "@react-router/dev": "^7.1.5",
    "@tailwindcss/vite": "^4.0.0",
    "@types/node": "^20",
    "@types/react": "^19.0.1",
    "@types/react-dom": "^19.0.1",
    "react-router-devtools": "^1.1.0",
    "tailwindcss": "^4.0.0",
    "typescript": "^5.7.2",
    "vite-plugin-svgr": "^4.3.0",
    "vite-tsconfig-paths": "^5.1.4"
  }
}

```

### server/app.py

```python
from flask import Flask, jsonify, request
from course_search import CourseSearch
from mongo import ping
from users import UserManager
from flask_cors import CORS

app = Flask(__name__)
CORS(app)

@app.route('/')
def hello():
    return jsonify({"message": "Welcome to the API!"})

@app.route('/get_course_desc', methods=['POST'])
def get_course_desc():
    data = request.get_json()
    if not data:
        return jsonify({"error": "No data provided"}), 400
    
    from_university = data.get('from_university')
    if not from_university:
        return jsonify({"error": "From university is required"}), 400
    
    from_code = data.get('from_code')
    if not from_code:
        return jsonify({"error": "From code is required"}), 400
    
    course_search = CourseSearch()
    result = course_search.get_course_desc(from_code, from_university)
    
    return jsonify(result)

@app.route('/course_search', methods=['POST'])
def course_search():
    data = request.get_json()
    if not data:
        return jsonify({"error": "No data provided"}), 400
    
    from_university = data.get('from_university')
    if not from_university:
        return jsonify({"error": "From university is required"}), 400
        
    from_code = data.get('from_code')
    if not from_code:
        return jsonify({"error": "From code is required"}), 400
    
    user_id = data.get('user_id')
    if not user_id:
        return jsonify({"error": "User ID is required"}), 400
        
    to_university = data.get('to_university')
    if not to_university:
        return jsonify({"error": "To university is required"}), 400
    
    course_search = CourseSearch()
    course_search.course_finder(from_code, from_university, target_school=to_university, num=5)


    user_manager = UserManager()
    result = user_manager.get_equivalences_with_favourites(user_id, from_code, from_university, to_university)
    return jsonify(result)

# Gets the search course info, and annotate with whether it is a favourite or not
@app.route('/get_user_courses', methods=['POST'])
def get_user_courses():
    data = request.get_json()
    if not data:
        return jsonify({"error": "No data provided"}), 400
    
    user_id = data.get('user_id')
    if not user_id:
        return jsonify({"error": "User ID is required"}), 400
    
    from_code = data.get('from_code')
    if not from_code:
        return jsonify({"error": "From code is required"}), 400
    
    from_university = data.get('from_university')
    if not from_university:
        return jsonify({"error": "From university is required"}), 400
    
    to_university = data.get('to_university')
    if not to_university:
        return jsonify({"error": "To university is required"}), 400

    user_manager = UserManager()
    result = user_manager.get_courses_with_favourites(user_id, from_code, from_university, to_university)
    return result

@app.route('/add_favourite', methods=['POST'])
def add_favourite():
    data = request.get_json()
    if not data:
        return jsonify({"error": "No data provided"}), 400
    
    user_id = data.get('user_id')
    if not user_id:
        return jsonify({"error": "User ID is required"}), 400
    
    eq_id = data.get('eq_id')
    if not eq_id:
        return jsonify({"error": "Equivalence ID is required"}), 400

    user_manager = UserManager()
    success = user_manager.add_user_favourite(user_id, eq_id)
    if success:
        return jsonify({"message": "Favourite added", "user_id": user_id, "eq_id": eq_id}), 200
    return jsonify({"error": "Failed to add favourite"}), 500

@app.route('/remove_favourite', methods=['POST'])
def remove_favourite():
    data = request.get_json()
    if not data:
        return jsonify({"error": "No data provided"}), 400
    
    user_id = data.get('user_id')
    if not user_id:
        return jsonify({"error": "User ID is required"}), 400
    
    eq_id = data.get('eq_id')
    if not eq_id:
        return jsonify({"error": "Equivalence ID is required"}), 400

    user_manager = UserManager()
    success = user_manager.remove_user_favourite(user_id, eq_id)
    if success:
        return jsonify({"message": "Favourite removed", "user_id": user_id, "eq_id": eq_id}), 200
    return jsonify({"error": "Failed to remove favourite"}), 500

@app.route('/get_favourites', methods=['GET'])
def get_favourites():
    data = request.get_json()
    if not data:
        return jsonify({"error": "No data provided"}), 400
    
    user_id = data.get('user_id')
    if not user_id:
        return jsonify({"error": "User ID is required"}), 400
    
    user_manager = UserManager()
    result = user_manager.get_user_favourites(user_id)
    return result

@app.route('/get_favourites_with_course_info', methods=['GET'])
def get_favourites_with_course_info():
    data = request.get_json()
    if not data:
        return jsonify({"error": "No data provided"}), 400
    
    user_id = data.get('user_id')
    if not user_id:
        return jsonify({"error": "User ID is required"}), 400
    
    user_manager = UserManager()
    result = user_manager.get_user_favourites_with_course_info(user_id)
    return result

@app.route('/get_equivalences_with_favourites', methods=['GET'])
def get_equivalences_with_favourites():
    data = request.get_json()
    if not data:
        return jsonify({"error": "No data provided"}), 400
    
    user_id = data.get('user_id')
    if not user_id:
        return jsonify({"error": "User ID is required"}), 400
    
    from_code = data.get('from_code')
    if not from_code:
        return jsonify({"error": "From code is required"}), 400
    
    from_university = data.get('from_university')
    if not from_university:
        return jsonify({"error": "From university is required"}), 400
    
    to_university = data.get('to_university')
    if not to_university:
        return jsonify({"error": "To university is required"}), 400
    
    user_manager = UserManager()
    result = user_manager.get_equivalences_with_favo
[truncated — 1036 more characters]
```

### frontend/vite-env.d.ts

```typescript
/// <reference types="vite-plugin-svgr/client" />

```

### frontend/react-router.config.ts

```typescript
import type { Config } from "@react-router/dev/config";

export default {
  // Config options...
  // Server-side render by default, to enable SPA mode set this to `false`
  ssr: true,
} satisfies Config;

```

### frontend/vite.config.ts

```typescript
import { reactRouter } from "@react-router/dev/vite";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
import svgr from "vite-plugin-svgr";

export default defineConfig({
  plugins: [tailwindcss(), reactRouter(), tsconfigPaths(), svgr()],
});

```

### server/utils.py

```python
import uuid

# Has side effects but that's fine because it's the goal
def format_equivalences(equivalences):
    for eq in equivalences:
        eq['eq_id'] = f"eq_{uuid.uuid4()}"
        eq['similarity_score'] = eq['similarity_score'] / 2
        
    return equivalences

def package_equivalences(from_code, from_university, to_university, equivalences):
    return {
        "from_code": from_code,
        "from_university": from_university,
        "to_university": to_university,
        "equivalences": equivalences
    }
```

### server/users.py

```python
from mongo import add_user_favourite, remove_user_favourite, get_user_favourites, get_course_info_by_eq_ids, get_equivalences_with_favourites

class UserManager:
    def add_user_favourite(self, user_id, eq_id):
        return add_user_favourite(user_id, eq_id)

    def remove_user_favourite(self, user_id, eq_id):
        return remove_user_favourite(user_id, eq_id)
    
    def get_user_favourites(self, user_id):
        return get_user_favourites(user_id)
    
    def get_user_favourites_with_course_info(self, user_id):
        return get_course_info_by_eq_ids(self.get_user_favourites(user_id))
    
    def get_equivalences_with_favourites(self, user_id, from_code, from_university, to_university):
        return get_equivalences_with_favourites(user_id, from_code, from_university, to_university)
        
    
```

[23 more indexed source files omitted to keep this export small. The full file list is in the Codebase structure section above.]