Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
eb7a418
Backlog and start refactor ui
JoseMelNet May 24, 2026
1b2fcbb
Implement Opportunity Workspace inbox shell
JoseMelNet May 24, 2026
05f0b4e
Refine Opportunity Workspace inbox UI
JoseMelNet May 24, 2026
4fdc645
Refine Opportunity Workspace decision panel
JoseMelNet May 24, 2026
7be361a
Refine Opportunity Workspace vacancy list
JoseMelNet May 24, 2026
dec4e89
Polish Opportunity Workspace inbox behavior
JoseMelNet May 24, 2026
5c0159a
Fix Opportunity Workspace inbox polish
JoseMelNet May 24, 2026
adb834d
Fix Opportunity Workspace inbox closure
JoseMelNet May 24, 2026
69ca22f
Fix vacancy detail description reveal
JoseMelNet May 24, 2026
fd8c9fc
Normalize vacancy decision signals
JoseMelNet May 25, 2026
8272a4e
Show only pending vacancies in default inbox
JoseMelNet May 25, 2026
2acee83
Keep inbox flow after vacancy actions
JoseMelNet May 25, 2026
6a4fa05
Fix applications workspace operational contract
JoseMelNet May 25, 2026
7eb570f
Refine applications follow-up detail panel
JoseMelNet May 25, 2026
1395e46
Refine applications follow-up list
JoseMelNet May 25, 2026
b2399f2
Show original vacancy decision in applications
JoseMelNet May 25, 2026
046d882
Add conservative application follow-up signals
JoseMelNet May 25, 2026
71c8a9b
Fix application rail date copy
JoseMelNet May 25, 2026
50af09f
Fix applications rail selection loading
JoseMelNet May 25, 2026
f88d5ce
Compact applications workspace header
JoseMelNet May 25, 2026
0e2851a
Compact applications workspace filters
JoseMelNet May 25, 2026
510741e
Add sticky applications detail panel
JoseMelNet May 25, 2026
14f7bdb
Fix applications HTMX selection URL
JoseMelNet May 25, 2026
7d8cdd5
Prioritize applications follow-up action
JoseMelNet May 25, 2026
251118f
Reduce applications rail signal noise
JoseMelNet May 25, 2026
f4713f5
Declutter applications detail panel
JoseMelNet May 26, 2026
fab3257
Collapse original analysis in applications detail
JoseMelNet May 26, 2026
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
1 change: 1 addition & 0 deletions app/interfaces/web/presentation/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Presentation helpers for the web UI."""
130 changes: 130 additions & 0 deletions app/interfaces/web/presentation/application_signals.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
"""Presentation helpers for conservative application follow-up signals."""

from __future__ import annotations

from datetime import date, datetime

TERMINAL_STATES = {"Done", "Rejected"}
AGE_SIGNAL_RULES = {
"Pending": (7, "Lleva tiempo pendiente"),
"Applied": (21, "Lleva tiempo aplicada"),
"Technical Test": (14, "Lleva tiempo en prueba tecnica"),
"In Interview": (14, "Lleva tiempo en entrevista"),
"Open Offer": (14, "Lleva tiempo en oferta"),
}


def _as_date(value) -> date | None:
if value is None:
return None
if isinstance(value, datetime):
return value.date()
return value


def _reference_date(application: dict) -> date | None:
return _as_date(application.get("fecha_aplicacion")) or _as_date(application.get("fecha_registro"))


def _age_days(application: dict, *, today: date) -> int | None:
reference_date = _reference_date(application)
if reference_date is None:
return None
return max((today - reference_date).days, 0)


def _has_notes(application: dict) -> bool:
return bool((application.get("notas") or "").strip())


def _has_contact(application: dict) -> bool:
return any(
(application.get(field) or "").strip()
for field in ("nombre_recruiter", "email_recruiter", "telefono_recruiter")
)


def build_follow_up_signals(application: dict, *, today: date | None = None) -> list[dict]:
current_status = application.get("estado")
today_value = today or date.today()

if current_status in TERMINAL_STATES:
return [
{
"kind": "terminal",
"label": "Terminal",
"tone": "gray",
"compact": False,
}
]

signals: list[dict] = []
age_days = _age_days(application, today=today_value)
age_rule = AGE_SIGNAL_RULES.get(current_status)
if age_rule and age_days is not None and age_days >= age_rule[0]:
signals.append(
{
"kind": "age",
"label": age_rule[1],
"tone": "amber",
"compact": True,
}
)

if current_status == "Pending":
signals.append(
{
"kind": "status",
"label": "Pendiente por aplicar",
"tone": "blue",
"compact": True,
}
)

if not _has_notes(application):
signals.append(
{
"kind": "missing",
"label": "Sin notas",
"tone": "gray",
"compact": True,
}
)

if not _has_contact(application):
signals.append(
{
"kind": "missing",
"label": "Sin contacto",
"tone": "gray",
"compact": True,
}
)

return signals


def pick_compact_follow_up_signal(application: dict, signals: list[dict]) -> dict | None:
current_status = application.get("estado")

if current_status == "Pending":
non_redundant_signal = next(
(
signal
for signal in signals
if signal.get("compact") and signal.get("label") != "Pendiente por aplicar"
),
None,
)
if non_redundant_signal is not None:
return non_redundant_signal
return None

return next((signal for signal in signals if signal.get("compact")), None)


__all__ = [
"TERMINAL_STATES",
"build_follow_up_signals",
"pick_compact_follow_up_signal",
]
162 changes: 162 additions & 0 deletions app/interfaces/web/presentation/decision_signal.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
"""Shared decision presentation helpers for vacancy analysis."""

from __future__ import annotations

import unicodedata

DECISION_TONE_MAP = {
"apply_strong": "green",
"apply_later": "amber",
"discard": "red",
"unknown": "gray",
}

COHERENCE_LABELS = {
"aligned": "Coherente",
"cautious_high_score": "Score alto con decision cauta",
"cautious_discard": "Score competitivo con descarte",
"optimistic_low_score": "Decision optimista con score bajo",
"missing_score": "Decision sin score",
"missing_decision": "Score sin decision",
"unknown_decision": "Decision desconocida",
}


def _safe_score(value) -> float | None:
try:
return float(value) if value is not None else None
except (TypeError, ValueError):
return None


def _normalize_text(value: str | None) -> str:
if not value:
return ""

collapsed = " ".join(str(value).strip().split())
normalized = unicodedata.normalize("NFKD", collapsed)
return "".join(char for char in normalized if not unicodedata.combining(char)).lower()


def _score_band(score: float | None) -> str:
if score is None:
return "unknown"
if score >= 80:
return "high"
if score >= 60:
return "medium"
return "low"


def _score_tone(score_band: str) -> str:
return {
"high": "green",
"medium": "amber",
"low": "red",
"unknown": "gray",
}.get(score_band, "gray")


def _normalize_decision(decision: str | None) -> tuple[str | None, str]:
cleaned = str(decision).strip() if decision is not None else ""
if not cleaned or cleaned.lower() in {"null", "none", "n/a", "na", "-"}:
return None, "unknown"

normalized = _normalize_text(cleaned)
if "aplicar si sobra tiempo" in normalized:
return cleaned, "apply_later"
if "aplicar si o si" in normalized:
return cleaned, "apply_strong"
if any(keyword in normalized for keyword in ("no aplicar", "descartar", "rechazar")):
return cleaned, "discard"
if any(keyword in normalized for keyword in ("revis", "evalu", "consider")):
return cleaned, "apply_later"
if any(keyword in normalized for keyword in ("aplicar", "prior", "avanz")):
return cleaned, "apply_strong"
return cleaned, "unknown"


def _coherence_status(score_band: str, decision_normalized: str, *, has_decision: bool) -> str:
if not has_decision and score_band != "unknown":
return "missing_decision"
if has_decision and score_band == "unknown":
return "missing_score"
if decision_normalized == "unknown":
return "unknown_decision"
if decision_normalized == "apply_later" and score_band == "high":
return "cautious_high_score"
if decision_normalized == "discard" and score_band in {"high", "medium"}:
return "cautious_discard"
if decision_normalized == "apply_strong" and score_band == "low":
return "optimistic_low_score"
return "aligned"


def _build_decision_signal(analysis: dict | None) -> dict:
score = _safe_score(analysis.get("score_total")) if analysis else None
score_value = round(score) if score is not None else None
score_band = _score_band(score)
score_tone = _score_tone(score_band)
score_label = f"{score_value:.0f}" if score_value is not None else "-"
decision_raw, decision_normalized = _normalize_decision(
analysis.get("decision_aplicacion") if analysis else None
)

if decision_normalized == "apply_strong":
decision_label = decision_raw or "Aplicar si o si"
decision_compact_label = "Si"
elif decision_normalized == "apply_later":
decision_label = decision_raw or "Aplicar si sobra tiempo"
decision_compact_label = "Si hay tiempo"
elif decision_normalized == "discard":
decision_label = decision_raw or "Descartar"
decision_compact_label = "No"
else:
if decision_raw:
decision_label = decision_raw
decision_compact_label = "Sin decision"
else:
decision_label = "Pendiente de analisis" if not analysis else "Sin decision"
decision_compact_label = "Pendiente"

decision_tone = DECISION_TONE_MAP[decision_normalized]
coherence_status = _coherence_status(
score_band,
decision_normalized,
has_decision=bool(decision_raw),
)
score_title = f"Score {score_label}" if score_value is not None else "Score pendiente"
title_parts = [decision_label]
if score_value is not None:
title_parts.append(f"Score {score_label}")

return {
"score": score_value,
"score_label": score_label,
"score_band": score_band,
"score_tone": score_tone,
"decision_raw": decision_raw,
"decision_normalized": decision_normalized,
"decision_label": decision_label,
"decision_compact_label": decision_compact_label,
"decision_tone": decision_tone,
"display_tone": decision_tone,
"coherence_status": coherence_status,
"coherence_label": COHERENCE_LABELS[coherence_status],
"title": " · ".join(title_parts),
"aria_label": " · ".join(title_parts),
"score_title": score_title,
}


__all__ = [
"COHERENCE_LABELS",
"DECISION_TONE_MAP",
"_build_decision_signal",
"_coherence_status",
"_normalize_decision",
"_normalize_text",
"_safe_score",
"_score_band",
"_score_tone",
]
Loading
Loading