diff --git a/routes/main_routes.py b/routes/main_routes.py index 658553e..1c7b54b 100644 --- a/routes/main_routes.py +++ b/routes/main_routes.py @@ -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 @@ -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/") def project_detail(project_id): """Render the full detail page for a single project.""" diff --git a/static/script.js b/static/script.js index f97e5a0..6fd44c0 100644 --- a/static/script.js +++ b/static/script.js @@ -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) { @@ -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) @@ -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) { @@ -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)); }); @@ -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 = + "" + + "" + gap.skill + "" + + " — unlocks " + gap.unlocks + "" + + " more project" + (gap.unlocks === 1 ? "" : "s") + "" + + ""; + + 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 diff --git a/static/style.css b/static/style.css index b399ee5..b47b6d1 100644 --- a/static/style.css +++ b/static/style.css @@ -2983,4 +2983,148 @@ select:focus { flex: 1; white-space: pre; color: #e6edf3; -} \ No newline at end of file +} +/* ============================================================ + 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; +} diff --git a/templates/index.html b/templates/index.html index aa7751f..d863b57 100644 --- a/templates/index.html +++ b/templates/index.html @@ -546,6 +546,18 @@

No Projects Found

+ + + @@ -636,7 +648,7 @@ -
+