Skip to content
Open
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
27 changes: 26 additions & 1 deletion routes/main_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

from flask import Blueprint, render_template, request, jsonify, send_from_directory, abort, make_response

from utils.recommender import get_recommendations, validate_recommendation_inputs
from utils.recommender import get_recommendations, validate_recommendation_inputs, get_skill_gap
from utils.data_loader import find_project_by_id, load_all_projects, get_project_stats
from utils.file_server import read_starter_code, resolve_starter_file, get_starter_code_dir
import os
Expand Down Expand Up @@ -91,6 +91,31 @@ def recommend():
return jsonify({"projects": results}), 200


@main.route("/api/skill-gap", methods=["POST"])
def skill_gap():
"""
Return skills that would unlock additional project matches for the user.

Expected JSON fields: same as /api/recommend (skills, level, interest, time).
Returns: {"gaps": [{"skill": "React", "unlocks": 4}, ...]}
"""
payload = request.get_json()
if not payload:
return jsonify({"error": "Request body must be valid JSON."}), 400

skills = payload.get("skills", "").strip()
level = payload.get("level", "").strip()
interest = payload.get("interest", "").strip()
time_availability = payload.get("time", "").strip()

errors = validate_recommendation_inputs(skills, level, interest, time_availability)
if errors:
return jsonify({"error": errors[0]}), 400

gaps = get_skill_gap(skills, level, interest, time_availability)
return jsonify({"gaps": gaps}), 200


@main.route("/project/<int:project_id>")
def project_detail(project_id):
"""Render the full detail page for a single project."""
Expand Down
130 changes: 109 additions & 21 deletions static/script.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,9 +94,11 @@ if (clearFiltersBtn) {
skillsTextInput.focus(); // Place cursor back on input
}

// 4. Hide autocomplete suggestions if any are open
// 4. Hide autocomplete suggestions and skill gap section
var suggestionsBox = document.getElementById("skills-suggestions");
if (suggestionsBox) suggestionsBox.innerHTML = "";
var skillGapSection = document.getElementById("skill-gap-section");
if (skillGapSection) skillGapSection.style.display = "none";

// 5. Reset quick-pick chip visual active states if they have any
if (quickPickChips) {
Expand Down Expand Up @@ -524,18 +526,15 @@ if (clearFiltersBtn) {
}

renderResults(data.projects || [], data.message);
fetchSkillGap(payload);
})
.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("API request failed:", err);
});
});
});

// Manages the loading state of the form and results section(whats visible or not)
Expand Down Expand Up @@ -570,21 +569,13 @@ if (clearFiltersBtn) {
function renderResults(projects, message) {
resultsSection.style.display = "block";
resultsLoadingEl.style.display = "none";
// Clear out any cards from a previous search before showing new ones
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 All @@ -600,7 +591,6 @@ if (clearFiltersBtn) {
resultsEmptyEl.style.display = "none";
resultsGrid.style.display = "grid";

//build a card for each project and add it to the grid
projects.forEach(function (project) {
resultsGrid.appendChild(buildProjectCard(project));
});
Expand Down Expand Up @@ -678,6 +668,104 @@ if (clearFiltersBtn) {
return text.length > maxLength ? text.slice(0, maxLength) + "..." : text;
}


// ----------------------------------------------------------
// Skill Gap Analyzer (Issue #138)
// ----------------------------------------------------------

function fetchSkillGap(payload) {
fetch("/api/skill-gap", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
})
.then(function (res) { return res.json(); })
.then(function (data) {
if (!data.error && data.gaps && data.gaps.length > 0) {
renderSkillGap(data.gaps, payload.interest);
}
})
.catch(function () {});
}

function renderSkillGap(gaps, interest) {
var section = document.getElementById("skill-gap-section");
var list = document.getElementById("skill-gap-list");
if (!section || !list) return;

var projectGaps = gaps.filter(function (g) { return !g.trending; });
var trendingGaps = gaps.filter(function (g) { return g.trending; });

list.innerHTML = "";

// Render each project-based gap as an expandable row
projectGaps.forEach(function (gap) {
var wrapper = document.createElement("div");
wrapper.className = "skill-gap-item";
wrapper.setAttribute("role", "button");
wrapper.setAttribute("tabindex", "0");
wrapper.setAttribute("aria-expanded", "false");

var header = document.createElement("div");
header.className = "skill-gap-header";
header.innerHTML =
"<span class='skill-gap-arrow'>→</span>" +
"<span class='skill-gap-label'><strong>" + gap.skill + "</strong>" +
" — unlocks <span class='skill-gap-count'>" + gap.unlocks + "</span>" +
" more project" + (gap.unlocks === 1 ? "" : "s") + "</span>" +
"<span class='skill-gap-chevron'>▾</span>";

var dropdown = document.createElement("div");
dropdown.className = "skill-gap-dropdown";
(gap.projects || []).forEach(function (proj) {
var link = document.createElement("a");
link.href = "/project/" + proj.id;
link.className = "skill-gap-project-link";
link.textContent = proj.title;
dropdown.appendChild(link);
});

wrapper.appendChild(header);
wrapper.appendChild(dropdown);
wrapper.addEventListener("click", function () {
var isOpen = wrapper.getAttribute("aria-expanded") === "true";
wrapper.setAttribute("aria-expanded", isOpen ? "false" : "true");
dropdown.style.display = isOpen ? "none" : "flex";
});

list.appendChild(wrapper);
});

// Render all trending skills as one combined card
if (trendingGaps.length > 0) {
var label = interest
? "Trending in " + interest.charAt(0).toUpperCase() + interest.slice(1)
: "Trending Skills";

var card = document.createElement("div");
card.className = "skill-gap-trending-card";

var cardTitle = document.createElement("div");
cardTitle.className = "skill-gap-trending-card-title";
cardTitle.textContent = label;
card.appendChild(cardTitle);

var tagsWrap = document.createElement("div");
tagsWrap.className = "skill-gap-trending-tags";
trendingGaps.forEach(function (gap) {
var tag = document.createElement("span");
tag.className = "skill-gap-trending-tag";
tag.textContent = gap.skill;
tagsWrap.appendChild(tag);
});
card.appendChild(tagsWrap);
list.appendChild(card);
}

// Show the section but do NOT scroll — renderResults already scrolled to results
section.style.display = "block";
}

} // end isIndexPage


Expand Down
146 changes: 145 additions & 1 deletion static/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -2983,4 +2983,148 @@ select:focus {
flex: 1;
white-space: pre;
color: #e6edf3;
}
}
/* ============================================================
Skill Gap Section (Issue #138)
============================================================ */

#skill-gap-section {
padding: 48px 0;
border-top: 1px solid var(--border);
}

#skill-gap-section .section-eyebrow,
#skill-gap-section .section-title,
#skill-gap-section .section-sub {
text-align: left !important;
}

.skill-gap-list {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 24px;
max-width: 600px;
}

.skill-gap-item {
display: flex;
flex-direction: column;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--r-md, 10px);
font-size: 0.95rem;
color: var(--text-body);
transition: border-color 0.2s;
cursor: pointer;
overflow: hidden;
}

.skill-gap-item:hover {
border-color: var(--indigo-400, #818cf8);
}

.skill-gap-header {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 20px;
}

.skill-gap-label {
flex: 1;
}

.skill-gap-chevron {
font-size: 1rem;
color: var(--text-muted, #888);
transition: transform 0.2s;
}

.skill-gap-item[aria-expanded="true"] .skill-gap-chevron {
transform: rotate(180deg);
}

.skill-gap-item[aria-expanded="true"] {
border-color: var(--indigo-400, #818cf8);
}

.skill-gap-dropdown {
display: none;
flex-direction: column;
border-top: 1px solid var(--border);
padding: 8px 0;
background: var(--bg, #fafafa);
}

.skill-gap-project-link {
padding: 9px 20px 9px 44px;
font-size: 0.9rem;
color: var(--indigo-600, #4f46e5);
text-decoration: none;
transition: background 0.15s;
}

.skill-gap-project-link:hover {
background: var(--surface-hover, #f0f0ff);
text-decoration: underline;
}

.skill-gap-trending-card {
border: 1px solid var(--border);
border-radius: var(--r-md, 10px);
padding: 18px 20px;
background: var(--surface);
}

.skill-gap-trending-card-title {
font-size: 0.8rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--indigo-600, #4f46e5);
margin-bottom: 14px;
}

.skill-gap-trending-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}

.skill-gap-trending-tag {
display: inline-block;
padding: 5px 14px;
border-radius: 999px;
font-size: 0.88rem;
font-weight: 500;
background: linear-gradient(135deg, #ede9fe, #dbeafe);
color: var(--indigo-600, #4f46e5);
border: 1px solid #c7d2fe;
}

.skill-gap-trending-badge {
display: inline-block;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
background: linear-gradient(135deg, #f59e0b, #ef4444);
color: #fff;
padding: 2px 8px;
border-radius: 999px;
margin-left: 8px;
vertical-align: middle;
}

.skill-gap-arrow {
color: var(--indigo-600, #4f46e5);
font-size: 1.1rem;
font-weight: 700;
flex-shrink: 0;
}

.skill-gap-count {
color: var(--indigo-600, #4f46e5);
font-weight: 700;
}
14 changes: 13 additions & 1 deletion templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,18 @@ <h3>No Projects Found</h3>
</div>
</section>

<!-- ============================================================
Skill Gap Section — shown only when at least one skill unlocks more projects
============================================================ -->
<section id="skill-gap-section" style="display:none;">
<div class="container">
<div class="section-eyebrow">Level Up</div>
<h2 class="section-title">Want More Matches?</h2>
<p class="section-sub" style="text-align: left;">Learning one of these skills would unlock more projects for your current level and interest.</p>
<div id="skill-gap-list" class="skill-gap-list"></div>
</div>
</section>

<!-- ============================================================
CTA Banner
============================================================ -->
Expand Down Expand Up @@ -636,7 +648,7 @@ <h4 class="footer-col-title">About Us</h4>
</footer>

<!-- GitHub Modal Overlay -->
<div class="code-panel-overlay" id="github-modal-overlay" style="align-items: center; justify-content: center; opacity: 1;">
<div class="code-panel-overlay" id="github-modal-overlay" style="display: none; align-items: center; justify-content: center;">
<div class="sidebar-card" style="width: 100%; max-width: 400px; margin: 20px; z-index: 400; box-shadow: 0 20px 50px rgba(0,0,0,0.3);">

<div class="sidebar-card-title">
Expand Down
Loading
Loading