Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 33 additions & 31 deletions static/script.js
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ if (clearFiltersBtn) {
function isSkillSelected(skill) {
var normalizedSkill = normalizeSkill(skill);
return selectedSkills.some(function (selectedSkill) {
return normalizeSkill(selectedSkill) === normalizedSkill;
return normalizeSkill(selectedSkill.name) === normalizedSkill;
});
}

Expand Down Expand Up @@ -306,9 +306,7 @@ if (clearFiltersBtn) {
quickPickChips.forEach(function (chip) {
chip.addEventListener("click", function () {
var skill = chip.getAttribute("data-skill");
var isAlreadySelected = selectedSkills.some(function (s) {
return s.toLowerCase() === skill.toLowerCase();
});
var isAlreadySelected = isSkillSelected(skill);

if (isAlreadySelected) {
removeSkill(skill);
Expand Down Expand Up @@ -364,7 +362,10 @@ if (clearFiltersBtn) {
// Block duplicate entries (case-insensitive)
if (isSkillSelected(skill)) return;

selectedSkills.push(skill);
var proficiencySelect = document.getElementById("skill-proficiency");
var proficiency = proficiencySelect ? proficiencySelect.value : "Intermediate";

selectedSkills.push({ name: skill, level: proficiency });
renderSelectedChips();
syncSkillsHiddenInput();
updateQuickPickState();
Expand All @@ -376,7 +377,7 @@ if (clearFiltersBtn) {
function removeSkill(skill) {
// Rebuild the array without the skill that was just removed
selectedSkills = selectedSkills.filter(function (selectedSkill) {
return normalizeSkill(selectedSkill) !== normalizeSkill(skill);
return normalizeSkill(selectedSkill.name) !== normalizeSkill(skill);
});
renderSelectedChips();
syncSkillsHiddenInput();
Expand All @@ -388,11 +389,22 @@ if (clearFiltersBtn) {
function renderSelectedChips() {
// Wipe out old chips first so we don't end up with duplicates in the UI
chipsSelectedEl.innerHTML = "";
selectedSkills.forEach(function (skill) {
selectedSkills.forEach(function (skillObj) {
var skill = skillObj.name;
var level = skillObj.level;

// Create a new chip element for each selected skill
var chipEl = document.createElement("span");
chipEl.className = "skill-chip-selected";
chipEl.textContent = skill;

// Proficiency badge
var badge = document.createElement("span");
badge.className = "skill-proficiency-badge";
badge.textContent = level.substring(0, 3); // BEG, INT, ADV
chipEl.appendChild(badge);

var textNode = document.createTextNode(skill);
chipEl.appendChild(textNode);

// Remove button for each chip (create lil "x" button)
var removeBtn = document.createElement("button");
Expand All @@ -412,12 +424,11 @@ if (clearFiltersBtn) {
}

function syncSkillsHiddenInput() {
if (!skillsHidden){
var skillsHidden = document.getElementById("skills");
}
// Keep the hidden <input> in sync for form serialisation
// The API expects a comma-separated string, so join the array that way
skillsHidden.value = selectedSkills.join(", ");
// Serialize as JSON string for the backend
if (skillsHidden) {
skillsHidden.value = JSON.stringify(selectedSkills);
}
}

updateQuickPickState();
Expand Down Expand Up @@ -525,17 +536,15 @@ if (clearFiltersBtn) {

renderResults(data.projects || [], data.message);
})
.catch(function () {

.catch(function (err) {
setLoadingState(false);
//combine form values into an object to send to server/api
var payload = {
// Prefer the hidden input value; fall back to raw text box if hidden input is empty
skills: skillsHidden.value.trim() || skillsTextInput.value.trim(),
level: document.getElementById("level").value,
interest: document.getElementById("interest").value,
time: document.getElementById("time").value
};
var generalErr = document.getElementById("form-error-general");
if (generalErr) {
generalErr.textContent = "Something went wrong. Please try again.";
}
console.error("Recommendation error:", err);
});
});
});

// Manages the loading state of the form and results section(whats visible or not)
Expand All @@ -556,7 +565,6 @@ if (clearFiltersBtn) {
resultsSection.scrollIntoView({ behavior: "smooth" });
} else {
resultsLoadingEl.style.display = "none";
resultsGrid.style.display = "grid"; //switch back to gird layout
}
}

Expand All @@ -574,17 +582,11 @@ if (clearFiltersBtn) {
resultsGrid.innerHTML = "";

if (!projects || projects.length === 0) {
resultsGrid.style.display = "none";
resultsEmptyEl.style.display = "block";
resultsGrid.style.display = "none";
resultsEmptyEl.style.display = "block";
if (message && emptyMessageEl) emptyMessageEl.textContent = message;
if (!projects || projects.length === 0) { //if no projects returned from api, show the "no results" message and hide the grid
resultsGrid.style.display = "none";
resultsEmptyEl.style.display = "block";

// Show a friendly custom message when the user selected an interest
var selectedInterest = document.getElementById("interest")?.value;
var selectedInterest = document.getElementById("interest") ? document.getElementById("interest").value : "";
if (selectedInterest) {
emptyMessageEl.textContent = "No projects are currently available for this interest. Please check back later or try a different area.";
} else if (message) {
Expand Down
35 changes: 35 additions & 0 deletions static/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -1333,6 +1333,41 @@ label {
padding: 3px 10px 3px 12px;
}

.skill-proficiency-badge {
font-size: 0.65rem;
text-transform: uppercase;
background: rgba(0, 0, 0, 0.08);
color: var(--indigo-800);
padding: 1px 6px;
border-radius: 4px;
margin-right: 2px;
font-weight: 700;
letter-spacing: 0.02em;
}

.skill-proficiency-select {
border: none;
background: var(--indigo-50);
color: var(--indigo-700);
font-size: 0.75rem;
font-weight: 700;
padding: 2px 24px 2px 8px;
border-radius: 4px;
cursor: pointer;
margin-right: 6px;
outline: none;
appearance: none;
-webkit-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 24 24' fill='none' stroke='%233347e0' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'%3E%3C/path%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 8px center;
transition: background var(--t);
}

.skill-proficiency-select:hover {
background-color: var(--indigo-100);
}

.skill-chip-remove {
background: none;
border: none;
Expand Down
13 changes: 9 additions & 4 deletions templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -347,10 +347,18 @@ <h2 class="section-title">Find Your Next Project</h2>
</div>
<div class="skill-input-wrap" id="skill-input-wrap">
<div class="skill-chips-selected" id="skill-chips-selected"></div>

<!-- Skill Proficiency Selector -->
<select id="skill-proficiency" class="skill-proficiency-select" aria-label="Skill Proficiency">
<option value="Beginner">Beginner</option>
<option value="Intermediate" selected>Intermediate</option>
<option value="Advanced">Advanced</option>
</select>

<input
type="text"
id="skills-input"
placeholder="Type a skill and press Enter..."
placeholder="Type a skill..."
autocomplete="off"
aria-haspopup="listbox"
aria-expanded="false"
Expand Down Expand Up @@ -524,9 +532,6 @@ <h2 class="section-title">Recommended Projects</h2>
<div id="results-empty" style="display:none;">
<div class="empty-state">
<div class="empty-icon">
<svg width="52" height="52" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"
stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8" /><line
x1="21" y1="21" x2="16.65" y2="16.65" /></svg>
<svg width="52" height="52" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"
stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8" />
Expand Down
76 changes: 52 additions & 24 deletions utils/recommender.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,40 +32,45 @@

def parse_skills(skills_string):
"""
Convert a raw comma-separated skills string into
a normalized lowercase list.

Example:
"JS, HTML5, CSS3" -> ["javascript", "html", "css"]
Convert a skills string into a normalized list of dicts.
Handles both legacy comma-separated strings and new JSON proficiency objects.
"""

import json

try:
# Try to parse as JSON (new format: [{"name": "Python", "level": "Beginner"}])
skills_data = json.loads(skills_string)
if isinstance(skills_data, list):
return [{
"name": SKILL_ALIASES.get(item["name"].lower(), item["name"].lower()),
"level": item.get("level", "intermediate").lower()
} for item in skills_data if isinstance(item, dict) and item.get("name")]
except (json.JSONDecodeError, TypeError):
pass

# Fallback for legacy format: "JS, HTML5, CSS3" -> [{"name": "javascript", "level": "intermediate"}, ...]
raw_skills = [
s.strip().lower()
for s in skills_string.split(",")
if s.strip()
]

normalized_skills = [
SKILL_ALIASES.get(skill, skill)
return [
{"name": SKILL_ALIASES.get(skill, skill), "level": "intermediate"}
for skill in raw_skills
]

return normalized_skills


def score_single_project(
project, user_skills,
level, interest, time_availability):
"""
Calculate a numeric relevance score for one project.

Each matching criterion adds points:
- Each matching skill: +3
- Level match: +2
- Interest match: +2
- Time match: +1

Returns an integer score (0 means no match at all).
Weights are adjusted based on skill-specific proficiency:
- Skill match: +3 (base)
- Proficiency match bonus: +1
- Close match bonus (user > project): +0.5
"""
# Compare time availability, return results with the same time availibity or lower.
TIME_AVAILABILITY = ['low', 'medium', 'high']
Expand All @@ -74,14 +79,37 @@ def score_single_project(

score = 0

# Compare user's skills against the project's required skills
# Project required skills (normalized)
project_skills = [SKILL_ALIASES.get(s.lower(), s.lower()) for s in project.get("skills", [])]
# Count how many user skills overlap with the
# skills required by the current project.
matched_skills = sum(1 for skill in user_skills if skill in project_skills)
# Add weighted points based on the number of matching skills.
# More overlapping skills result in a higher recommendation score.
score += matched_skills * SCORING_WEIGHTS["skill"]
project_level = project.get("level", "beginner").lower()

# Score each user skill against project requirements
for u_skill in user_skills:
# Handle both list of strings (legacy/tests) and list of dicts (new)
if isinstance(u_skill, dict):
u_name = u_skill.get("name", "")
u_level = u_skill.get("level", "intermediate").lower()
else:
u_name = u_skill
u_level = "intermediate"

if not u_name:
continue

u_name = SKILL_ALIASES.get(u_name.lower(), u_name.lower())

if u_name in project_skills:
# Base match points
points = SCORING_WEIGHTS["skill"]

# Adjust points based on proficiency match with project level
if u_level == project_level:
points += 1 # Perfect match
elif (u_level == "advanced" and project_level == "intermediate") or \
(u_level == "intermediate" and project_level == "beginner"):
points += 0.5 # User over-qualified is still a good match

score += points

# Award points for each additional matching criterion
if project.get("level", "").lower() == level.lower():
Expand Down
Loading