From 289e89edec216ed53ad16770e383fe75e8f5657d Mon Sep 17 00:00:00 2001 From: JoseMelNet Date: Mon, 6 Apr 2026 14:23:16 -0500 Subject: [PATCH 1/7] feat: improve extension task tracking and side panel flow --- README.md | 32 ++- api.py | 95 +++++---- .../repositories/vacancy_repository.py | 43 +++- chrome-extension/background.js | 33 ++- chrome-extension/manifest.json | 2 +- .../{popup.html => sidepanel.html} | 2 +- chrome-extension/{popup.js => sidepanel.js} | 196 +++++++++++++----- docs/ARCHITECTURE.md | 38 +++- tests/test_api.py | 79 ++++++- tests/test_vacancy_repository.py | 31 +++ 10 files changed, 444 insertions(+), 107 deletions(-) rename chrome-extension/{popup.html => sidepanel.html} (99%) rename chrome-extension/{popup.js => sidepanel.js} (73%) diff --git a/README.md b/README.md index e4e5169..3ef1463 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,9 @@ Aplicacion para registrar vacantes, analizarlas contra un perfil profesional y g - registrar vacantes - listar, archivar, reactivar y eliminar vacantes - analizar vacantes contra el perfil activo +- guardar vacantes desde la extension de Chrome +- mostrar progreso de guardado y analisis en la extension +- recuperar analisis historicos en la extension al reabrir una vacante ya registrada por `link` - gestionar aplicaciones y su seguimiento - gestionar perfil profesional, skills, experiencia, proyectos, educacion, cursos y certificaciones - renderizar una vista CV consolidada desde la web @@ -32,7 +35,7 @@ CVs-Optimizator/ | |-- infrastructure/ | `-- interfaces/ | `-- web/ -|-- modules/ +|-- chrome-extension/ |-- tests/ |-- sql_queries/ |-- run_tests.py @@ -106,6 +109,27 @@ La interfaz web ya cubre el flujo principal del producto: Las vistas largas de `Inbox` y `Seguimiento` ya incluyen paginacion y tamano de pagina configurable para evitar listas demasiado pesadas. +## Extension de Chrome + +La extension usa `side panel` en lugar de popup efimero. + +Flujo actual: + +1. extrae la vacante desde LinkedIn +2. guarda la vacante por API local +3. inicia el analisis en segundo plano +4. muestra estados intermedios en el panel lateral +5. si la vacante ya existia y tenia analisis, lo recupera desde la BD usando el `link` + +Endpoints usados por la extension: + +- `GET /health` +- `POST /vacantes` +- `POST /vacantes/async` +- `GET /vacantes/tasks/{task_id}` +- `GET /vacantes/{vacancy_id}/analysis` +- `GET /vacantes/by-link?link=...` + ## Tests Entrada unica recomendada: @@ -121,6 +145,7 @@ Estado actual de la suite principal: - tests de API - tests de rutas web - tests de flujos integrados con mocks +- tests del flujo async y de recuperacion de analisis por `link` ## CI @@ -140,7 +165,7 @@ Resumen corto: - `app/application`: casos de uso y servicios - `app/infrastructure`: conexion y repositorios - `app/interfaces/web`: interfaz HTML principal -- `modules`: codigo residual compartido, como `analizar_vacante.py` +- `chrome-extension`: integracion local con LinkedIn y la API Detalle adicional en [ARCHITECTURE.md](/C:/Users/josem/PycharmProjects/CVs-Optimizator/docs/ARCHITECTURE.md). @@ -148,5 +173,6 @@ Detalle adicional en [ARCHITECTURE.md](/C:/Users/josem/PycharmProjects/CVs-Optim - la web es ahora la interfaz recomendada - `app.py` en la raiz queda solo como acceso rapido informativo -- la suite principal corre localmente con `53` tests cubriendo la interfaz web principal +- la extension de Chrome ya opera con panel lateral y recuperacion de analisis historico por `link` +- la suite principal corre localmente con `60` tests - el pipeline de CI ya esta preparado para validar cambios automaticamente diff --git a/api.py b/api.py index 430cefe..70eb710 100644 --- a/api.py +++ b/api.py @@ -9,7 +9,7 @@ from typing import Optional from uuid import uuid4 -from fastapi import BackgroundTasks, FastAPI, HTTPException +from fastapi import BackgroundTasks, FastAPI, HTTPException, Query from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from pydantic import BaseModel @@ -38,8 +38,8 @@ profile_repository=profile_repository, analysis_repository=analysis_repository, ) -analysis_jobs: dict[str, dict] = {} -analysis_jobs_lock = threading.Lock() +analysis_tasks: dict[str, dict] = {} +analysis_tasks_lock = threading.Lock() app = FastAPI( @@ -78,12 +78,12 @@ class VacanteResponse(BaseModel): class AsyncVacanteResponse(VacanteResponse): - job_id: str + task_id: str status: str -class AnalysisJobResponse(BaseModel): - job_id: str +class AnalysisTaskResponse(BaseModel): + task_id: str vacancy_id: int status: str step: str @@ -98,24 +98,30 @@ class VacancyAnalysisResponse(BaseModel): analysis: dict +class VacancyByLinkResponse(BaseModel): + found: bool + vacancy: Optional[dict] = None + analysis: Optional[dict] = None + + def _utc_now() -> str: return datetime.utcnow().isoformat(timespec="seconds") + "Z" -def _set_job_state(job_id: str, **updates) -> dict: - with analysis_jobs_lock: - current = analysis_jobs.get(job_id, {}).copy() +def _set_task_state(task_id: str, **updates) -> dict: + with analysis_tasks_lock: + current = analysis_tasks.get(task_id, {}).copy() current.update(updates) current["updated_at"] = _utc_now() - analysis_jobs[job_id] = current + analysis_tasks[task_id] = current return current.copy() -def _create_analysis_job(vacancy_id: int) -> dict: - job_id = str(uuid4()) +def _create_analysis_task(vacancy_id: int) -> dict: + task_id = str(uuid4()) now = _utc_now() - job = { - "job_id": job_id, + task = { + "task_id": task_id, "vacancy_id": vacancy_id, "status": "queued", "step": "saved", @@ -124,14 +130,14 @@ def _create_analysis_job(vacancy_id: int) -> dict: "created_at": now, "updated_at": now, } - with analysis_jobs_lock: - analysis_jobs[job_id] = job - return job.copy() + with analysis_tasks_lock: + analysis_tasks[task_id] = task + return task.copy() -def _run_analysis_job(job_id: str, vacancy_id: int) -> None: - _set_job_state( - job_id, +def _run_analysis_task(task_id: str, vacancy_id: int) -> None: + _set_task_state( + task_id, status="running", step="analyzing", message="Analizando vacante contra el perfil activo.", @@ -140,16 +146,16 @@ def _run_analysis_job(job_id: str, vacancy_id: int) -> None: try: result = analyze_vacancy_use_case.execute(vacancy_id) if result["omitido"]: - _set_job_state( - job_id, + _set_task_state( + task_id, status="completed", step="completed", message="Vacante guardada. Analisis omitido porque no hay perfil activo.", error=None, ) return - _set_job_state( - job_id, + _set_task_state( + task_id, status="completed", step="completed", message="Vacante guardada y analizada correctamente.", @@ -157,8 +163,8 @@ def _run_analysis_job(job_id: str, vacancy_id: int) -> None: ) except AnalysisError as exc: logger.warning("La vacante %s se guardo, pero el analisis automatico fallo: %s", vacancy_id, exc) - _set_job_state( - job_id, + _set_task_state( + task_id, status="failed", step="failed", message="La vacante se guardo, pero el analisis fallo.", @@ -257,25 +263,30 @@ def crear_vacante_async(payload: VacantePayload, background_tasks: BackgroundTas if not resultado["success"] or not resultado.get("id"): raise HTTPException(status_code=500, detail=resultado["message"]) - job = _create_analysis_job(resultado["id"]) - background_tasks.add_task(_run_analysis_job, job["job_id"], resultado["id"]) + task = _create_analysis_task(resultado["id"]) + background_tasks.add_task(_run_analysis_task, task["task_id"], resultado["id"]) return AsyncVacanteResponse( success=True, message="Vacante guardada. Analisis en segundo plano iniciado.", id=resultado["id"], - job_id=job["job_id"], - status=job["status"], + task_id=task["task_id"], + status=task["status"], ) -@app.get("/vacantes/jobs/{job_id}", response_model=AnalysisJobResponse) -def get_analysis_job(job_id: str): - with analysis_jobs_lock: - job = analysis_jobs.get(job_id) - if not job: - raise HTTPException(status_code=404, detail="Job no encontrado") - return AnalysisJobResponse(**job) +@app.get("/vacantes/tasks/{task_id}", response_model=AnalysisTaskResponse) +def get_analysis_task(task_id: str): + with analysis_tasks_lock: + task = analysis_tasks.get(task_id) + if not task: + raise HTTPException(status_code=404, detail="Tarea no encontrada") + return AnalysisTaskResponse(**task) + + +@app.get("/vacantes/jobs/{task_id}", response_model=AnalysisTaskResponse) +def get_analysis_job_legacy(task_id: str): + return get_analysis_task(task_id) @app.get("/vacantes/{vacancy_id}/analysis", response_model=VacancyAnalysisResponse) @@ -284,3 +295,13 @@ def get_vacancy_analysis(vacancy_id: int): if not analysis: raise HTTPException(status_code=404, detail="Analisis no encontrado") return VacancyAnalysisResponse(vacancy_id=vacancy_id, analysis=analysis) + + +@app.get("/vacantes/by-link", response_model=VacancyByLinkResponse) +def get_vacancy_by_link(link: str = Query(..., min_length=1)): + vacancy = vacancy_repository.get_by_link(link) + if not vacancy: + return VacancyByLinkResponse(found=False, vacancy=None, analysis=None) + + analysis = analysis_repository.get_by_vacancy_id(vacancy["id"]) + return VacancyByLinkResponse(found=True, vacancy=vacancy, analysis=analysis) diff --git a/app/infrastructure/persistence/repositories/vacancy_repository.py b/app/infrastructure/persistence/repositories/vacancy_repository.py index c2dc503..1ba05a2 100644 --- a/app/infrastructure/persistence/repositories/vacancy_repository.py +++ b/app/infrastructure/persistence/repositories/vacancy_repository.py @@ -15,6 +15,16 @@ class VacancyRepository: """Persistence access for vacancies.""" + @staticmethod + def normalize_link(link: Optional[str]) -> Optional[str]: + if not link: + return None + normalized = link.strip() + if not normalized: + return None + normalized = normalized.split("?", 1)[0].rstrip("/") + return normalized or None + @staticmethod def _close(cursor=None, conn=None) -> None: if cursor is not None: @@ -71,7 +81,7 @@ def create( empresa, cargo, modalidad, - link.strip() if link and link.strip() else None, + self.normalize_link(link), descripcion, ), ) @@ -145,6 +155,37 @@ def get_by_id(self, vacante_id: int) -> Optional[dict]: finally: self._close(cursor, conn) + def get_by_link(self, link: Optional[str]) -> Optional[dict]: + normalized_link = self.normalize_link(link) + if not normalized_link: + return None + + conn = None + cursor = None + try: + conn = get_connection() + cursor = conn.cursor() + cursor.execute( + """ + SELECT TOP 1 id, empresa, cargo, modalidad, link, descripcion, fecha_registro, motivo_archivo + FROM vacantes + WHERE link = ? OR link = ? OR link = ? + ORDER BY fecha_registro DESC + """, + ( + normalized_link, + f"{normalized_link}/", + normalized_link.split("?", 1)[0], + ), + ) + row = cursor.fetchone() + return self._row_to_vacancy(row) if row else None + except Exception: + logger.exception("Error obteniendo vacante por link %s", normalized_link) + return None + finally: + self._close(cursor, conn) + def delete(self, vacante_id: int) -> dict: conn = None cursor = None diff --git a/chrome-extension/background.js b/chrome-extension/background.js index a57590c..1a37511 100644 --- a/chrome-extension/background.js +++ b/chrome-extension/background.js @@ -1,5 +1,9 @@ const API_BASE = "http://localhost:8001"; -const ACTIVE_JOB_KEY = "activeAnalysisJob"; +const ACTIVE_TASK_KEY = "activeAnalysisTasks"; + +function normalizarUrlVacante(url) { + return (url || "").split("?")[0]; +} if (chrome.sidePanel?.setPanelBehavior) { chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }).catch(() => {}); @@ -13,8 +17,17 @@ chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => { return true; } - if (request.action === "clearActiveJob") { - chrome.storage.local.remove(ACTIVE_JOB_KEY, () => sendResponse({ success: true })); + if (request.action === "clearActiveTask") { + const pageUrl = normalizarUrlVacante(request.pageUrl); + chrome.storage.local.get(ACTIVE_TASK_KEY, (result) => { + const tasks = result[ACTIVE_TASK_KEY] || {}; + if (pageUrl) { + delete tasks[pageUrl]; + chrome.storage.local.set({ [ACTIVE_TASK_KEY]: tasks }, () => sendResponse({ success: true })); + return; + } + chrome.storage.local.remove(ACTIVE_TASK_KEY, () => sendResponse({ success: true })); + }); return true; } @@ -37,15 +50,19 @@ async function guardarVacanteEnSegundoPlano(payload) { throw new Error(data.detail || data.message || "No se pudo guardar la vacante"); } - const activeJob = { - jobId: data.job_id, + const activeTask = { + taskId: data.task_id || data.job_id, vacancyId: data.id, status: data.status, message: data.message, + pageUrl: normalizarUrlVacante(payload.link), savedAt: new Date().toISOString(), }; - await chrome.storage.local.set({ [ACTIVE_JOB_KEY]: activeJob }); - return activeJob; + const storage = await chrome.storage.local.get(ACTIVE_TASK_KEY); + const tasks = storage[ACTIVE_TASK_KEY] || {}; + tasks[activeTask.pageUrl] = activeTask; + await chrome.storage.local.set({ [ACTIVE_TASK_KEY]: tasks }); + return activeTask; } async function guardarVacanteConApiLegacy(payload) { @@ -61,7 +78,7 @@ async function guardarVacanteConApiLegacy(payload) { } return { - jobId: null, + taskId: null, vacancyId: data.id, status: "completed", message: "Vacante guardada. La API local no soporta analisis en segundo plano todavia.", diff --git a/chrome-extension/manifest.json b/chrome-extension/manifest.json index ddcb10f..a0fad51 100644 --- a/chrome-extension/manifest.json +++ b/chrome-extension/manifest.json @@ -20,7 +20,7 @@ }, "side_panel": { - "default_path": "popup.html" + "default_path": "sidepanel.html" }, "background": { diff --git a/chrome-extension/popup.html b/chrome-extension/sidepanel.html similarity index 99% rename from chrome-extension/popup.html rename to chrome-extension/sidepanel.html index 69c0b9e..657c8a7 100644 --- a/chrome-extension/popup.html +++ b/chrome-extension/sidepanel.html @@ -317,6 +317,6 @@

CVs-Optimizador

- + diff --git a/chrome-extension/popup.js b/chrome-extension/sidepanel.js similarity index 73% rename from chrome-extension/popup.js rename to chrome-extension/sidepanel.js index f2c27e5..5929bff 100644 --- a/chrome-extension/popup.js +++ b/chrome-extension/sidepanel.js @@ -1,6 +1,6 @@ /** - * popup.js - * Orquesta toda la lógica del popup: + * sidepanel.js + * Orquesta toda la lógica del panel lateral: * 1. Verifica que la API local esté corriendo * 2. Inyecta content.js y solicita extracción del DOM * 3. Renderiza el formulario con los datos extraídos (editables) @@ -8,9 +8,12 @@ */ const API_BASE = "http://localhost:8001"; -const ACTIVE_JOB_KEY = "activeAnalysisJob"; -const LAST_ANALYSIS_KEY = "lastAnalysisResult"; +const ACTIVE_TASK_KEY = "activeAnalysisTasks"; +const LAST_ANALYSIS_KEY = "lastAnalysisResults"; let pollingTimer = null; +let currentTabId = null; +let currentTabUrl = null; +let watchersInitialized = false; const MODALIDADES = ["Remoto", "Presencial", "Híbrido"]; @@ -39,6 +42,12 @@ function mostrarToast(tipo, mensaje) { $("mainContent").appendChild(toast); } +function limpiarVistaTransitoria() { + document.querySelectorAll(".toast").forEach((t) => t.remove()); + const analysisPanel = document.getElementById("analysisPanel"); + if (analysisPanel) analysisPanel.remove(); +} + function setGuardarEstado(texto, disabled = true) { const btn = $("btnGuardar"); if (!btn) return; @@ -71,21 +80,49 @@ async function storageSet(values) { await chrome.storage.local.set(values); } -function describirJob(job) { - if (job.status === "queued") { +async function guardarDatoPorUrl(storageKey, pageUrl, value) { + const normalizedUrl = normalizarUrlVacante(pageUrl); + if (!normalizedUrl) return; + const current = (await storageGet(storageKey)) || {}; + current[normalizedUrl] = value; + await storageSet({ [storageKey]: current }); +} + +async function leerDatoPorUrl(storageKey, pageUrl) { + const normalizedUrl = normalizarUrlVacante(pageUrl); + if (!normalizedUrl) return null; + const current = (await storageGet(storageKey)) || {}; + return current[normalizedUrl] || null; +} + +async function borrarDatoPorUrl(storageKey, pageUrl) { + const normalizedUrl = normalizarUrlVacante(pageUrl); + if (!normalizedUrl) return; + const current = (await storageGet(storageKey)) || {}; + if (!(normalizedUrl in current)) return; + delete current[normalizedUrl]; + await storageSet({ [storageKey]: current }); +} + +function normalizarUrlVacante(url) { + return (url || "").split("?")[0]; +} + +function describirTarea(task) { + if (task.status === "queued") { return { status: "checking", text: "Vacante guardada · analisis en cola" }; } - if (job.status === "running") { + if (task.status === "running") { return { status: "checking", text: "Analizando vacante en segundo plano..." }; } - if (job.status === "completed") { + if (task.status === "completed") { return { status: "ok", text: "Vacante guardada y analizada" }; } return { status: "error", text: "Vacante guardada, pero el analisis fallo" }; } -async function consultarEstadoJob(jobId) { - const res = await fetch(`${API_BASE}/vacantes/jobs/${jobId}`, { signal: AbortSignal.timeout(3000) }); +async function consultarEstadoTarea(taskId) { + const res = await fetch(`${API_BASE}/vacantes/tasks/${taskId}`, { signal: AbortSignal.timeout(3000) }); const data = await res.json(); if (!res.ok) { throw new Error(data.detail || "No se pudo consultar el estado del analisis"); @@ -102,6 +139,17 @@ async function consultarAnalisisVacante(vacancyId) { return data.analysis; } +async function consultarVacantePorLink(link) { + const url = new URL(`${API_BASE}/vacantes/by-link`); + url.searchParams.set("link", normalizarUrlVacante(link)); + const res = await fetch(url, { signal: AbortSignal.timeout(3000) }); + const data = await res.json(); + if (!res.ok) { + throw new Error(data.detail || "No se pudo consultar la vacante"); + } + return data; +} + function renderAnalisis(analysis) { const resumenPrevio = document.getElementById("analysisPanel"); if (resumenPrevio) resumenPrevio.remove(); @@ -137,42 +185,46 @@ function renderAnalisis(analysis) { $("mainContent").appendChild(root); } -async function iniciarSeguimientoJob(jobId) { +async function iniciarSeguimientoTarea(taskId) { if (pollingTimer) clearInterval(pollingTimer); const actualizar = async () => { try { - const job = await consultarEstadoJob(jobId); - const status = describirJob(job); + const task = await consultarEstadoTarea(taskId); + const status = describirTarea(task); setStatus(status.status, status.text); - if (job.status === "queued" || job.status === "running") { - setGuardarEstado(job.status === "queued" ? "⏳ Analisis en cola..." : "🤖 Analizando...", true); + if (task.status === "queued" || task.status === "running") { + setGuardarEstado(task.status === "queued" ? "⏳ Analisis en cola..." : "🤖 Analizando...", true); return; } - if (job.status === "completed") { + if (task.status === "completed") { try { - const analysis = await consultarAnalisisVacante(job.vacancy_id); - await storageSet({ [LAST_ANALYSIS_KEY]: { vacancyId: job.vacancy_id, analysis } }); + const analysis = await consultarAnalisisVacante(task.vacancy_id); + await guardarDatoPorUrl(LAST_ANALYSIS_KEY, currentTabUrl, { + vacancyId: task.vacancy_id, + pageUrl: currentTabUrl, + analysis, + }); renderAnalisis(analysis); } catch (_) { // Si el analisis todavia no esta visible por API, mantenemos solo el estado. } - mostrarToast("success", `✅ ${job.message}`); + mostrarToast("success", `✅ ${task.message}`); setGuardarEstado("✅ Guardada y analizada", true); } else { - const detalle = job.error ? `${job.message} ${job.error}` : job.message; + const detalle = task.error ? `${task.message} ${task.error}` : task.message; mostrarToast("error", `❌ ${detalle}`); setGuardarEstado("💾 Guardar en CVs-Optimizador", false); } clearInterval(pollingTimer); pollingTimer = null; - await storageRemove(ACTIVE_JOB_KEY); - await runtimeSendMessage({ action: "clearActiveJob" }); + await borrarDatoPorUrl(ACTIVE_TASK_KEY, currentTabUrl); + await runtimeSendMessage({ action: "clearActiveTask", pageUrl: currentTabUrl }); } catch (_) { - setStatus("checking", "Analisis en segundo plano. Reabre el popup para refrescar."); + setStatus("checking", "Analisis en segundo plano. Reabre el panel para refrescar."); } }; @@ -180,19 +232,41 @@ async function iniciarSeguimientoJob(jobId) { pollingTimer = setInterval(actualizar, 2000); } -async function restaurarJobActivo() { - const activeJob = await storageGet(ACTIVE_JOB_KEY); - if (!activeJob?.jobId) return; +async function restaurarTareaActiva() { + const activeTask = await leerDatoPorUrl(ACTIVE_TASK_KEY, currentTabUrl); + if (!activeTask?.taskId) return; mostrarToast("info", "ℹ️ Hay una vacante procesandose en segundo plano."); - await iniciarSeguimientoJob(activeJob.jobId); + await iniciarSeguimientoTarea(activeTask.taskId); } async function restaurarUltimoAnalisis() { - const lastAnalysis = await storageGet(LAST_ANALYSIS_KEY); + const lastAnalysis = await leerDatoPorUrl(LAST_ANALYSIS_KEY, currentTabUrl); if (!lastAnalysis?.analysis) return; renderAnalisis(lastAnalysis.analysis); } +async function cargarAnalisisHistorico(link) { + if (!link) return false; + + try { + const result = await consultarVacantePorLink(link); + if (!result?.found || !result?.analysis || !result?.vacancy?.id) { + return false; + } + + await guardarDatoPorUrl(LAST_ANALYSIS_KEY, link, { + vacancyId: result.vacancy.id, + pageUrl: normalizarUrlVacante(link), + analysis: result.analysis, + }); + renderAnalisis(result.analysis); + setStatus("ok", "Vacante ya analizada"); + return true; + } catch (_) { + return false; + } +} + // ───────────────────────────────────────────────────────────── // 1. VERIFICAR API // ───────────────────────────────────────────────────────────── @@ -381,11 +455,15 @@ async function guardarVacante() { }); if (response?.success) { - if (response.legacyMode || !response.jobId) { + if (response.legacyMode || !response.taskId) { setStatus("ok", "Vacante guardada"); try { const analysis = await consultarAnalisisVacante(response.vacancyId); - await storageSet({ [LAST_ANALYSIS_KEY]: { vacancyId: response.vacancyId, analysis } }); + await guardarDatoPorUrl(LAST_ANALYSIS_KEY, payload.link, { + vacancyId: response.vacancyId, + pageUrl: normalizarUrlVacante(payload.link), + analysis, + }); renderAnalisis(analysis); mostrarToast("success", `✅ Vacante guardada (ID: ${response.vacancyId}) y analisis cargado.`); } catch (_) { @@ -396,7 +474,7 @@ async function guardarVacante() { } mostrarToast("info", `ℹ️ Vacante guardada (ID: ${response.vacancyId}). Analisis en segundo plano iniciado.`); - await iniciarSeguimientoJob(response.jobId); + await iniciarSeguimientoTarea(response.taskId); } else { const detalle = response?.error || "Error desconocido"; mostrarToast("error", `❌ ${detalle}`); @@ -410,27 +488,24 @@ async function guardarVacante() { } } -// ───────────────────────────────────────────────────────────── -// INIT -// ───────────────────────────────────────────────────────────── - -async function init() { - // 1. Verificar API +async function refrescarTabActiva() { const apiOk = await verificarAPI(); - - // 2. Obtener tab actual const tab = await obtenerTab(); - await restaurarJobActivo(); + currentTabId = tab?.id ?? null; + currentTabUrl = normalizarUrlVacante(tab?.url); + + limpiarVistaTransitoria(); + if (pollingTimer) { + clearInterval(pollingTimer); + pollingTimer = null; + } - // 3. Verificar que sea una página de vacante de LinkedIn - if (!esLinkedInJobView(tab.url)) { - renderPaginaIncorrecta(tab.url); + if (!esLinkedInJobView(tab?.url)) { + renderPaginaIncorrecta(tab?.url); return; } - // 4. Extraer datos del DOM $("mainContent").innerHTML = `
`; - const resultado = await extraerDatosDeTab(tab.id); if (!resultado.success) { @@ -438,11 +513,11 @@ async function init() { return; } - // 5. Renderizar formulario (con datos extraídos editables) renderFormulario(resultado.datos); + await restaurarTareaActiva(); await restaurarUltimoAnalisis(); + await cargarAnalisisHistorico(resultado.datos.link); - // 6. Si la API no está disponible, deshabilitar el botón de guardar if (!apiOk) { const btn = $("btnGuardar"); if (btn) { @@ -452,4 +527,31 @@ async function init() { } } +function inicializarObservadoresTabs() { + if (watchersInitialized) return; + watchersInitialized = true; + + chrome.tabs.onActivated.addListener(() => { + refrescarTabActiva().catch(() => {}); + }); + + chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { + const targetUrl = normalizarUrlVacante(tab?.url); + const matchesCurrent = tabId === currentTabId || (tab?.active && targetUrl === currentTabUrl); + if (!matchesCurrent) return; + if (changeInfo.status === "complete") { + refrescarTabActiva().catch(() => {}); + } + }); +} + +// ───────────────────────────────────────────────────────────── +// INIT +// ───────────────────────────────────────────────────────────── + +async function init() { + inicializarObservadoresTabs(); + await refrescarTabActiva(); +} + document.addEventListener("DOMContentLoaded", init); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9d1a1fd..fccf73e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -4,6 +4,8 @@ El proyecto evoluciono de un MVP centrado en scripts a un monolito ordenado con capas claras y con interfaz principal web. +Tambien expone una API local para la extension de Chrome, usada para capturar vacantes desde LinkedIn y consultar analisis ya existentes. + ## Capas ### Domain @@ -64,6 +66,7 @@ Responsabilidades: - recoger input - llamar casos de uso o repositorios - renderizar output +- exponer endpoints para la extension de Chrome y el panel lateral ## Estado actual del refactor @@ -73,6 +76,9 @@ Completado: - repositorios nativos sobre `pyodbc` - casos de uso principales conectados - interfaz web principal sobre FastAPI + Jinja2 + HTMX +- extension de Chrome migrada a `side panel` +- guardado asincrono de vacantes con seguimiento de tasks +- recuperacion de analisis historico por `link` para vacantes ya registradas - `Mi Perfil` migrado a web, incluida `Vista CV` - tests unitarios y de API - tests de rutas web principales @@ -100,6 +106,32 @@ Tecnologia usada: - HTMX - CSS propio +### Extension de Chrome + +La extension vive en `chrome-extension/` y usa `manifest v3` con `service worker` y `side panel`. + +Responsabilidades: + +- extraer datos de la vacante desde LinkedIn +- llamar la API local para guardar y analizar +- mostrar progreso de guardado y analisis +- recuperar un analisis historico si la vacante ya existe en BD + +Endpoints clave: + +- `GET /health` +- `POST /vacantes` +- `POST /vacantes/async` +- `GET /vacantes/tasks/{task_id}` +- `GET /vacantes/{vacancy_id}/analysis` +- `GET /vacantes/by-link?link=...` + +Decisiones relevantes: + +- el panel lateral persiste mejor que un popup clasico y no depende del foco +- el `service worker` permite disparar guardado y analisis aunque la UI no permanezca abierta +- la vacante se relaciona por `link` normalizado para recuperar analisis historicos sin depender de storage local + ### Navegacion principal web La interfaz web sigue el flujo real de trabajo: @@ -147,8 +179,10 @@ Vacantes: 1. la web o la extension registran la vacante 2. `CreateVacancyUseCase` valida y delega al repositorio -3. `AnalyzeVacancyUseCase` obtiene vacante, perfil y guarda analisis -4. la vacante se revisa en `Inbox` +3. si entra por extension, puede lanzarse `POST /vacantes/async` y seguirse por task +4. `AnalyzeVacancyUseCase` obtiene vacante, perfil y guarda analisis +5. la extension puede volver a cargar ese analisis por `link` +6. la vacante se revisa en `Inbox` Aplicaciones: diff --git a/tests/test_api.py b/tests/test_api.py index 1fd7eae..8ed2113 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -67,9 +67,9 @@ def test_create_vacancy_returns_500_when_repository_result_fails(self, mock_crea self.assertEqual(response.status_code, 500) self.assertEqual(response.json()["detail"], "db error") - @patch("api._run_analysis_job") + @patch("api._run_analysis_task") @patch("api.create_vacancy_use_case") - def test_create_vacancy_async_returns_job_metadata(self, mock_create_use_case, _mock_run_analysis_job): + def test_create_vacancy_async_returns_task_metadata(self, mock_create_use_case, _mock_run_analysis_task): mock_create_use_case.execute.return_value = { "success": True, "message": "saved", @@ -92,11 +92,11 @@ def test_create_vacancy_async_returns_job_metadata(self, mock_create_use_case, _ self.assertTrue(body["success"]) self.assertEqual(body["id"], 15) self.assertEqual(body["status"], "queued") - self.assertTrue(body["job_id"]) + self.assertTrue(body["task_id"]) - def test_get_analysis_job_returns_status_when_job_exists(self): - api.analysis_jobs["job-test"] = { - "job_id": "job-test", + def test_get_analysis_task_returns_status_when_task_exists(self): + api.analysis_tasks["task-test"] = { + "task_id": "task-test", "vacancy_id": 21, "status": "running", "step": "analyzing", @@ -106,12 +106,30 @@ def test_get_analysis_job_returns_status_when_job_exists(self): "updated_at": "2026-04-06T10:01:00Z", } - response = self.client.get("/vacantes/jobs/job-test") + response = self.client.get("/vacantes/tasks/task-test") self.assertEqual(response.status_code, 200) self.assertEqual(response.json()["status"], "running") self.assertEqual(response.json()["vacancy_id"], 21) + def test_get_analysis_job_legacy_alias_returns_status_when_task_exists(self): + api.analysis_tasks["task-legacy"] = { + "task_id": "task-legacy", + "vacancy_id": 34, + "status": "queued", + "step": "saved", + "message": "En cola", + "error": None, + "created_at": "2026-04-06T10:00:00Z", + "updated_at": "2026-04-06T10:01:00Z", + } + + response = self.client.get("/vacantes/jobs/task-legacy") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["task_id"], "task-legacy") + self.assertEqual(response.json()["status"], "queued") + @patch("api.analysis_repository") def test_get_vacancy_analysis_returns_saved_analysis(self, mock_analysis_repository): mock_analysis_repository.get_by_vacancy_id.return_value = { @@ -131,6 +149,53 @@ def test_get_vacancy_analysis_returns_saved_analysis(self, mock_analysis_reposit self.assertEqual(response.json()["vacancy_id"], 15) self.assertEqual(response.json()["analysis"]["score_total"], 88) + @patch("api.analysis_repository") + @patch("api.vacancy_repository") + def test_get_vacancy_by_link_returns_existing_vacancy_and_analysis( + self, + mock_vacancy_repository, + mock_analysis_repository, + ): + mock_vacancy_repository.get_by_link.return_value = { + "id": 22, + "empresa": "ACME", + "cargo": "Data Analyst", + "modalidad": "Remoto", + "link": "https://www.linkedin.com/jobs/view/4384356134", + "descripcion": "Desc", + "fecha_registro": "2026-04-06T10:00:00", + "motivo_archivo": None, + } + mock_analysis_repository.get_by_vacancy_id.return_value = { + "score_total": 91, + "afinidad_general": "Alta", + "decision_aplicacion": "Aplicar", + } + + response = self.client.get( + "/vacantes/by-link", + params={"link": "https://www.linkedin.com/jobs/view/4384356134?tracking=abc"}, + ) + + self.assertEqual(response.status_code, 200) + self.assertTrue(response.json()["found"]) + self.assertEqual(response.json()["vacancy"]["id"], 22) + self.assertEqual(response.json()["analysis"]["score_total"], 91) + + @patch("api.vacancy_repository") + def test_get_vacancy_by_link_returns_found_false_when_missing(self, mock_vacancy_repository): + mock_vacancy_repository.get_by_link.return_value = None + + response = self.client.get( + "/vacantes/by-link", + params={"link": "https://www.linkedin.com/jobs/view/4384356134"}, + ) + + self.assertEqual(response.status_code, 200) + self.assertFalse(response.json()["found"]) + self.assertIsNone(response.json()["vacancy"]) + self.assertIsNone(response.json()["analysis"]) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_vacancy_repository.py b/tests/test_vacancy_repository.py index 6a20732..18125de 100644 --- a/tests/test_vacancy_repository.py +++ b/tests/test_vacancy_repository.py @@ -32,6 +32,13 @@ def test_row_to_vacancy_maps_expected_fields(self): self.assertEqual(result["empresa"], "ACME") self.assertEqual(result["motivo_archivo"], "Descartada") + def test_normalize_link_removes_query_and_trailing_slash(self): + repository = VacancyRepository() + + result = repository.normalize_link(" https://www.linkedin.com/jobs/view/4384356134/?refId=abc ") + + self.assertEqual(result, "https://www.linkedin.com/jobs/view/4384356134") + @patch("app.infrastructure.persistence.repositories.vacancy_repository.get_connection") def test_create_commits_when_insert_succeeds(self, get_connection_mock): conn = Mock() @@ -53,6 +60,30 @@ def test_create_commits_when_insert_succeeds(self, get_connection_mock): self.assertEqual(result["id"], 5) conn.commit.assert_called_once() + @patch("app.infrastructure.persistence.repositories.vacancy_repository.get_connection") + def test_get_by_link_returns_latest_match(self, get_connection_mock): + conn = Mock() + cursor = Mock() + conn.cursor.return_value = cursor + cursor.fetchone.return_value = ( + 9, + "ACME", + "Analyst", + "Remoto", + "https://www.linkedin.com/jobs/view/4384356134/", + "Desc", + "2026-04-06", + None, + ) + get_connection_mock.return_value = conn + repository = VacancyRepository() + + result = repository.get_by_link("https://www.linkedin.com/jobs/view/4384356134/?tracking=abc") + + self.assertIsNotNone(result) + self.assertEqual(result["id"], 9) + cursor.execute.assert_called_once() + if __name__ == "__main__": unittest.main() From 4e1799ecab475fb4dc634325b8539a58a8ecba88 Mon Sep 17 00:00:00 2001 From: JoseMelNet Date: Sat, 23 May 2026 09:54:55 -0500 Subject: [PATCH 2/7] Checkpoint current inbox UI state before stabilization --- app/interfaces/web/routes/vacancies.py | 183 ++++- app/interfaces/web/static/css/app.css | 690 +++++++++++++++++- app/interfaces/web/templates.py | 12 + app/interfaces/web/templates/base.html | 33 +- .../web/templates/vacancies/_detail.html | 155 ++-- .../web/templates/vacancies/_list.html | 77 +- .../web/templates/vacancies/index.html | 84 ++- tests/test_web_vacancies.py | 76 +- 8 files changed, 1156 insertions(+), 154 deletions(-) diff --git a/app/interfaces/web/routes/vacancies.py b/app/interfaces/web/routes/vacancies.py index 510ef88..fe1151c 100644 --- a/app/interfaces/web/routes/vacancies.py +++ b/app/interfaces/web/routes/vacancies.py @@ -39,9 +39,79 @@ STATUS_META = { "En seguimiento": {"tone": "blue", "label": "En seguimiento"}, "Analizada": {"tone": "green", "label": "Analizada"}, - "Registrada": {"tone": "gray", "label": "Registrada"}, + "Sin analizar": {"tone": "gray", "label": "Sin analizar"}, } INBOX_VIEWS = ["Todas", "Recientes", "Analizadas", "En seguimiento", "Sin analizar"] +ANALYSIS_TONE_DEFAULT = {"tone": "gray", "label": "Sin analisis"} + + +def _safe_score(value) -> float | None: + try: + return float(value) if value is not None else None + except (TypeError, ValueError): + return None + + +def _score_meta(analysis: dict | None) -> dict: + if not analysis: + return {**ANALYSIS_TONE_DEFAULT, "value": None} + + score = _safe_score(analysis.get("score_total")) + if score is None: + return {**ANALYSIS_TONE_DEFAULT, "value": None} + if score >= 80: + tone = "green" + elif score >= 60: + tone = "amber" + else: + tone = "red" + return {"tone": tone, "label": "Score", "value": round(score)} + + +def _keyword_tone(value: str | None, mapping: dict[str, str], default_label: str) -> dict: + if not value: + return {"tone": "gray", "label": "-", "raw": None} + + normalized = value.strip() + lowered = normalized.lower() + for keyword, tone in mapping.items(): + if keyword in lowered: + return {"tone": tone, "label": normalized, "raw": normalized} + return {"tone": "gray", "label": normalized or default_label, "raw": normalized} + + +def _affinity_meta(analysis: dict | None) -> dict: + if not analysis: + return {"tone": "gray", "label": "-", "raw": None} + return _keyword_tone( + analysis.get("afinidad_general"), + { + "alta": "green", + "media": "amber", + "baja": "red", + }, + "-", + ) + + +def _decision_meta(analysis: dict | None) -> dict: + if not analysis: + return {"tone": "gray", "label": "-", "raw": None} + + decision = analysis.get("decision_aplicacion") + if not decision: + return {"tone": "gray", "label": "-", "raw": None} + + lowered = decision.strip().lower() + if "no aplicar" in lowered or "descartar" in lowered or "rechazar" in lowered: + tone = "red" + elif "revis" in lowered or "evalu" in lowered or "consider" in lowered: + tone = "amber" + elif "aplicar" in lowered or "prior" in lowered or "avanz" in lowered: + tone = "green" + else: + tone = "gray" + return {"tone": tone, "label": decision.strip(), "raw": decision.strip()} def _application_ids_with_tracking() -> set[int]: @@ -50,6 +120,7 @@ def _application_ids_with_tracking() -> set[int]: def _build_vacancy_items(limit: int | None = None) -> list[dict]: vacancies = vacancy_repository.list_all() + vacancies = [item for item in vacancies if not item.get("motivo_archivo")] vacancies = sorted(vacancies, key=lambda item: item.get("fecha_registro") or "", reverse=True) visible_vacancies = vacancies[:limit] if limit else vacancies vacancy_ids = [item["id"] for item in visible_vacancies] @@ -59,14 +130,20 @@ def _build_vacancy_items(limit: int | None = None) -> list[dict]: for vacancy in visible_vacancies: analysis = analyses_by_vacancy.get(vacancy["id"]) has_application = vacancy["id"] in tracked_vacancy_ids - status_label = "En seguimiento" if has_application else ("Analizada" if analysis else "Registrada") + status_label = "En seguimiento" if has_application else ("Analizada" if analysis else "Sin analizar") + score_meta = _score_meta(analysis) + affinity_meta = _affinity_meta(analysis) + decision_meta = _decision_meta(analysis) items.append( { **vacancy, "analisis": analysis, "status_label": status_label, "status_meta": STATUS_META[status_label], - "score_label": f"{analysis.get('score_total', 0):.0f}" if analysis else "Sin score", + "score_label": f"{score_meta['value']:.0f}" if score_meta["value"] is not None else "Sin analisis", + "score_meta": score_meta, + "affinity_meta": affinity_meta, + "decision_meta": decision_meta, "has_application": has_application, } ) @@ -134,6 +211,41 @@ def _selected_vacancy(selected_id: int | None) -> dict | None: return next((item for item in items if item["id"] == selected_id), items[0]) +def _next_visible_vacancy_id(current_id: int, *, q: str | None, view: str) -> int | None: + items = _filter_vacancy_items(_build_vacancy_items(limit=None), q=q, view=view) + if not items: + return None + for item in items: + if item["id"] != current_id: + return item["id"] + return None + + +def _build_inbox_url( + *, + selected: int | None = None, + flash: str | None = None, + q: str | None = None, + view: str = "Todas", + page: int = 1, + page_size: int = DEFAULT_PAGE_SIZE, +) -> str: + params: list[str] = [] + if selected is not None: + params.append(f"selected={selected}") + if flash: + params.append(f"flash={flash}") + if q: + params.append(f"q={q}") + if view and view != "Todas": + params.append(f"view={view}") + if page != 1: + params.append(f"page={page}") + if page_size != DEFAULT_PAGE_SIZE: + params.append(f"page_size={page_size}") + return "/app/vacancies" + (f"?{'&'.join(params)}" if params else "") + + def _flash_message(flash: str | None) -> tuple[str, str] | None: if flash == "vacancy_created": return ("success", "Vacante registrada y analizada. Revisa el resultado en Inbox.") @@ -143,6 +255,10 @@ def _flash_message(flash: str | None) -> tuple[str, str] | None: return ("warning", "Vacante registrada, pero el analisis no pudo completarse.") if flash == "interest_error": return ("warning", "No se pudo enviar la vacante a Seguimiento.") + if flash == "vacancy_discarded": + return ("info", "Vacante descartada y removida del Inbox.") + if flash == "vacancy_discard_error": + return ("warning", "No se pudo descartar la vacante.") return None @@ -160,7 +276,7 @@ def _build_inbox_context( filtered_items = _filter_vacancy_items(all_items, q=q, view=normalized_view) resolved_page = _resolve_page_for_selected(filtered_items, selected, normalized_page_size, page) items, pagination = _paginate_items(filtered_items, resolved_page, normalized_page_size) - selected_vacancy = next((item for item in items if item["id"] == selected), None) if selected else (items[0] if items else None) + selected_vacancy = next((item for item in items if item["id"] == selected), None) if selected else None return { "vacancies": items, "selected_vacancy": selected_vacancy, @@ -188,6 +304,8 @@ def vacancies_index( page_size: int = DEFAULT_PAGE_SIZE, ): context = _build_inbox_context(selected=selected, flash=flash, q=q, view=view, page=page, page_size=page_size) + metrics = _build_metrics() + metric_lookup = {item["label"]: item["value"] for item in metrics} return templates.TemplateResponse( request=request, name="vacancies/index.html", @@ -195,7 +313,15 @@ def vacancies_index( "page_title": "Inbox de Vacantes", "active_nav": "vacancies", "nav_items": _build_nav("vacancies"), - "metrics": _build_metrics(), + "metrics": metrics, + "hide_global_metrics": True, + "context_bar": [ + {"label": "Vacantes visibles", "value": context["summary"]["total"]}, + {"label": "Analizadas", "value": context["summary"]["analizadas"]}, + {"label": "En seguimiento", "value": context["summary"]["seguimiento"]}, + {"label": "Aplicaciones", "value": metric_lookup.get("Aplicaciones", 0)}, + {"label": "Rechazadas", "value": metric_lookup.get("Rechazadas", 0)}, + ], **context, }, ) @@ -264,7 +390,16 @@ def vacancy_detail_partial(request: Request, vacancy_id: int): return templates.TemplateResponse( request=request, name="vacancies/_detail.html", - context={"request": request, "selected_vacancy": vacancy}, + context={ + "request": request, + "selected_vacancy": vacancy, + "query": request.query_params.get("q", ""), + "current_view": request.query_params.get("view", "Todas"), + "pagination": { + "page": int(request.query_params.get("page", "1")), + "page_size": int(request.query_params.get("page_size", str(DEFAULT_PAGE_SIZE))), + }, + }, ) @@ -311,3 +446,39 @@ def mark_vacancy_as_interesting(vacancy_id: int): ) return RedirectResponse(url="/app/vacancies?flash=interest_error", status_code=303) + + +@router.post("/app/vacancies/{vacancy_id}/discard") +def discard_vacancy( + vacancy_id: int, + q: str | None = Form(default=None), + view: str = Form(default="Todas"), + page: int = Form(default=1), + page_size: int = Form(default=DEFAULT_PAGE_SIZE), +): + result = vacancy_repository.archive(vacancy_id, "Otro") + if not result["success"]: + return RedirectResponse( + url=_build_inbox_url( + selected=vacancy_id, + flash="vacancy_discard_error", + q=q, + view=view, + page=page, + page_size=page_size, + ), + status_code=303, + ) + + next_selected = _next_visible_vacancy_id(vacancy_id, q=q, view=view) + return RedirectResponse( + url=_build_inbox_url( + selected=next_selected, + flash="vacancy_discarded", + q=q, + view=view, + page=page, + page_size=page_size, + ), + status_code=303, + ) diff --git a/app/interfaces/web/static/css/app.css b/app/interfaces/web/static/css/app.css index 2819183..4e637a8 100644 --- a/app/interfaces/web/static/css/app.css +++ b/app/interfaces/web/static/css/app.css @@ -37,9 +37,10 @@ a { .shell-header { display: flex; justify-content: space-between; - align-items: end; + align-items: center; gap: 24px; - padding: 28px 0 16px; + padding: 18px 0 14px; + border-bottom: 1px solid var(--border); } .shell-brand { @@ -57,13 +58,21 @@ a { } .shell-title { - font-size: 1.5rem; + font-size: 1.25rem; font-weight: 700; } .shell-subtitle { color: var(--muted); margin-top: 4px; + font-size: 0.88rem; +} + +.shell-nav-group { + display: flex; + align-items: center; + gap: 14px; + flex-wrap: wrap; } .shell-nav { @@ -96,6 +105,25 @@ a { font-weight: 700; } +.shell-header-action { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 40px; + padding: 0 16px; + border-radius: 12px; + background: var(--brand); + color: #fff; + text-decoration: none; + font-weight: 700; + box-shadow: 0 8px 16px rgba(29, 78, 216, 0.16); +} + +.shell-header-action.is-active, +.shell-header-action:hover { + background: var(--brand-strong); +} + .kpi-strip { display: grid; grid-template-columns: repeat(5, 1fr); @@ -128,7 +156,7 @@ a { } .page-shell { - padding-bottom: 32px; + padding: 20px 0 32px; } .layout-two-columns { @@ -144,14 +172,18 @@ a { .page-header { display: flex; justify-content: space-between; - align-items: end; + align-items: start; gap: 16px; - margin-bottom: 14px; + margin-bottom: 10px; +} + +.page-header > * { + min-width: 0; } .page-header h1 { margin: 0; - font-size: 1.55rem; + font-size: 1.28rem; } .page-header p, @@ -161,6 +193,7 @@ a { .panel { padding: 18px 20px; + overflow: hidden; } .panel.narrow { @@ -168,7 +201,17 @@ a { } .filter-panel { - margin-bottom: 16px; + margin-bottom: 12px; +} + +.page-header-copy { + display: grid; + gap: 4px; +} + +.page-header-copy p { + margin: 0; + font-size: 0.92rem; } .primary-link, @@ -232,6 +275,35 @@ a { font-weight: 700; } +.context-strip { + display: flex; + flex-wrap: wrap; + gap: 10px; + padding: 0 0 14px; + margin-bottom: 10px; + border-bottom: 1px solid var(--border); +} + +.context-item { + display: inline-flex; + align-items: baseline; + gap: 6px; + padding: 8px 12px; + border-radius: 999px; + background: #fff; + border: 1px solid var(--border); +} + +.context-value { + font-size: 1rem; + font-weight: 800; +} + +.context-label { + font-size: 0.82rem; + color: var(--muted); +} + .filter-form { display: grid; grid-template-columns: minmax(0, 2fr) minmax(220px, 1fr) auto; @@ -239,11 +311,28 @@ a { align-items: end; } +.filter-form-compact { + grid-template-columns: minmax(0, 2.2fr) minmax(180px, 0.8fr) minmax(120px, 0.6fr) auto; + gap: 10px; +} + .filter-form label { display: grid; gap: 6px; } +.filter-form label span { + font-size: 0.8rem; + color: var(--muted); +} + +.filter-submit { + min-height: 44px; + border: 1px solid var(--border); + background: #fff; + color: var(--muted); +} + .primary-link:hover, .stack-form button:hover { background: var(--brand-strong); @@ -317,47 +406,73 @@ a { .simple-table { display: grid; gap: 8px; + min-width: 0; + --vacancy-table-columns: 92px minmax(180px, 1.05fr) minmax(220px, 1.35fr) 112px 148px 124px; } .table-head, .table-row { display: grid; - grid-template-columns: 90px 1.2fr 1.3fr 1fr 1fr; - gap: 12px; + grid-template-columns: var(--vacancy-table-columns); + gap: 14px; align-items: center; } +.table-head > div, +.table-row > div, +.detail-card, +.summary-card, +.panel { + min-width: 0; +} + .table-head { font-size: 0.82rem; font-weight: 700; color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; - padding: 0 8px; + padding: 0 12px; +} + +.table-head > div:nth-child(4), +.table-head > div:nth-child(5), +.table-head > div:nth-child(6) { + text-align: center; } .table-row { - padding: 12px 8px; + padding: 12px 92px 12px 12px; + transition: 0.2s ease; +} + +.table-row-shell { + position: relative; + display: block; + padding: 0; border: 1px solid var(--border); - border-radius: 12px; + border-radius: 14px; background: #fbfcfe; transition: 0.2s ease; + overflow: hidden; } .row-link { color: inherit; text-decoration: none; + min-width: 0; } -.row-link:hover { +.table-row-shell:hover { border-color: #bfdbfe; background: #f8fbff; transform: translateY(-1px); } -.row-link.is-selected { +.table-row-shell.is-selected { border-color: #b3c9ff; background: #eef4ff; + box-shadow: inset 4px 0 0 #1d4ed8; } .mono-cell { @@ -365,14 +480,90 @@ a { font-weight: 700; } +.score-cell { + display: flex; + align-items: center; +} + +.company-cell, +.role-cell { + min-width: 0; +} + +.date-cell { + display: flex; + justify-content: center; +} + +.date-chip { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 34px; + padding: 0 10px; + border-radius: 999px; + background: #f8fafc; + border: 1px solid var(--border); + color: var(--muted); + font-size: 0.8rem; + font-weight: 700; + white-space: nowrap; +} + +.row-quick-actions { + display: flex; + align-items: center; + justify-content: flex-end; + position: absolute; + top: 50%; + right: 12px; + transform: translateY(-50%); + opacity: 0; + transition: opacity 0.2s ease; + pointer-events: none; +} + +.table-row-shell:hover .row-quick-actions, +.table-row-shell.is-selected .row-quick-actions { + opacity: 1; + pointer-events: auto; +} + +.row-quick-actions form { + margin: 0; +} + +.quick-action { + min-height: 34px; + padding: 0 12px; + border-radius: 10px; + font-size: 0.78rem; +} + .primary-cell { font-weight: 600; + overflow-wrap: anywhere; +} + +.truncate-cell { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .secondary-cell { margin-top: 3px; color: var(--muted); font-size: 0.84rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.decision-cell, +.status-cell { + display: flex; + justify-content: center; } .detail-grid { @@ -389,10 +580,15 @@ a { background: #fbfcfe; } -#vacancy-detail .panel, -.layout-two-columns > .panel:last-child { +#vacancy-detail { position: sticky; - top: 20px; + top: 18px; + align-self: start; +} + +.detail-sheet { + max-height: calc(100vh - 110px); + overflow-y: auto; } .detail-label { @@ -406,6 +602,7 @@ a { margin-top: 6px; font-size: 1rem; font-weight: 700; + overflow-wrap: anywhere; } .soft-badge, @@ -416,7 +613,75 @@ a { padding: 6px 10px; font-size: 0.82rem; font-weight: 700; - white-space: nowrap; + white-space: normal; + text-align: center; + max-width: 100%; +} + +.analysis-pill { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 42px; + max-width: 100%; + padding: 6px 10px; + border-radius: 999px; + font-size: 0.82rem; + font-weight: 800; + white-space: normal; + text-align: center; + border: 1px solid transparent; +} + +.analysis-pill-score { + min-width: 62px; + font-size: 0.92rem; +} + +.analysis-pill-large { + min-width: 104px; + padding: 10px 14px; + font-size: 1rem; +} + +.table-row .analysis-pill, +.table-row .status-badge { + justify-self: center; +} + +.tone-gray { + background: #f3f4f6; + border-color: #e5e7eb; + color: #4b5563; +} + +.tone-green { + background: #dcfce7; + border-color: #86efac; + color: #166534; +} + +.tone-amber { + background: #fef3c7; + border-color: #fcd34d; + color: #b45309; +} + +.tone-red { + background: #fee2e2; + border-color: #fca5a5; + color: #b91c1c; +} + +.detail-header-badges { + display: flex; + gap: 8px; + flex-wrap: wrap; + align-items: center; +} + +.analysis-detail-grid .detail-card { + background: #ffffff; } .soft-badge { @@ -471,6 +736,11 @@ a { border: 1px solid #dbeafe; } +.analysis-summary-empty { + background: #fbfcfe; + border-color: var(--border); +} + .empty-panel { padding: 20px 12px; text-align: center; @@ -480,6 +750,340 @@ a { display: flex; gap: 10px; flex-wrap: wrap; + min-width: 0; +} + +.description-disclosure { + border: 1px solid var(--border); + border-radius: 14px; + background: #fbfcfe; + padding: 0; + overflow: hidden; +} + +.description-disclosure > summary { + cursor: pointer; + list-style: none; + padding: 14px 16px; + font-weight: 700; + background: #f8fbff; +} + +.description-disclosure > summary::-webkit-details-marker { + display: none; +} + +.description-disclosure[open] > summary { + border-bottom: 1px solid var(--border); +} + +.description-disclosure > p { + margin: 0; + padding: 16px; +} + +.inbox-workspace { + align-items: start; +} + +.workspace-panel { + min-height: 0; +} + +.inbox-list-panel { + padding: 16px 18px 18px; +} + +.inline-detail-row { + margin: 8px 0 16px; + transform-origin: top center; + animation: inline-detail-enter 0.22s ease; +} + +.detail-sheet-inline { + max-height: none; + overflow: visible; + border-radius: 16px; + border-color: #cfe0ff; + background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%); +} + +.detail-sheet-inline .page-header { + margin-bottom: 16px; +} + +@keyframes inline-detail-enter { + from { + opacity: 0; + transform: translateY(-8px) scaleY(0.98); + } + to { + opacity: 1; + transform: translateY(0) scaleY(1); + } +} + +.vacancy-card-inline { + padding: 0; + overflow: hidden; +} + +.vacancy-card-header { + display: flex; + justify-content: space-between; + align-items: start; + gap: 12px; + padding: 18px 22px 16px; +} + +.vacancy-card-copy { + min-width: 0; +} + +.vacancy-card-title { + margin: 0 0 6px; + display: flex; + align-items: center; + gap: 8px; + font-size: 1rem; + font-weight: 600; + line-height: 1.35; +} + +.vacancy-link-icon { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 24px; + padding: 0 8px; + border-radius: 999px; + border: 1px solid var(--border); + color: var(--muted); + text-decoration: none; + font-size: 0.76rem; + font-weight: 700; +} + +.vacancy-link-icon:hover { + border-color: #bfdbfe; + color: var(--brand); + background: #f8fbff; +} + +.vacancy-card-meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px 14px; + font-size: 0.78rem; + color: var(--muted); +} + +.vacancy-card-actions { + display: flex; + gap: 8px; + align-items: center; + flex-shrink: 0; +} + +.vacancy-card-actions form { + margin: 0; +} + +.vacancy-action-button { + min-height: 34px; + padding: 0 12px; + border-radius: 10px; + font-size: 0.78rem; + box-shadow: none; +} + +.vacancy-close-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 10px; + border: 1px solid var(--border); + color: var(--muted); + text-decoration: none; + background: transparent; + font-size: 1rem; + line-height: 1; +} + +.vacancy-close-button:hover { + background: #fee2e2; + border-color: #fecaca; + color: #b91c1c; +} + +.vacancy-card-divider { + height: 1px; + background: var(--border); +} + +.vacancy-score-strip { + display: flex; + align-items: center; + gap: 18px; + padding: 16px 22px; + background: #f8fbff; +} + +.vacancy-score-value { + flex-shrink: 0; +} + +.vacancy-score-label { + margin-bottom: 4px; + font-size: 0.7rem; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.vacancy-score-number { + font-size: 1.9rem; + font-weight: 700; + line-height: 1; +} + +.vacancy-score-number.tone-green, +.vacancy-score-fill.tone-green, +.vacancy-verdict.tone-green { + color: #166534; +} + +.vacancy-score-number.tone-amber, +.vacancy-score-fill.tone-amber, +.vacancy-verdict.tone-amber { + color: #b45309; +} + +.vacancy-score-number.tone-red, +.vacancy-score-fill.tone-red, +.vacancy-verdict.tone-red { + color: #b91c1c; +} + +.vacancy-score-number.tone-gray, +.vacancy-score-fill.tone-gray, +.vacancy-verdict.tone-gray { + color: #4b5563; +} + +.vacancy-score-gauge-block { + flex: 1; + min-width: 0; +} + +.vacancy-score-scale { + display: flex; + justify-content: space-between; + margin-bottom: 6px; + font-size: 0.72rem; + color: var(--muted); +} + +.vacancy-score-gauge { + height: 6px; + background: #dbe3f0; + border-radius: 999px; + overflow: hidden; +} + +.vacancy-score-fill { + height: 100%; + border-radius: 999px; + background: currentColor; +} + +.vacancy-verdict { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 6px 10px; + border-radius: 10px; + font-size: 0.78rem; + font-weight: 700; + background: currentColor; + color: #fff; + white-space: nowrap; +} + +.vacancy-verdict.tone-green { + background: #dcfce7; + color: #166534; +} + +.vacancy-verdict.tone-amber { + background: #fef3c7; + color: #b45309; +} + +.vacancy-verdict.tone-red { + background: #fee2e2; + color: #b91c1c; +} + +.vacancy-verdict.tone-gray { + background: #f3f4f6; + color: #4b5563; +} + +.vacancy-card-body { + padding: 18px 22px; +} + +.vacancy-skills { + display: flex; + gap: 6px; + flex-wrap: wrap; + margin-bottom: 12px; +} + +.vacancy-skill { + display: inline-flex; + align-items: center; + min-height: 24px; + padding: 0 10px; + border-radius: 999px; + border: 1px solid var(--border); + color: var(--muted); + background: #fff; + font-size: 0.74rem; + font-weight: 700; +} + +.vacancy-analysis-text { + margin: 0; + font-size: 0.88rem; + line-height: 1.7; + color: #4b5563; +} + +.vacancy-description { + margin-top: 0; + border: 0; + border-top: 1px solid var(--border); + border-radius: 0; + background: transparent; +} + +.vacancy-description > summary { + padding: 14px 22px; + background: transparent; + font-size: 0.8rem; +} + +.vacancy-description > .vacancy-description-body { + padding: 0 22px 18px; +} + +.vacancy-description > .vacancy-description-body p { + margin: 0; } .pill-group { @@ -865,12 +1469,11 @@ a { } .summary-strip, + .context-strip, .profile-hint-grid, .filter-form, .layout-two-columns, .detail-grid, - .table-head, - .table-row, .profile-grid, .grid-two, .grid-three, @@ -878,11 +1481,52 @@ a { grid-template-columns: 1fr; } - #vacancy-detail .panel, - .layout-two-columns > .panel:last-child { + .table-head, + .table-row { + grid-template-columns: 76px minmax(0, 1fr) minmax(0, 1fr) 92px; + } + + .table-head > div:nth-child(4), + .table-head > div:nth-child(5), + .table-head > div:nth-child(6), + .table-row > div:nth-child(4), + .table-row > div:nth-child(5), + .table-row > div:nth-child(6), + .table-row-shell > .row-quick-actions { + display: none; + } + + #vacancy-detail { position: static; } + .detail-sheet { + max-height: none; + } + + .inbox-list-panel { + padding: 14px; + } + + .table-row { + padding-right: 12px; + } + + .vacancy-card-header, + .vacancy-score-strip { + flex-direction: column; + align-items: start; + } + + .vacancy-card-actions { + width: 100%; + flex-wrap: wrap; + } + + .vacancy-score-gauge-block { + width: 100%; + } + .record-header, .section-title { flex-direction: column; diff --git a/app/interfaces/web/templates.py b/app/interfaces/web/templates.py index 0d25de5..5786641 100644 --- a/app/interfaces/web/templates.py +++ b/app/interfaces/web/templates.py @@ -8,4 +8,16 @@ TEMPLATES_DIR = Path(__file__).resolve().parent / "templates" +STATIC_DIR = TEMPLATES_DIR.parent / "static" templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) + + +def _static_version(path: str) -> int: + target = STATIC_DIR / path + try: + return int(target.stat().st_mtime) + except OSError: + return 0 + + +templates.env.globals["static_version"] = _static_version diff --git a/app/interfaces/web/templates/base.html b/app/interfaces/web/templates/base.html index d96c6a7..6fffdd5 100644 --- a/app/interfaces/web/templates/base.html +++ b/app/interfaces/web/templates/base.html @@ -4,7 +4,7 @@ {{ page_title }} | CVs Optimizator - + @@ -13,21 +13,28 @@
CVs Optimizator
Vacantes, analisis y seguimiento
- + -
- {% for metric in metrics %} -
-
{{ metric.label }}
-
{{ metric.value }}
-
- {% endfor %} -
+ {% if not hide_global_metrics %} +
+ {% for metric in metrics %} +
+
{{ metric.label }}
+
{{ metric.value }}
+
+ {% endfor %} +
+ {% endif %}
{% block content %}{% endblock %} diff --git a/app/interfaces/web/templates/vacancies/_detail.html b/app/interfaces/web/templates/vacancies/_detail.html index 0e52e80..f79e39a 100644 --- a/app/interfaces/web/templates/vacancies/_detail.html +++ b/app/interfaces/web/templates/vacancies/_detail.html @@ -1,87 +1,114 @@ {% if selected_vacancy %} -
-