feat: ship premium IT-focused vacancy radar
deploy / deploy (push) Canceled after 0s

This commit is contained in:
Jens
2026-07-22 15:16:15 +02:00
parent 7d3d026d1d
commit 029df89265
23 changed files with 838 additions and 53 deletions
+16
View File
@@ -2,6 +2,22 @@
Alle betekenisvolle wijzigingen worden hier bijgehouden. Het project volgt voorlopig een pre-1.0 semantische versieaanpak.
## 0.2.11 — 2026-07-22
### Gewijzigd
- De standaardradar toont alleen titelmatig aantoonbare IT-vacatures en laat verborgen/hard uitgesloten matches weg; het volledige bron- en scorearchief blijft expliciet bereikbaar.
- Vacatures worden standaard op de laatste persoonlijke matchscore gerangschikt en tonen scorebewijs, datakwaliteit, aandachtspunt en directe acties in de lijst.
- Vandaag gebruikt uitsluitend de laatste gebruikersgescopeerde score en telt alleen persoonlijke open dossiers.
- Dashboard, vacaturelijst en detail volgen de Stitch-panelhiërarchie met context-rails, rijkere evidencekaarten en een adaptieve contentbreedte tot 2240 px.
- Op 1920 en 2560 px krijgen resultaatfeeds twee kolommen; op 320 px stapelen filters, sectiekoppen en acties zonder horizontale overflow.
- Feedbackterugkeer is tegen externe redirects afgeschermd en `/favicon.ico` gebruikt het lokale SVG-favicon.
### Verificatie
- 225 tests geslaagd, 2 optionele Playwrightvarianten overgeslagen en 84,53% branch-aware codedekking; Ruff, Django, migraties, taakledger en repositoryvalidatie zijn groen.
- De geïntegreerde browsermatrix controleerde zes kernroutes en zes breedtes van 320 tot 2560 px, dark/light, mobiele navigatie en de radar-/archiefflow zonder horizontale paginaoverflow.
## 0.2.10 — 2026-07-22
### Gewijzigd
+1 -1
View File
@@ -10,7 +10,7 @@ from apps.profiles.models import SearchProfile
def navigation_context(request: HttpRequest) -> dict[str, Any]:
context = {
"app_name": "VacatureRadar",
"app_version": "0.2.2",
"app_version": "0.2.11",
}
if request.user.is_authenticated:
context["navigation_profile"] = (
+29 -9
View File
@@ -4,13 +4,15 @@ from django.conf import settings
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.views import LoginView
from django.db.models import Count, Q
from django.db.models import Count, OuterRef, Q, Subquery
from django.http import JsonResponse
from django.utils import timezone
from django.views.generic import TemplateView
from apps.core.rate_limit import clear_rate_limit, is_rate_limited, register_rate_limit_failure
from apps.jobs.models import Application, JobPosting, ScoreRun
from apps.jobs.services.relevance import it_relevance_query
from apps.profiles.models import SearchProfile
from apps.sources.models import Source
from apps.sources.services.health import collect_source_health
@@ -83,9 +85,24 @@ class TodayView(LoginRequiredMixin, TemplateView):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
profile = SearchProfile.objects.filter(user=self.request.user, is_active=True).first()
latest_score_id = (
ScoreRun.objects.filter(job_id=OuterRef("job_id"), profile=profile)
.order_by("-created_at")
.values("pk")[:1]
)
latest_scores = (
ScoreRun.objects.select_related("job", "job__employer", "profile")
.filter(job__status=JobPosting.Status.ACTIVE, hard_exclusions=[])
.filter(
profile=profile,
job__status=JobPosting.Status.ACTIVE,
hard_exclusions=[],
recommendation__in=[
ScoreRun.Recommendation.STRONG,
ScoreRun.Recommendation.POSSIBLE,
],
)
.filter(it_relevance_query("job__"), pk=Subquery(latest_score_id))
.order_by("-score", "-created_at")
)
# Eén score per vacature, zonder PostgreSQL-specifieke DISTINCT ON.
@@ -99,6 +116,10 @@ class TodayView(LoginRequiredMixin, TemplateView):
cards.append(score)
if len(cards) >= 20:
break
active_it_jobs = JobPosting.objects.filter(
Q(status=JobPosting.Status.ACTIVE) & it_relevance_query()
)
all_active_jobs = JobPosting.objects.filter(status=JobPosting.Status.ACTIVE)
context.update(
{
"score_cards": cards,
@@ -106,13 +127,12 @@ class TodayView(LoginRequiredMixin, TemplateView):
.exclude(status__in=[Application.Status.REJECTED, Application.Status.WITHDRAWN])
.select_related("job", "job__employer")
.order_by("follow_up_date", "-updated_at")[:3],
"active_jobs": JobPosting.objects.filter(status=JobPosting.Status.ACTIVE).count(),
"new_today": JobPosting.objects.filter(
first_seen__date=timezone.localdate()
).count(),
"applications_open": Application.objects.exclude(
status__in=[Application.Status.REJECTED, Application.Status.WITHDRAWN]
).count(),
"active_jobs": active_it_jobs.count(),
"filtered_non_it_jobs": all_active_jobs.exclude(it_relevance_query()).count(),
"new_today": active_it_jobs.filter(first_seen__date=timezone.localdate()).count(),
"applications_open": Application.objects.filter(user=self.request.user)
.exclude(status__in=[Application.Status.REJECTED, Application.Status.WITHDRAWN])
.count(),
"source_counts": Source.objects.exclude(
domain="jobs.example.org", status=Source.Status.DISABLED
).aggregate(
+180
View File
@@ -0,0 +1,180 @@
from __future__ import annotations
import re
from dataclasses import dataclass
from django.db.models import Q
from apps.profiles.models import SearchProfile
from .normalization import normalize_token
# Deliberately title-led: employer pages regularly mention digital systems in otherwise
# non-IT vacancies. A description hit alone must therefore never make a vacancy relevant.
IT_TITLE_TERMS = (
"it",
"ict",
"informatica",
"software",
"development engineer",
"devops",
"secops",
"cloud",
"data engineer",
"data scientist",
"data steward",
"database",
"dba",
"business intelligence",
"bi analyst",
"system engineer",
"systems engineer",
"systeembeheer",
"infrastructure engineer",
"infrastructuur engineer",
"network engineer",
"network architect",
"netwerkbeheer",
"netwerk engineer",
"cybersecurity",
"cyber security",
"security engineer",
"security architect",
"security analyst",
"information security",
"informatiebeveiliging",
"workplace engineer",
"modern workplace",
"digital workplace",
"service desk",
"servicedesk",
"helpdesk",
"support engineer",
"support specialist",
"application lead",
"application engineer",
"application manager",
"application specialist",
"applicatiebeheer",
"platform engineer",
"platform expert",
"solution architect",
"solutions architect",
"technical architect",
"technisch architect",
"functional analyst",
"functioneel analist",
"business analyst",
"product owner",
"web developer",
"mobile developer",
"low-code developer",
"low code developer",
"api developer",
"idm developer",
"power platform",
"forgerock",
"embedded",
"c#",
"front end",
"frontend",
"back end",
"backend",
"full stack",
"fullstack",
"php",
".net",
"java",
"python",
"powershell",
"azure",
"aws",
"microsoft 365",
"linux",
"windows server",
"kubernetes",
"docker",
"terraform",
"erp",
"sap",
"machine learning",
"ai engineer",
"artificial intelligence",
"image processing",
"computer vision",
"qa engineer",
"test automation",
)
# These phrases can contain IT vocabulary while describing commercial, recruitment,
# or educational-design work. They therefore override positive title signals.
NON_IT_TITLE_TERMS = (
"business developer",
"business development",
"it recruitment",
"it recruiter",
"learning designer",
)
IT_PROFILE_TERMS = (*IT_TITLE_TERMS, "networking", "security", "active directory", "intune")
def _term_pattern(term: str) -> str:
escaped = re.escape(term).replace(r"\ ", r"[\s/_-]+")
return rf"(?<!\w){escaped}(?!\w)"
IT_TITLE_PATTERN = "(?:" + "|".join(_term_pattern(term) for term in IT_TITLE_TERMS) + ")"
NON_IT_TITLE_PATTERN = "(?:" + "|".join(_term_pattern(term) for term in NON_IT_TITLE_TERMS) + ")"
_IT_TITLE_RE = re.compile(IT_TITLE_PATTERN, re.IGNORECASE)
_NON_IT_TITLE_RE = re.compile(NON_IT_TITLE_PATTERN, re.IGNORECASE)
@dataclass(frozen=True)
class ItRelevanceAssessment:
relevant: bool
signals: tuple[str, ...]
reason: str
def assess_it_relevance(title: str) -> ItRelevanceAssessment:
normalized_title = normalize_token(title)
blocked_signal = _NON_IT_TITLE_RE.search(normalized_title)
if blocked_signal:
return ItRelevanceAssessment(
relevant=False,
signals=(),
reason=f"Niet-technische titelcontext: {blocked_signal.group(0)}.",
)
signals = tuple(
term
for term in IT_TITLE_TERMS
if re.search(_term_pattern(normalize_token(term)), normalized_title, re.IGNORECASE)
)
if signals:
return ItRelevanceAssessment(
relevant=True,
signals=signals[:4],
reason="IT-signaal in functietitel: " + ", ".join(signals[:4]),
)
return ItRelevanceAssessment(
relevant=False,
signals=(),
reason="Geen aantoonbaar IT-signaal in de functietitel.",
)
def profile_requires_it_focus(profile: SearchProfile) -> bool:
configured = " ".join([profile.name, *profile.desired_titles, *profile.desired_skills])
normalized = normalize_token(configured)
return any(
re.search(_term_pattern(normalize_token(term)), normalized, re.IGNORECASE)
for term in IT_PROFILE_TERMS
)
def it_relevance_query(prefix: str = "") -> Q:
"""Return the database equivalent of the conservative title-led classifier."""
return Q(**{f"{prefix}original_title__iregex": IT_TITLE_PATTERN}) & ~Q(
**{f"{prefix}original_title__iregex": NON_IT_TITLE_PATTERN}
)
+12
View File
@@ -14,6 +14,7 @@ from apps.jobs.services.geocoding import resolve_cached_location
from apps.profiles.models import SearchProfile
from .normalization import normalize_token
from .relevance import assess_it_relevance, profile_requires_it_focus
@dataclass(frozen=True)
@@ -227,6 +228,9 @@ def _hard_exclusions(
conflicts = sorted(excluded_skills.intersection(explicit_job_skills))
if conflicts:
reasons.append("Uitgesloten verplichte skill: " + ", ".join(conflicts))
it_relevance = assess_it_relevance(job.original_title)
if profile_requires_it_focus(profile) and not it_relevance.relevant:
reasons.append(it_relevance.reason)
return reasons, distance.distance_confidence
@@ -292,6 +296,7 @@ def _analyze_with_ai(job: JobPosting, profile: SearchProfile) -> tuple[AiAnalysi
def calculate_score(job: JobPosting, profile: SearchProfile) -> ScoreResult:
distance = _distance(job, profile)
exclusions, distance_confidence = _hard_exclusions(job, profile, distance)
it_relevance = assess_it_relevance(job.original_title)
title_fit = _title_fit(job, profile)
skill_fit, present_skills, missing_skills = _skill_fit(job, profile)
features = job.analysis_features or {}
@@ -381,6 +386,8 @@ def calculate_score(job: JobPosting, profile: SearchProfile) -> ScoreResult:
concerns: list[str] = []
if title_fit >= 0.75:
positives.append("Functietitel sluit goed aan op het zoekprofiel.")
if it_relevance.relevant:
positives.append("IT-focus bevestigd via de functietitel.")
if present_skills:
positives.append("Herkenbare skills: " + ", ".join(present_skills[:6]))
if job.direct_employer and not job.recruiter:
@@ -435,6 +442,11 @@ def calculate_score(job: JobPosting, profile: SearchProfile) -> ScoreResult:
concerns=concerns,
hard_exclusions=exclusions,
evidence={
"it_relevance": {
"relevant": it_relevance.relevant,
"signals": list(it_relevance.signals),
"reason": it_relevance.reason,
},
"distance_km": distance.exact_distance_km,
"distance_has_data": distance.has_distance_data,
"distance_exact": distance.exact_distance_km is not None,
+90 -3
View File
@@ -3,10 +3,20 @@ from __future__ import annotations
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import LoginRequiredMixin
from django.db.models import CharField, DecimalField, OuterRef, Q, Subquery
from django.db.models import (
CharField,
DateTimeField,
DecimalField,
F,
JSONField,
OuterRef,
Q,
Subquery,
)
from django.http import Http404, HttpResponse
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.utils.http import url_has_allowed_host_and_scheme
from django.views.decorators.http import require_POST
from django.views.generic import DetailView, ListView, UpdateView
@@ -21,6 +31,7 @@ from .services.applications import (
track_application_changes,
)
from .services.feedback import record_feedback
from .services.relevance import it_relevance_query
class JobListView(LoginRequiredMixin, ListView):
@@ -29,6 +40,15 @@ class JobListView(LoginRequiredMixin, ListView):
context_object_name = "jobs"
paginate_by = 30
VALID_RECOMMENDATIONS = {
ScoreRun.Recommendation.STRONG,
ScoreRun.Recommendation.POSSIBLE,
ScoreRun.Recommendation.WEAK,
ScoreRun.Recommendation.HIDDEN,
}
VALID_MATCH_FILTERS = {*VALID_RECOMMENDATIONS, "radar", "all"}
VALID_SORTS = {"match", "newest", "closing"}
def get_queryset(self):
queryset = JobPosting.objects.select_related("employer").all()
profile = SearchProfile.objects.filter(user=self.request.user, is_active=True).first()
@@ -48,10 +68,27 @@ class JobListView(LoginRequiredMixin, ListView):
match_recommendation=Subquery(
latest_score.values("recommendation")[:1], output_field=CharField()
),
match_positives=Subquery(
latest_score.values("positives")[:1], output_field=JSONField()
),
match_concerns=Subquery(
latest_score.values("concerns")[:1], output_field=JSONField()
),
match_hard_exclusions=Subquery(
latest_score.values("hard_exclusions")[:1], output_field=JSONField()
),
match_scored_at=Subquery(
latest_score.values("created_at")[:1], output_field=DateTimeField()
),
)
query = self.request.GET.get("q", "").strip()
status = self.request.GET.get("status", "active").strip()
workplace = self.request.GET.get("workplace", "").strip()
focus = self.request.GET.get("focus", "it").strip()
recommendation = self.request.GET.get("match", "radar").strip()
sort = self.request.GET.get("sort", "match").strip()
if recommendation not in self.VALID_MATCH_FILTERS:
recommendation = "radar"
if query:
queryset = queryset.filter(
Q(original_title__icontains=query)
@@ -63,7 +100,51 @@ class JobListView(LoginRequiredMixin, ListView):
queryset = queryset.filter(status=status)
if workplace:
queryset = queryset.filter(workplace_type=workplace)
return queryset.order_by("-first_seen")
if focus != "all":
queryset = queryset.filter(it_relevance_query())
if profile and recommendation == "radar":
queryset = queryset.filter(
Q(
match_recommendation__in=[
ScoreRun.Recommendation.STRONG,
ScoreRun.Recommendation.POSSIBLE,
ScoreRun.Recommendation.WEAK,
]
)
| Q(match_recommendation__isnull=True)
)
elif profile and recommendation in self.VALID_RECOMMENDATIONS:
queryset = queryset.filter(match_recommendation=recommendation)
if sort not in self.VALID_SORTS:
sort = "match"
if sort == "newest":
return queryset.order_by("-first_seen", F("match_score").desc(nulls_last=True))
if sort == "closing":
return queryset.order_by(F("valid_through").asc(nulls_last=True), "-first_seen")
return queryset.order_by(F("match_score").desc(nulls_last=True), "-first_seen")
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
active_jobs = JobPosting.objects.filter(status=JobPosting.Status.ACTIVE)
it_jobs = active_jobs.filter(it_relevance_query())
context.update(
{
"active_profile": SearchProfile.objects.filter(
user=self.request.user, is_active=True
).first(),
"active_job_count": active_jobs.count(),
"it_job_count": it_jobs.count(),
"filtered_non_it_count": active_jobs.exclude(it_relevance_query()).count(),
"current_focus": self.request.GET.get("focus", "it"),
"current_match": (
self.request.GET.get("match", "radar")
if self.request.GET.get("match", "radar") in self.VALID_MATCH_FILTERS
else "radar"
),
"current_sort": self.request.GET.get("sort", "match"),
}
)
return context
class JobDetailView(LoginRequiredMixin, DetailView):
@@ -113,7 +194,13 @@ def job_feedback(request, pk):
)
messages.success(request, "Actie opgeslagen.")
next_url = request.POST.get("next")
return redirect(next_url or reverse("jobs:detail", kwargs={"pk": pk}))
if next_url and url_has_allowed_host_and_scheme(
next_url,
allowed_hosts={request.get_host()},
require_https=request.is_secure(),
):
return redirect(next_url)
return redirect(reverse("jobs:detail", kwargs={"pk": pk}))
class ApplicationListView(LoginRequiredMixin, ListView):
+7
View File
@@ -4,11 +4,18 @@ from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.contrib.auth import views as auth_views
from django.templatetags.static import static as static_url
from django.urls import include, path
from django.views.generic import RedirectView
from apps.core.views import SecurityAwareLoginView
urlpatterns = [
path(
"favicon.ico",
RedirectView.as_view(url=static_url("favicon.svg"), permanent=True),
name="favicon",
),
path("admin/", admin.site.urls),
path(
"login/",
+44
View File
@@ -1444,6 +1444,50 @@ tasks:
note: GeoNames België postcodedataset 2026-04-03 onder CC BY 4.0 gereviewd, checksums vastgelegd, 2781 unieke
rijen gevalideerd en 5562 lookuprecords geïmporteerd; 2400 Mol opgelost naar 51.1919, 5.1166 en actieve profielscores
herberekend.
- id: VR-206
title: Premium Stitch-cockpit en deterministische IT-focus
status: done
priority: P0
requirement_ids:
- PR-040
- PR-050
- PR-051
- NFR-004
- NFR-008
depends_on:
- VR-119
- VR-120
- VR-205
summary: Audit de volledige gebruikersflow in code en browser, voorkom dat niet-IT-vacatures de dagelijkse radar
vervuilen en benut de Stitch-compositie veel dominanter op mobiel, desktop en widescreen.
acceptance_criteria:
- Een deterministische en uitlegbare IT-relevantieregel heeft voorrang op scoring en AI; niet-IT-vacatures zijn
standaard afwezig uit Vandaag en de vacaturefeed maar blijven via een expliciete archiefkeuze inspecteerbaar.
- De vacaturefeed gebruikt echte score-evidence, bruikbare relevantie-/match-/sorteerfilters en toont standaard
de meest relevante IT-kansen in plaats van louter de nieuwste bronrecords.
- Dashboard, vacaturelijst en vacaturedetail nemen de bruikbare Stitch-panelhiërarchie, informatiedichtheid en
actiepatronen over zonder fictieve metrics, quick-apply of externe frontendafhankelijkheden.
- De shell en kernlayouts schalen inhoudelijk tussen 320 en 2560 px; widescreen voegt bruikbare kolommen/panelen
toe en laat de inhoud niet op een smalle 1280px-strook staan.
- Focus, headings, labels, lege toestanden, dark/light contrast, mobiele navigatie en reduced motion blijven toegankelijk;
kernroutes hebben geen horizontale paginaoverflow of browserconsolefouten.
verification:
- uv run pytest tests/unit/test_relevance.py tests/unit/test_scoring.py tests/integration/test_views.py tests/e2e/test_vr114_browser_and_accessibility.py
- ./scripts/codex_verify.sh
- Browsermatrix op 2560x1440, 1920x1080, 1440x900, 1024x768, 390x844 en 320x800
primary_paths:
- apps/jobs/services/relevance.py
- apps/jobs/services/scoring.py
- apps/jobs/views.py
- templates/dashboard/today.html
- templates/jobs/list.html
- templates/jobs/detail.html
- static/css/app.css
- docs/design/STITCH_INTEGRATION_PLAN.md
result:
completed_at: '2026-07-22'
note: 'Volledige code- en browseraudit: deterministische IT-focus, laatste gebruikersscore, premium Stitch-feed/detail
en adaptieve 320-2560px-layout; 225 tests groen en browsermatrix geslaagd.'
- id: VR-301
title: Multi-tenant SaaS, teams en billing
status: deferred
+17 -1
View File
@@ -1,7 +1,7 @@
# Projectstatus
- Laatst bijgewerkt: 2026-07-22
- Repositoryversie: 0.2.10 Kempen-werkgeversradar en GeoNames
- Repositoryversie: 0.2.11 premium IT-radar en ultrawide Stitch-cockpit
- Uitvoeringsmodus: autonome backlog
- Eerste uitvoerbare taak: controleer met `python scripts/backlog.py next`
@@ -131,6 +131,15 @@ VR-201/VR-205-herverificatie op 2026-07-22:
- de fetcher valideert het werkelijk verbonden publieke peer-IP, zodat legitieme CDN-rotatie van DAF en Xplore Group veilig wordt ondersteund;
- taakledger bevat 44 taken: 38 `done`, 3 `blocked-external`, 3 `deferred` en 0 `ready`.
VR-206-herverificatie op 2026-07-22:
- 225 geslaagde tests en 2 optioneel overgeslagen pytest-Playwrightvarianten; HTML/a11y-fallback geslaagd;
- 84,53% branch-aware codedekking; Ruff, Django system check, migratiecontrole, taakledger en repositoryvalidatie geslaagd via `scripts/codex_verify.sh`;
- de standaardradar gebruikt een deterministische titelgestuurde IT-grens en laat verborgen/hard uitgesloten matches weg, met expliciete archiefopties voor volledige inspectie;
- de lokale auditinventaris telt 336 actieve records: 108 aantoonbare IT-titels, 228 gefilterde niet-IT-records en 102 kaarten in de persoonlijke radarselectie;
- de Stitch-cockpit benut maximaal 2240 px met tweekoloms matchfeeds op ultrawide; de zes kernroutes zijn handmatig gecontroleerd op 320, 390, 1024, 1440, 1920 en 2560 px zonder horizontale paginaoverflow;
- dark/light, gelabelde velden, mobiele drawer, score-/confidencecopy, archieffilters en inline bewaren zijn in de geïntegreerde browser gecontroleerd.
## Deploymentstatus
- `main` is via de bestaande Gitea-SSH-sleutel naar `NuklearRabbit/VacatureRadar` gepusht.
@@ -141,6 +150,13 @@ VR-201/VR-205-herverificatie op 2026-07-22:
## Laatste uitgevoerde backlogtaak
- 2026-07-22: `VR-206` voltooid.
- Niet-IT-vacatures en commercieel/recruitmentmisleidende IT-titels zijn standaard uit Vandaag en de vacaturefeed verwijderd zonder bronhistoriek te wissen.
- Matchsortering, radar-/archieffilters, score-evidence en gebruikersgescopeerde dashboardscores maken de dagelijkse selectie beslisbaar.
- De Stitch-dominante shell, feeds, details en context-rails schalen van 320 tot 2560 px en gebruiken op ultrawide twee resultaatkolommen.
- Een open redirect in feedbackterugkeer, 320 px bronoverflow en de ontbrekende faviconfallback zijn als auditbevindingen mee opgelost.
- Volgende taak: geen uitvoerbare `ready` taak; `VR-202`, `VR-203` en `VR-204` blijven `blocked-external`.
- 2026-07-22: `VR-201` en `VR-205` voltooid.
- De officiële GeoNames-postcodedataset lost postcode 2400 op naar Mol en levert indicatieve postcodecentrumafstanden; licentie, versie, reviewdatum en twee SHA-256-checksums zijn vastgelegd.
- Gemeente Mol, Ziekenhuis Geel, lokaal bestuur Geel, Stad Turnhout, Group Renotec, Ravago, Sanofi Geel en DAF Trucks Westerlo zijn na fixtures, negatieve routechecks en twee livecycli actief.
+3 -2
View File
@@ -52,11 +52,12 @@ mag in een afgeschermde browsercontext uitvallen zonder de bediening te breken.
## 4. Spacing en layout
- 4/8px-gebaseerde schaal;
- contentcontainer maximaal 1600px zodat de cockpit op brede schermen bruikbaar blijft;
- contentcontainer maximaal 2240px; vanaf 1850px gebruikt de cockpit extra resultaatkolommen en
contextpanelen in plaats van alleen langere tekstregels;
- kaarten met consistente padding 2024px desktop, 16px mobiel;
- minimaal 44×44px touch targets voor primaire interactie;
- vaste zijbalk van 272px boven 900px, toegankelijke uitschuifnavigatie daaronder;
- grids breken onder 1180px, 900px en 680px logisch af;
- grids breken rond 1850px, 1350px, 1180px, 900px en 680px logisch af;
- vaste/sticky elementen mogen content niet bedekken.
## 5. Componenten
+12 -1
View File
@@ -19,6 +19,17 @@ Na productfeedback is de integratie aangescherpt: Stitch bepaalt nu niet alleen
De bedoelde “circa 90% Stitch” slaat hiermee op overgenomen visuele structuur en interactiehiërarchie, niet op het kopiëren van fictieve content, externe assets, auto-apply of routes waarvoor het domeinmodel geen betrouwbare data bezit.
## VR-206: premium radar en ultrawide compositie
De volledige audit maakt de Stitch-compositie ook functioneel dominant. De vacaturefeed start met
een brede commandofilter, toont beslisbare score-evidence en krijgt op breed desktop een vaste
radarcontext. Vanaf 1850 px worden matchfeeds tweekoloms; de shell benut maximaal 2240 px in plaats
van de vroegere 1280 px. Op mobiel worden filters, sectiekoppen en acties lineair gestapeld.
De visuele hiërarchie is gekoppeld aan een deterministische IT-grens: niet-IT en hard uitgesloten
matches zijn standaard afwezig. Het volledige archief en verborgen scores blijven expliciet
inspecteerbaar, zodat premium rust niet ten koste gaat van auditbaarheid.
## Visuele richting
- Donkere cockpit als standaard: diep marineblauw, tonale panelen, fijne slate-randen, signaalcyaan voor primaire interactie, emerald voor aantoonbaar gezonde status en koraal/amber voor fouten of aandacht.
@@ -58,7 +69,7 @@ De bedoelde “circa 90% Stitch” slaat hiermee op overgenomen visuele structuu
## Responsive en toegankelijkheid
- Validatiebreedtes: 1440×900, 1280×800, 1024×768, 768×1024, 390×844 en 360×800.
- Validatiebreedtes: 2560×1440, 1920×1080, 1440×900, 1024×768, 390×844 en 320×800.
- Eén `h1` per pagina, semantische landmarks, zichtbare focus, toetsenbordbediening, 44px touchdoelen waar praktisch, tekstlabels naast statuskleur en een robuuste skiplink.
- Tabellen schakelen op smalle schermen naar kaartachtige rijen of blijven gecontroleerd horizontaal scrolbaar wanneer kolomvergelijking essentieel is.
- Animatie is subtiel en volledig uitgeschakeld bij `prefers-reduced-motion`.
+6
View File
@@ -56,6 +56,8 @@ Regels:
Filters:
- vrije tekst;
- relevantiefocus: standaard aantoonbare IT-titels, met expliciet volledig bronarchief;
- radarselectie: standaard geen harde uitsluitingen of verborgen score, met aparte inspectiefilters;
- status;
- recommendation/scoreband;
- werkvorm;
@@ -65,6 +67,10 @@ Filters:
Desktop gebruikt een compacte filterbalk; mobiel een disclosurepanel. Filters blijven in querystring zodat bookmarks en back navigation werken.
De standaardvolgorde is persoonlijke matchscore, niet importtijd. Niet-IT-inhoud wordt niet
verwijderd: ze blijft uitsluitend via `Volledig archief` bereikbaar. Een beschrijvingshit alleen
mag nooit een niet-IT-titel promoveren.
Tabel of kaarten kiezen op breedte. Geen horizontale scroll voor kernvelden.
## 5. Vacaturedetail
+65
View File
@@ -0,0 +1,65 @@
# VR-206 volledige UX-, logica- en browseraudit
Datum: 2026-07-22
## Uitgangssituatie
De productiecontrole toonde 242 actieve vacatures. De standaardlijst sorteerde hoofdzakelijk op
`first_seen` en liet elke actieve vacature zien. Daardoor stonden onder meer facilitaire techniek,
logopedie, vroedkunde en niet-IT-onderzoek tussen de persoonlijke IT-resultaten. Op 2560 px bleef
de inhoud bovendien op ongeveer 1280 px begrensd, waardoor de Stitch-cockpit een brede lege zone
overliet.
## Bevindingen en oplossing
| Prioriteit | Bevinding | Oplossing |
| --- | --- | --- |
| P0 | Niet-IT-vacatures bereikten Vandaag en de standaardfeed. | Titelgestuurde, deterministische IT-classificatie vóór score/AI; niet-IT blijft alleen in het expliciete volledige archief. |
| P0 | Vandaag gebruikte niet overal de laatste gebruikersgescopeerde score en open dossiercijfers waren niet volledig gebruikersgescopeerd. | Laatste score per vacature/profiel via subquery; profiel, scores en dossiers zijn aan de aangemelde gebruiker gebonden. |
| P1 | Verborgen/hard uitgesloten IT-vacatures stonden hoog door hun ruwe score. | Nieuwe standaardkeuze `Radarselectie` laat verborgen resultaten weg; `Verborgen` en `Alle IT-scores` blijven bewuste inspectiekeuzes. |
| P1 | De lijst sorteerde op bronrecency in plaats van persoonlijke besliswaarde. | Standaard matchscore aflopend, met alternatieven voor nieuwste en sluitingsdatum. |
| P1 | Stitch werd visueel gebruikt, maar ultrawide bleef een smalle strook. | Adaptieve contentbreedte tot 2240 px, tweekoloms feeds vanaf 1850 px en context-/actierails op breed desktop. |
| P1 | Vacaturekaarten boden weinig scorebewijs. | Score, datakwaliteit, eerste pluspunt, eerste aandachtspunt, status, werkmodel en directe actie staan in de feed. |
| P2 | Confidencewaarden wisselden tussen breuken en percentages. | Alle dagelijkse score- en extractiekwaliteit wordt als percentage getoond. |
| P2 | Inline feedback accepteerde een willekeurige `next`-URL. | Terugkeer alleen naar dezelfde host, met negatieve regressietest. |
| P2 | De bronpagina liep op 320 px uit door statuschips naast lange sectiekoppen. | Sectiekoppen stapelen onder 680 px; de 320 px matrix heeft geen horizontale overflow. |
| P2 | Sommige browsers vroegen `/favicon.ico` en kregen een 404. | Lokale permanente fallback naar het bestaande SVG-favicon. |
## Relevantieregel
De classifier is bewust conservatief en titelgestuurd. Een beschrijving die toevallig “digitale
systemen” vermeldt, maakt een zorg- of facilitaire functie niet relevant. Duidelijke IT-families
zoals infrastructuur, netwerk, cloud, security, software, data, applicatiebeheer en workplace
worden toegelaten. Commerciële of recruitmentcontext die IT-woorden bevat, wordt expliciet
afgewezen. Dezelfde regexcontracten worden in Python en in de databasequery gebruikt.
Op de lokale auditdataset met 336 actieve records bleven 108 titelmatig aantoonbare IT-vacatures
over en werden 228 niet-IT-records uit de standaardfocus gehouden. Na uitsluiting van verborgen
matches bevatte de persoonlijke radarselectie 102 kaarten. Het volledige archief blijft 336
records bevatten: er wordt geen bronhistoriek gewist.
## Stitch-vertaling
- Dashboard: KPI-strip, beslisbare matchfeed en vaste radar-/actierail.
- Vacaturelijst: brede commandofilter, compacte evidencekaarten, scorehiërarchie en radarcontext.
- Detail: scorehero, intelligence-strip, bronhistorie en menselijke actierail.
- Shell: vaste desktopnavigatie, compacte utilitybar, lokale iconen en mobiele drawer.
- Ultrawide: twee inhoudskolommen plus context; geen kunstmatig uitgerekte tekstregels of fictieve grafieken.
Alle cijfers en signalen komen uit de database. Quick apply, automatische sollicitatie,
platformloginbots, fictieve trendgrafieken en externe frontendassets blijven bewust afwezig.
## Browsermatrix
Handmatig gecontroleerd in de geïntegreerde browser op 320×800, 390×844, 1024×768, 1440×900,
1920×1080 en 2560×1440. De zes kernroutes (`/`, `/jobs/`, `/jobs/applications/`, `/profiles/`,
`/sources/`, `/system/`) hebben één `h1`, gelabelde formuliervelden en geen horizontale
paginaoverflow. De vacaturefeed schakelt van één kolom naar twee kolommen op ultrawide en gebruikt
daar 2240 px van de beschikbare ruimte. Mobiele navigatie opent en sluit met correcte
`aria-expanded`-status.
## Resterend operationeel punt
Na deployment moet de bestaande productie-inventaris opnieuw gescoord worden zodat opgeslagen
score-evidence eveneens het nieuwe `it_relevance`-blok bevat. De viewfilter werkt onmiddellijk en
onafhankelijk daarvan; het herscoren verbetert de uitleg en blijvende `hidden`-status.
+1
View File
@@ -80,6 +80,7 @@ Django/Celery
| T-38 | Cross-user mailboxbeheer, dubbele polls of IMAP naar private host | credentialmisbruik, duplicaten, toegang tot interne diensten | alle views scopen op gebruiker en vereisen CSRF; unieke user+platformkoppeling; transactionele lease; hostvorm en publieke DNS/IP-validatie vóór TLS-IMAP | `tests/integration/test_mailbox_connections.py`, `tests/unit/test_mailbox_credentials.py`, `apps/sources/services/imap_import.py`, `VR-123` |
| T-39 | Mol-/Kempenbron vermengt een andere werkgever, campus of vestiging | foutieve afstand, phishing of buitenregionale resultaten | exacte HTTPS-host/pad/GUID-routes; same-host details; expliciete plaatsfilters voor regionale en gedeelde lijsten; postcodecentra blijven als benadering herkenbaar; waaklijstbronnen worden niet gescand | `tests/unit/test_regional_adapters.py`, `tests/integration/test_tasks_and_more_views.py`, `VR-126/201` |
| T-40 | Verouderde lokale CA-store of ongeldige serverketen lokt een TLS-bypass uit | onderschepte vacature- of mailboxdata en credentialdiefstal | expliciet gepinde `certifi`-CA-bundel voor HTTP, robots en IMAP; hostnaamcontrole en `CERT_REQUIRED`; nooit `verify=False`; dependency- en negatieve TLS-tests bij update | `tests/unit/test_tls.py`, `tests/security/test_robots.py`, `apps/sources/services/tls.py`, `VR-201` |
| T-41 | Gemodificeerde `next`-parameter na vacaturefeedback | open redirect naar phishingdomein na een geldige gebruikersactie | terugkeer-URL moet dezelfde host en het actuele HTTPS-beleid respecteren; anders veilige detailfallback | `tests/integration/test_tasks_and_more_views.py`, `VR-206` |
## Misbruikscenario's
+3 -3
View File
@@ -32,14 +32,14 @@ Legenda: **I** = geïmplementeerd en getest in de huidige MVP; **P** = gedeeltel
| PR-031 | I | conservatieve fuzzy dedupe | `tests/unit/test_dedupe_lifecycle_feedback.py`, `scripts/benchmark.py`, `tests/benchmark/test_benchmark.py` | VR-116 benchmark |
| PR-032 | I | direct-sourcepreferentie/alias | pipeline- en dedupetests | VR-108 resolver |
| PR-033 | I | canonical constraints/transacties | replaytests | — |
| PR-040 | I | features/scoring | scoringtests | |
| PR-040 | I | features/scoring + deterministische IT-relevantie vóór AI | `tests/unit/test_scoring.py`, `tests/unit/test_relevance.py`, viewtests | VR-206 |
| PR-041 | I | scorecomponenten | `tests/unit/test_features_distance.py`, `tests/unit/test_scoring.py` | VR-105 afgerond |
| PR-042 | I | unknown/confidencegedrag | `tests/unit/test_features_distance.py`, `tests/unit/test_scoring.py` | VR-105 |
| PR-043 | I | `ScoreRun` | pipeline/scoringtests | — |
| PR-044 | I | profiel/modelversievelden | scoring/profiletests | VR-109 promptversie |
| PR-045 | I | optionele Ollamaservice/fallback | `test_ai.py` | VR-109 |
| PR-046 | I | geen tools + guardrails | AI/securitytests | VR-109/115 evaluaties |
| PR-050 | I | dashboard view/template + Stitch-dominante cockpitshell/feed/rail | viewtests, VR-119 browsermatrix | VR-114/118/119 e2e/a11y |
| PR-050 | I | dashboard/list views + Stitch-dominante cockpitshell/feed/rail en ultrawide compositie | viewtests, VR-119/206 browsermatrix | VR-114/118/119/206 e2e/a11y |
| PR-051 | I | detailview/template/provenance + scorehero/intelligence-strip | viewtests, VR-119 screenshots | VR-114/118/119 |
| PR-052 | I | feedbackendpoint/UI | feedback/viewtests | — |
| PR-053 | I | applicationmodel/serviceflow | `tests/integration/test_dedupe_lifecycle_feedback.py`, `tests/integration/test_applications.py` | VR-113 uitgebreid dossier |
@@ -98,5 +98,5 @@ Iedere backlogtaak die een requirement wijzigt, werkt in dezelfde commit deze ma
- VR-126 herankert profiel en broncatalogus op postcode 2400 Mol en 40 km. De host-, pad- en locatiefilters voor SCK CEN, Cipal Schaubroeck, VanRoey, NTX en Thomas More worden gedekt door regionale adaptertests, source-viewtests, live bronruns en de bijgewerkte threat T-39.
- VR-201 activeert Gemeente Mol en zeven bijkomende Kempen-werkgevers na menselijke voorwaardenreview, fixtures, fail-closed locatie-/routefilters en twee livecycli. Twaalf interessante werkgevers zonder stabiele actieve ingang staan afzonderlijk en niet-scanbaar op de UI-waaklijst. Bewijs staat in de livecatalogus, seeddata, `KempenEmployerAdapter`, bronruns en adapter-/seed-/viewtests.
- VR-205 laadt de officiële Belgische GeoNames-postcodedataset 2026-04-03 onder CC BY 4.0, documenteert bron- en afgeleide checksums en lost 2400 Mol op naar het benaderende postcodecentrum. Bewijs staat in `config-data/geodata/README.local.md`, het importlog, geocodingtests en de opnieuw berekende profiel-vacaturescores.
- VR-206 voegt een titelgestuurde IT-relevantiegrens vóór scoring/AI toe, maakt de persoonlijke radarselectie de standaard en benut Stitch-panelen dynamisch tot 2560 px. Bewijs staat in `apps/jobs/services/relevance.py`, de score-/viewtests en `docs/design/validation/VR-206_AUDIT.md`.
+95 -3
View File
@@ -7,6 +7,7 @@
--surface-low: #131b2e;
--surface-high: #222a3d;
--surface-bright: #2d3449;
--surface-soft: #111a2d;
--text: #dae2fd;
--text-soft: #b9caca;
--muted: #849495;
@@ -16,6 +17,7 @@
--primary-strong: #63f7ff;
--primary-ink: #003739;
--primary-soft: rgba(0, 245, 255, .1);
--accent: var(--primary);
--positive: #4edea3;
--positive-soft: rgba(78, 222, 163, .11);
--warning: #ffca69;
@@ -39,6 +41,7 @@
--surface-low: #f7f9fc;
--surface-high: #edf2f7;
--surface-bright: #e2e9f2;
--surface-soft: #f6f9fc;
--text: #142033;
--text-soft: #2c3c52;
--muted: #617187;
@@ -62,7 +65,7 @@
* { box-sizing: border-box; }
html { min-height: 100%; scroll-behavior: smooth; }
body {
min-width: 320px;
min-width: 0;
min-height: 100vh;
margin: 0;
overflow-x: hidden;
@@ -72,7 +75,7 @@ body {
var(--bg);
background-size: 48px 48px;
color: var(--text);
font: 15px/1.55 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font: 16px/1.55 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
-webkit-font-smoothing: antialiased;
}
a { color: var(--primary-strong); text-decoration: none; }
@@ -143,7 +146,7 @@ button.utility-link { width: auto; }
:root[data-theme="light"] .theme-icon-dark { display: none; }
:root[data-theme="light"] .theme-icon-light { display: block; }
.nav-scrim { display: none; }
.main-content { width: 100%; max-width: 1280px; margin: 0 auto; padding: 32px 24px 72px; }
.main-content { width: 100%; max-width: 2240px; margin: 0 auto; padding: clamp(28px, 2.2vw, 48px) clamp(22px, 2.4vw, 54px) 80px; }
.page-header { display: flex; align-items: flex-end; justify-content: space-between; gap: 26px; margin-bottom: 28px; }
.page-header h1, .job-hero h1 { margin: 0; font-size: clamp(30px, 3.2vw, 42px); line-height: 1.08; letter-spacing: -.035em; }
@@ -589,3 +592,92 @@ textarea { resize: vertical; }
.result-body { padding: 17px; }
.result-actions { padding: 0 17px 17px; }
}
/* VR-206: premium, title-led IT radar and genuinely adaptive widescreen composition. */
.jobs-page-header { align-items: center; }
.jobs-page-header .radar-status strong { color: var(--primary-strong); }
.premium-filter-console {
grid-template-columns: minmax(220px, 1fr) repeat(5, minmax(112px, 145px)) auto;
gap: 12px;
padding: 18px;
background:
linear-gradient(135deg, color-mix(in srgb, var(--primary) 4%, transparent), transparent 45%),
var(--surface);
box-shadow: 0 16px 42px rgba(0, 0, 0, .1);
}
.premium-filter-console select,
.premium-filter-console input { background: color-mix(in srgb, var(--surface-low) 94%, transparent); }
.results-layout { display: grid; grid-template-columns: minmax(0, 1fr); gap: 28px; align-items: start; }
.results-column { min-width: 0; }
.jobs-insight-rail { display: none; }
.result-card {
background:
linear-gradient(135deg, color-mix(in srgb, var(--primary) 2%, transparent), transparent 48%),
var(--surface);
box-shadow: 0 12px 32px rgba(0, 0, 0, .09);
}
.result-card:hover { box-shadow: 0 18px 42px rgba(0, 0, 0, .14); }
.result-heading h2 { font-size: clamp(20px, 1.3vw, 25px); letter-spacing: -.025em; }
.result-heading > div:first-child { min-width: 0; }
.score-block strong { font-size: 32px; }
.score-block small { max-width: 110px; text-align: right; line-height: 1.35; }
.score-pending strong { color: var(--muted); }
.pill-it { color: var(--primary-strong); border-color: color-mix(in srgb, var(--primary) 30%, var(--border)); background: var(--primary-soft); }
.evidence-line { display: flex; align-items: flex-start; gap: 9px; margin: 0; padding: 10px 12px; border-radius: var(--radius-sm); font-size: 13px; line-height: 1.45; }
.evidence-line .icon { width: 17px; height: 17px; margin-top: 1px; }
.positive-evidence { border-left: 2px solid var(--positive); background: var(--positive-soft); color: var(--text-soft); }
.positive-evidence .icon { color: var(--positive); }
.concern-evidence { border-left: 2px solid var(--warning); background: var(--warning-soft); color: var(--text-soft); }
.concern-evidence > span:first-child { color: var(--warning); font-weight: 900; }
.result-actions { align-content: center; gap: 9px; }
.result-actions form { margin: 0; }
.result-actions .button { width: 100%; }
.profile-signal-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 9px; margin: 16px 0; }
.profile-signal-grid > div { display: grid; gap: 3px; padding: 11px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface-low); }
.profile-signal-grid span { color: var(--muted); font: 9px ui-monospace, SFMono-Regular, Consolas, monospace; text-transform: uppercase; letter-spacing: .08em; }
.profile-signal-grid strong { font-size: 18px; }
.radar-steps { display: grid; gap: 0; margin: 14px 0 0; padding: 0; list-style: none; counter-reset: radar-step; }
.radar-steps li { position: relative; display: grid; gap: 2px; padding: 12px 0 12px 34px; border-top: 1px solid var(--border); counter-increment: radar-step; }
.radar-steps li::before { content: counter(radar-step); position: absolute; left: 0; top: 12px; width: 22px; height: 22px; display: grid; place-items: center; border: 1px solid color-mix(in srgb, var(--primary) 30%, var(--border)); border-radius: 50%; color: var(--primary-strong); font: 10px ui-monospace, SFMono-Regular, Consolas, monospace; }
.radar-steps span { color: var(--muted); font-size: 12px; }
.job-hero .title-row { margin-bottom: 12px; }
.detail-main .panel { box-shadow: 0 12px 38px rgba(0, 0, 0, .08); }
@media (min-width: 1350px) {
.results-layout { grid-template-columns: minmax(0, 1fr) 320px; }
.jobs-insight-rail { position: sticky; top: calc(var(--utility) + 20px); display: block; }
}
@media (min-width: 1850px) {
.utilitybar { padding-inline: clamp(30px, 2.5vw, 60px); }
.global-search { width: min(620px, 42vw); }
.dashboard-grid { grid-template-columns: minmax(0, 1fr) 360px; gap: 34px; }
.dashboard-grid .job-feed { grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: start; }
.dashboard-grid .section-heading { grid-column: 1 / -1; }
.results-layout { grid-template-columns: minmax(0, 1fr) 360px; gap: 34px; }
.results-feed { grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: start; }
.result-card { height: 100%; grid-template-columns: 4px minmax(0, 1fr); }
.result-actions { grid-column: 2; grid-template-columns: minmax(0, 1fr) auto; padding: 0 22px 20px; border: 0; background: transparent; }
.result-actions .button-ghost { width: auto; }
.detail-layout { grid-template-columns: minmax(0, 1fr) 380px; gap: 34px; }
.health-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.editor-layout { grid-template-columns: minmax(0, 1fr) 360px; gap: 34px; }
}
@media (max-width: 1180px) {
.premium-filter-console { grid-template-columns: minmax(0, 1fr) repeat(2, minmax(145px, .35fr)); }
.premium-filter-console .button { grid-column: 1 / -1; }
}
@media (max-width: 680px) {
body { font-size: 15px; }
.section-heading { align-items: flex-start; flex-direction: column; gap: 10px; }
.section-heading > .pill { align-self: flex-start; }
.jobs-page-header { align-items: stretch; }
.premium-filter-console { grid-template-columns: 1fr; padding: 14px; }
.premium-filter-console .button { grid-column: auto; }
.result-actions { gap: 8px; }
.result-actions form { width: 100%; }
.result-heading h2 { font-size: 20px; }
.score-block small { max-width: none; text-align: left; }
}
+3 -3
View File
@@ -17,7 +17,7 @@
<section class="stats-grid" aria-label="Kerncijfers">
<article class="stat-card signal"><span class="kpi-label">Nieuwe signalen</span><strong>{{ new_today }}</strong><small>vandaag gevonden</small></article>
<article class="stat-card"><span class="kpi-label">Actieve vacatures</span><strong>{{ active_jobs }}</strong><small>actueel in de radar</small></article>
<article class="stat-card"><span class="kpi-label">Actieve IT-vacatures</span><strong>{{ active_jobs }}</strong><small>niet-IT blijft uit beeld</small></article>
<article class="stat-card"><span class="kpi-label">Open dossiers</span><strong>{{ applications_open }}</strong><small>sollicitaties in opvolging</small></article>
<article class="stat-card"><span class="kpi-label">Bronstatus</span><strong>{{ source_counts.total|default:0 }}</strong><small>{{ source_counts.unhealthy|default:0 }} vragen aandacht</small></article>
</section>
@@ -34,7 +34,7 @@
<div class="job-card-main">
<div class="job-card-head">
<div>
<div class="title-row"><span class="pill pill-{{ score.recommendation }}">{{ score.get_recommendation_display }}</span><span class="confidence-label">Datakwaliteit {{ score.confidence|floatformat:2 }}</span></div>
{% widthratio score.confidence 1 100 as confidence_percent %}<div class="title-row"><span class="pill pill-{{ score.recommendation }}">{{ score.get_recommendation_display }}</span><span class="confidence-label">Datakwaliteit {{ confidence_percent }}%</span></div>
<h3><a href="{% url 'jobs:detail' score.job.pk %}">{{ score.job.original_title }}</a></h3>
<p>{{ score.job.employer_name }}{% if score.job.raw_location %} · {{ score.job.raw_location }}{% endif %}</p>
</div>
@@ -56,7 +56,7 @@
</section>
<aside class="dashboard-rail" aria-label="Acties en radarstatus">
<section class="panel rail-panel radar-summary"><span class="section-kicker">Vandaag</span><h2>Radarstatus</h2><p class="muted">Alle cijfers komen rechtstreeks uit de huidige database.</p><div class="rail-list"><div class="rail-item"><span>Bronnen geregistreerd</span><strong>{{ source_counts.total|default:0 }}</strong></div><div class="rail-item"><span>Aandacht nodig</span><strong>{{ source_counts.unhealthy|default:0 }}</strong></div><div class="rail-item"><span>Actieve vacatures</span><strong>{{ active_jobs }}</strong></div></div></section>
<section class="panel rail-panel radar-summary"><span class="section-kicker">Vandaag</span><h2>Radarstatus</h2><p class="muted">Alle cijfers komen rechtstreeks uit de huidige database.</p><div class="rail-list"><div class="rail-item"><span>Bronnen geregistreerd</span><strong>{{ source_counts.total|default:0 }}</strong></div><div class="rail-item"><span>Aandacht nodig</span><strong>{{ source_counts.unhealthy|default:0 }}</strong></div><div class="rail-item"><span>Actieve IT-vacatures</span><strong>{{ active_jobs }}</strong></div><div class="rail-item"><span>Niet-IT gefilterd</span><strong>{{ filtered_non_it_jobs }}</strong></div></div></section>
<section class="panel rail-panel"><span class="section-kicker">Actie vereist</span><h2>Open opvolging</h2><div class="rail-application-list">{% for application in priority_applications %}<a class="rail-application" href="{% url 'jobs:application-edit' application.pk %}"><span><strong>{{ application.job.original_title }}</strong><small>{{ application.job.employer_name }}</small></span>{% if application.follow_up_date %}<time datetime="{{ application.follow_up_date|date:'Y-m-d' }}">{{ application.follow_up_date|date:"d/m" }}</time>{% else %}<svg class="icon"><use href="#icon-arrow"></use></svg>{% endif %}</a>{% empty %}<p class="muted small">Geen open dossiers die opvolging vragen.</p>{% endfor %}</div><a class="button button-ghost button-block" href="{% url 'jobs:applications' %}">Pipeline openen</a></section>
<section class="panel rail-panel"><span class="section-kicker">Volgende stap</span><h2>Snelle acties</h2><div class="stack-actions"><a class="button button-secondary button-block" href="{% url 'sources:list' %}"><svg class="icon"><use href="#icon-upload"></use></svg>Handmatig importeren</a><a class="button button-ghost button-block" href="{% url 'profiles:list' %}"><svg class="icon"><use href="#icon-profile"></use></svg>Zoekprofiel controleren</a></div></section>
</aside>
+4 -4
View File
@@ -5,19 +5,19 @@
<div class="detail-layout">
<div class="detail-main">
<header class="job-hero">
<div><p class="eyebrow">{{ job.get_status_display }}{% if job.date_posted %} · gepubliceerd {{ job.date_posted|date:"d/m/Y" }}{% endif %}</p><h1>{{ job.original_title }}</h1><p class="hero-subtitle">{{ job.employer_name }}{% if job.raw_location %} · {{ job.raw_location }}{% endif %}</p><div class="job-meta"><span><svg class="icon"><use href="#icon-briefcase"></use></svg>{{ job.get_workplace_type_display }}</span>{% for type in job.employment_types %}<span>{{ type }}</span>{% endfor %}{% if job.hours_text %}<span>{{ job.hours_text }}</span>{% endif %}{% if job.valid_through %}<span><svg class="icon"><use href="#icon-clock"></use></svg>sluit {{ job.valid_through|date:"d/m/Y" }}</span>{% endif %}</div></div>
{% if score %}<div class="hero-score"><strong>{{ score.score|floatformat:0 }}%</strong><span>{{ score.get_recommendation_display }}</span><small>confidence {{ score.confidence|floatformat:2 }}</small></div>{% endif %}
<div><p class="eyebrow">{{ job.get_status_display }}{% if job.date_posted %} · gepubliceerd {{ job.date_posted|date:"d/m/Y" }}{% endif %}</p><div class="title-row">{% if score.evidence.it_relevance.relevant %}<span class="pill pill-it">IT-focus bevestigd</span>{% endif %}{% if job.direct_employer %}<span class="pill pill-active">Directe werkgever</span>{% endif %}</div><h1>{{ job.original_title }}</h1><p class="hero-subtitle">{{ job.employer_name }}{% if job.raw_location %} · {{ job.raw_location }}{% endif %}</p><div class="job-meta"><span><svg class="icon"><use href="#icon-briefcase"></use></svg>{{ job.get_workplace_type_display }}</span>{% for type in job.employment_types %}<span>{{ type }}</span>{% endfor %}{% if job.hours_text %}<span>{{ job.hours_text }}</span>{% endif %}{% if job.valid_through %}<span><svg class="icon"><use href="#icon-clock"></use></svg>sluit {{ job.valid_through|date:"d/m/Y" }}</span>{% endif %}</div></div>
{% if score %}{% widthratio score.confidence 1 100 as score_confidence_percent %}<div class="hero-score"><strong>{{ score.score|floatformat:0 }}%</strong><span>{{ score.get_recommendation_display }}</span><small>Datakwaliteit {{ score_confidence_percent }}%</small></div>{% endif %}
</header>
<section class="intelligence-strip" aria-label="Vacaturekerngegevens">
<div><span class="field-label">Matchkwaliteit</span>{% if score %}<strong>{{ score.score|floatformat:0 }}%</strong><small>{{ score.get_recommendation_display }}</small>{% else %}<strong></strong><small>Nog niet beoordeeld</small>{% endif %}</div>
<div><span class="field-label">Datakwaliteit</span><strong>{{ job.extraction_confidence|floatformat:2 }}</strong><small>Extractieconfidence</small></div>
{% widthratio job.extraction_confidence 1 100 as extraction_confidence_percent %}<div><span class="field-label">Datakwaliteit</span><strong>{{ extraction_confidence_percent }}%</strong><small>Extractiezekerheid</small></div>
<div><span class="field-label">Werkmodel</span><strong>{{ job.get_workplace_type_display }}</strong><small>{{ job.hours_text|default:"Uren onbekend" }}</small></div>
</section>
{% if score %}
<section class="panel"><div class="section-heading"><div><span class="section-kicker">Deterministische analyse</span><h2>Waarom deze score?</h2><p>De inhoudelijke match en betrouwbaarheid worden afzonderlijk beoordeeld.</p></div><span class="pill pill-{{ score.recommendation }}">{{ score.get_recommendation_display }}</span></div><div class="score-components">{% for name,value in score.components.items %}<div><span>{{ name|capfirst }}</span><div class="progress" aria-label="{{ name }}: {{ value|floatformat:1 }} procent"><i style="width: {{ value }}%"></i></div><strong>{{ value|floatformat:1 }}</strong></div>{% endfor %}</div><div class="two-columns"><div><h3>Waarom passend</h3><ul class="compact-list positive">{% for item in score.positives %}<li>{{ item }}</li>{% empty %}<li>Nog onvoldoende positieve signalen.</li>{% endfor %}</ul></div><div><h3>Aandachtspunten</h3><ul class="compact-list caution">{% for item in score.concerns %}<li>{{ item }}</li>{% empty %}<li>Geen specifieke aandachtspunten gedetecteerd.</li>{% endfor %}</ul></div></div>{% if score.hard_exclusions %}<div class="message warning"><strong>Harde uitsluitingen:</strong> {{ score.hard_exclusions|join:", " }}</div>{% endif %}</section>
{% else %}<section class="panel panel-subtle"><h2>Nog geen persoonlijke score</h2><p class="muted">Deze vacature heeft nog geen score voor je actieve zoekprofiel. De broninformatie en vacaturetekst blijven wel beschikbaar.</p></section>{% endif %}
<section class="panel"><span class="section-kicker">Vacaturetekst</span><h2>Over de functie</h2>{% if job.description_html_sanitized %}<div class="prose">{{ job.description_html_sanitized|safe }}</div>{% elif job.description_text %}<div class="prose"><p>{{ job.description_text|linebreaksbr }}</p></div>{% else %}<p class="muted">Geen vacatureomschrijving beschikbaar.</p>{% endif %}</section>
<section class="panel"><span class="section-kicker">Transparantie</span><h2>Bron en historie</h2><dl class="definition-grid"><div><dt>Eerst gezien</dt><dd>{{ job.first_seen|date:"d/m/Y H:i" }}</dd></div><div><dt>Laatst gezien</dt><dd>{{ job.last_seen|date:"d/m/Y H:i" }}</dd></div><div><dt>Laatst gewijzigd</dt><dd>{{ job.last_changed|date:"d/m/Y H:i" }}</dd></div><div><dt>Extractieconfidence</dt><dd>{{ job.extraction_confidence|floatformat:2 }}</dd></div></dl><h3>Ook gevonden via</h3><ul class="source-list">{% for alias in job.source_aliases.all %}<li><a href="{{ alias.url }}" target="_blank" rel="noopener noreferrer nofollow">{{ alias.source.name|default:"Onbekende bron" }} <span class="sr-only">(opent in nieuw tabblad)</span></a><span>{{ alias.extraction_method }} · confidence {{ alias.extraction_confidence|floatformat:2 }}</span></li>{% empty %}<li>Geen bronalias beschikbaar.</li>{% endfor %}</ul></section>
<section class="panel"><span class="section-kicker">Transparantie</span><h2>Bron en historie</h2><dl class="definition-grid"><div><dt>Eerst gezien</dt><dd>{{ job.first_seen|date:"d/m/Y H:i" }}</dd></div><div><dt>Laatst gezien</dt><dd>{{ job.last_seen|date:"d/m/Y H:i" }}</dd></div><div><dt>Laatst gewijzigd</dt><dd>{{ job.last_changed|date:"d/m/Y H:i" }}</dd></div><div><dt>Extractiezekerheid</dt><dd>{{ extraction_confidence_percent }}%</dd></div></dl><h3>Ook gevonden via</h3><ul class="source-list">{% for alias in job.source_aliases.all %}{% widthratio alias.extraction_confidence 1 100 as alias_confidence_percent %}<li><a href="{{ alias.url }}" target="_blank" rel="noopener noreferrer nofollow">{{ alias.source.name|default:"Onbekende bron" }} <span class="sr-only">(opent in nieuw tabblad)</span></a><span>{{ alias.extraction_method }} · datakwaliteit {{ alias_confidence_percent }}%</span></li>{% empty %}<li>Geen bronalias beschikbaar.</li>{% endfor %}</ul></section>
</div>
<aside class="detail-sidebar"><div class="sticky-card"><span class="section-kicker">Persoonlijke actie</span><h2>Volgende stap</h2><p>VacatureRadar verstuurt nooit automatisch een sollicitatie.</p><div class="stack-actions"><form method="post" action="{% url 'jobs:feedback' job.pk %}">{% csrf_token %}<input type="hidden" name="action" value="interesting"><button class="button button-primary button-block" type="submit">Interessant</button></form><form method="post" action="{% url 'jobs:feedback' job.pk %}">{% csrf_token %}<input type="hidden" name="action" value="save"><button class="button button-secondary button-block" type="submit"><svg class="icon"><use href="#icon-bookmark"></use></svg>Bewaren</button></form><form method="post" action="{% url 'jobs:feedback' job.pk %}">{% csrf_token %}<input type="hidden" name="action" value="applied"><button class="button button-secondary button-block" type="submit">Als gesolliciteerd markeren</button></form><form method="post" action="{% url 'jobs:feedback' job.pk %}" class="hide-form">{% csrf_token %}<input type="hidden" name="action" value="hide"><label>Reden (optioneel)<select name="reason"><option value="">Geen reden</option><option>Te ver</option><option>Te veel support</option><option>Recruiter/consultancy</option><option>Onrealistische senioriteit</option><option>Niet mijn technologie</option></select></label><button class="button button-ghost button-block" type="submit">Verbergen</button></form></div>{% if job.canonical_url %}<a class="button button-link button-block" href="{{ job.canonical_url }}" target="_blank" rel="noopener noreferrer nofollow">Originele vacature <svg class="icon"><use href="#icon-external"></use></svg><span class="sr-only">(opent in nieuw tabblad)</span></a>{% endif %}{% if application %}<hr><p class="muted">Dossierstatus: <strong>{{ application.get_status_display }}</strong></p><a class="button button-ghost button-block" href="{% url 'jobs:application-edit' application.pk %}">Dossier bewerken</a>{% endif %}</div></aside>
</div>
+45 -13
View File
@@ -1,36 +1,68 @@
{% extends "base.html" %}
{% block title %}Vacatures · VacatureRadar{% endblock %}
{% block content %}
<header class="page-header">
<div><p class="eyebrow">Nieuwe matches & archief</p><h1>Vacature-intelligentie</h1><p>Doorzoek actuele en gearchiveerde vacatures met score, herkomst en status in één scanbare feed.</p></div>
<header class="page-header jobs-page-header">
<div>
<p class="eyebrow">Nieuwe matches & archief</p>
<h1>Vacature-intelligentie</h1>
<p>Een rustige IT-radar: eerst de aantoonbaar relevante functies, gerangschikt op persoonlijke match.</p>
</div>
<div class="radar-status" aria-label="Actieve relevantiefilter">
<span class="signal-dot" aria-hidden="true"></span>
<span>{% if current_focus == 'all' %}Volledig bronarchief{% else %}IT-focus actief{% endif %}</span>
<span aria-hidden="true">·</span>
<strong>{{ it_job_count }} IT-signalen</strong>
</div>
</header>
<form method="get" class="filter-console">
<form method="get" class="filter-console premium-filter-console">
<label class="grow"><span class="field-label">Zoeken</span><span class="input-with-icon"><svg class="icon"><use href="#icon-search"></use></svg><input type="search" name="q" value="{{ request.GET.q }}" placeholder="Titel, werkgever, locatie of skill"></span></label>
<label><span class="field-label">Relevantie</span><select name="focus"><option value="it" {% if current_focus != 'all' %}selected{% endif %}>Alleen IT</option><option value="all" {% if current_focus == 'all' %}selected{% endif %}>Volledig archief</option></select></label>
<label><span class="field-label">Match</span><select name="match"><option value="radar" {% if current_match == 'radar' %}selected{% endif %}>Radarselectie</option><option value="strong" {% if current_match == 'strong' %}selected{% endif %}>Sterke match</option><option value="possible" {% if current_match == 'possible' %}selected{% endif %}>Mogelijke match</option><option value="weak" {% if current_match == 'weak' %}selected{% endif %}>Lage match</option><option value="hidden" {% if current_match == 'hidden' %}selected{% endif %}>Verborgen</option><option value="all" {% if current_match == 'all' %}selected{% endif %}>Alle IT-scores</option></select></label>
<label><span class="field-label">Status</span><select name="status"><option value="">Alle statussen</option>{% for value,label in view.model.Status.choices %}<option value="{{ value }}" {% if request.GET.status|default:'active' == value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></label>
<label><span class="field-label">Werkmodel</span><select name="workplace"><option value="">Elk werkmodel</option>{% for value,label in view.model.Workplace.choices %}<option value="{{ value }}" {% if request.GET.workplace == value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></label>
<label><span class="field-label">Sortering</span><select name="sort"><option value="match" {% if current_sort == 'match' %}selected{% endif %}>Beste match</option><option value="newest" {% if current_sort == 'newest' %}selected{% endif %}>Nieuwste eerst</option><option value="closing" {% if current_sort == 'closing' %}selected{% endif %}>Sluitingsdatum</option></select></label>
<button class="button button-primary" type="submit"><svg class="icon"><use href="#icon-search"></use></svg>Filters toepassen</button>
</form>
<div class="results-toolbar"><div><span class="section-kicker">Resultaten</span><h2>{{ page_obj.paginator.count }} vacatures gevonden</h2></div><span class="view-indicator"><svg class="icon"><use href="#icon-pipeline"></use></svg>Lijstweergave</span></div>
<div class="results-feed">
{% for job in jobs %}
<div class="results-layout">
<section class="results-column" aria-labelledby="results-heading">
<div class="results-toolbar">
<div><span class="section-kicker">Gerangschikte resultaten</span><h2 id="results-heading">{{ page_obj.paginator.count }} vacatures gevonden</h2></div>
<span class="view-indicator"><svg class="icon"><use href="#icon-radar"></use></svg>{% if current_sort == 'newest' %}Nieuwste eerst{% elif current_sort == 'closing' %}Deadline eerst{% else %}Matchvolgorde{% endif %}</span>
</div>
<div class="results-feed">
{% for job in jobs %}
{% widthratio job.match_confidence 1 100 as confidence_percent %}
<article class="result-card {% if job.match_recommendation == 'strong' %}strong-match{% endif %}">
<div class="result-accent" aria-hidden="true"></div>
<div class="result-body">
<div class="result-heading">
<div>
<div class="title-row"><span class="pill pill-{{ job.status }}">{{ job.get_status_display }}</span>{% if job.match_recommendation %}<span class="pill pill-{{ job.match_recommendation }}">{% if job.match_recommendation == 'strong' %}Sterke match{% elif job.match_recommendation == 'possible' %}Mogelijke match{% elif job.match_recommendation == 'weak' %}Lage match{% else %}Verborgen{% endif %}</span>{% endif %}</div>
<div class="title-row"><span class="pill pill-{{ job.status }}">{{ job.get_status_display }}</span>{% if job.match_recommendation %}<span class="pill pill-{{ job.match_recommendation }}">{% if job.match_recommendation == 'strong' %}Sterke match{% elif job.match_recommendation == 'possible' %}Mogelijke match{% elif job.match_recommendation == 'weak' %}Lage match{% else %}Verborgen{% endif %}</span>{% endif %}{% if current_focus != 'all' %}<span class="pill pill-it">IT-focus</span>{% endif %}</div>
<h2><a href="{% url 'jobs:detail' job.pk %}">{{ job.original_title }}</a></h2>
<p>{{ job.employer_name }}</p>
</div>
{% if job.match_score is not None %}<div class="score-block"><strong>{{ job.match_score|floatformat:0 }}%</strong><small>confidence {{ job.match_confidence|floatformat:2 }}</small></div>{% endif %}
{% if job.match_score is not None %}<div class="score-block"><strong>{{ job.match_score|floatformat:0 }}%</strong><small>Datakwaliteit {{ confidence_percent }}%</small></div>{% else %}<div class="score-block score-pending"><strong></strong><small>Nog niet gescoord</small></div>{% endif %}
</div>
<div class="job-meta"><span><svg class="icon"><use href="#icon-location"></use></svg>{{ job.raw_location|default:"Locatie onbekend" }}</span><span><svg class="icon"><use href="#icon-briefcase"></use></svg>{{ job.get_workplace_type_display }}</span><span><svg class="icon"><use href="#icon-clock"></use></svg>gevonden {{ job.first_seen|date:"d/m/Y" }}</span>{% if job.direct_employer %}<span>Directe werkgever</span>{% endif %}</div>
{% if job.skills_required %}<p class="result-summary"><strong>Herkenbare skills:</strong> {{ job.skills_required|slice:":4"|join:", " }}</p>{% elif job.description_text %}<p class="result-summary">{{ job.description_text|truncatewords:24 }}</p>{% endif %}
<div class="job-meta"><span><svg class="icon"><use href="#icon-location"></use></svg>{{ job.raw_location|default:"Locatie onbekend" }}</span><span><svg class="icon"><use href="#icon-briefcase"></use></svg>{{ job.get_workplace_type_display }}</span><span><svg class="icon"><use href="#icon-clock"></use></svg>{{ job.first_seen|date:"d/m/Y" }}</span>{% if job.direct_employer %}<span>Directe werkgever</span>{% endif %}</div>
{% if job.match_positives.0 %}<p class="evidence-line positive-evidence"><svg class="icon"><use href="#icon-radar"></use></svg><span>{{ job.match_positives.0 }}</span></p>{% elif job.skills_required %}<p class="result-summary"><strong>Herkenbare skills:</strong> {{ job.skills_required|slice:":4"|join:", " }}</p>{% elif job.description_text %}<p class="result-summary">{{ job.description_text|truncatewords:24 }}</p>{% endif %}
{% if job.match_concerns.0 %}<p class="evidence-line concern-evidence"><span aria-hidden="true">!</span><span>{{ job.match_concerns.0 }}</span></p>{% endif %}
</div>
<div class="result-actions">
<a class="button button-primary" href="{% url 'jobs:detail' job.pk %}">Bekijk analyse <svg class="icon"><use href="#icon-arrow"></use></svg></a>
<form method="post" action="{% url 'jobs:feedback' job.pk %}">{% csrf_token %}<input type="hidden" name="action" value="save"><input type="hidden" name="next" value="{{ request.get_full_path }}"><button class="button button-ghost" type="submit"><svg class="icon"><use href="#icon-bookmark"></use></svg>Bewaren</button></form>
</div>
<div class="result-actions"><a class="button button-primary" href="{% url 'jobs:detail' job.pk %}">Bekijk analyse <svg class="icon"><use href="#icon-arrow"></use></svg></a></div>
</article>
{% empty %}<div class="empty-state compact-empty"><div class="empty-icon"><svg class="icon"><use href="#icon-search"></use></svg></div><h2>Geen vacatures gevonden</h2><p>Pas de zoekterm of filters aan. Gearchiveerde resultaten verschijnen wanneer je de statusfilter leegmaakt.</p></div>{% endfor %}
{% empty %}<div class="empty-state compact-empty"><div class="empty-icon"><svg class="icon"><use href="#icon-search"></use></svg></div><h2>Geen passende vacatures gevonden</h2><p>Pas de filters aan of open het volledige bronarchief. Niet-IT-vacatures en harde uitsluitingen blijven standaard bewust uit je radar.</p><a class="button button-secondary" href="?focus=all&match=all&status=active&sort=match">Volledig archief bekijken</a></div>{% endfor %}
</div>
{% if is_paginated %}<nav class="pagination" aria-label="Paginering">{% if page_obj.has_previous %}<a class="button button-ghost" href="?page={{ page_obj.previous_page_number }}&q={{ request.GET.q }}&status={{ request.GET.status }}&workplace={{ request.GET.workplace }}&focus={{ current_focus }}&match={{ current_match }}&sort={{ current_sort }}">Vorige</a>{% endif %}<span>Pagina {{ page_obj.number }} van {{ page_obj.paginator.num_pages }}</span>{% if page_obj.has_next %}<a class="button button-ghost" href="?page={{ page_obj.next_page_number }}&q={{ request.GET.q }}&status={{ request.GET.status }}&workplace={{ request.GET.workplace }}&focus={{ current_focus }}&match={{ current_match }}&sort={{ current_sort }}">Volgende</a>{% endif %}</nav>{% endif %}
</section>
<aside class="jobs-insight-rail" aria-label="Radarcontext">
<section class="panel rail-panel radar-summary"><span class="section-kicker">Radarbereik</span><h2>IT-focus</h2><p class="muted">Titels krijgen eerst een deterministische sectorcontrole. AI kan deze grens niet wijzigen.</p><div class="rail-list"><div class="rail-item"><span>Actieve IT-vacatures</span><strong>{{ it_job_count }}</strong></div><div class="rail-item"><span>Niet-IT uit beeld</span><strong>{{ filtered_non_it_count }}</strong></div><div class="rail-item"><span>Volledig actief archief</span><strong>{{ active_job_count }}</strong></div></div><a class="button button-ghost button-block" href="?focus={% if current_focus == 'all' %}it{% else %}all{% endif %}&match={% if current_focus == 'all' %}radar{% else %}all{% endif %}&status=active&sort=match">{% if current_focus == 'all' %}Terug naar IT-focus{% else %}Volledig archief openen{% endif %}</a></section>
<section class="panel rail-panel"><span class="section-kicker">Actief zoekprofiel</span><h2>{{ active_profile.name|default:"Geen profiel actief" }}</h2>{% if active_profile %}<div class="profile-signal-grid"><div><span>Rollen</span><strong>{{ active_profile.desired_titles|length }}</strong></div><div><span>Skills</span><strong>{{ active_profile.desired_skills|length }}</strong></div><div><span>Radius</span><strong>{{ active_profile.max_distance_km }} km</strong></div><div><span>Vanaf</span><strong>{{ active_profile.recommendation_threshold }}%</strong></div></div><a class="button button-secondary button-block" href="{% url 'profiles:edit' active_profile.pk %}">Profiel verfijnen</a>{% else %}<p class="muted">Activeer een zoekprofiel om persoonlijke scores te berekenen.</p>{% endif %}</section>
<section class="panel rail-panel"><span class="section-kicker">Hoe rangschikking werkt</span><h2>Deterministisch eerst</h2><ol class="radar-steps"><li><strong>IT-titel</strong><span>Sectorfilter op functietitel.</span></li><li><strong>Harde regels</strong><span>Afstand en uitsluitingen winnen altijd.</span></li><li><strong>Matchscore</strong><span>Rollen, skills en voorkeuren bepalen de volgorde.</span></li></ol></section>
</aside>
</div>
{% if is_paginated %}<nav class="pagination" aria-label="Paginering">{% if page_obj.has_previous %}<a class="button button-ghost" href="?page={{ page_obj.previous_page_number }}&q={{ request.GET.q }}&status={{ request.GET.status }}&workplace={{ request.GET.workplace }}">Vorige</a>{% endif %}<span>Pagina {{ page_obj.number }} van {{ page_obj.paginator.num_pages }}</span>{% if page_obj.has_next %}<a class="button button-ghost" href="?page={{ page_obj.next_page_number }}&q={{ request.GET.q }}&status={{ request.GET.status }}&workplace={{ request.GET.workplace }}">Volgende</a>{% endif %}</nav>{% endif %}
{% endblock %}
@@ -159,6 +159,15 @@ def test_job_profile_source_and_application_views(client, user, profile, job, so
assert response.status_code == 302
assert Feedback.objects.filter(user=user, job=job).exists()
unsafe_redirect = client.post(
reverse("jobs:feedback", kwargs={"pk": job.pk}),
{
"action": Feedback.Action.INTERESTING,
"next": "https://malicious.example/steal-session",
},
)
assert unsafe_redirect.url == reverse("jobs:detail", kwargs={"pk": job.pk})
assert client.get(reverse("profiles:list")).status_code == 200
assert client.get(reverse("profiles:edit", kwargs={"pk": profile.pk})).status_code == 200
assert client.get(reverse("sources:list")).status_code == 200
+135
View File
@@ -2,6 +2,9 @@ import pytest
from django.core.cache import cache
from django.test import override_settings
from django.urls import reverse
from django.utils import timezone
from apps.jobs.models import JobPosting, ScoreRun
@pytest.mark.integration
@@ -12,6 +15,14 @@ def test_today_requires_login(client):
assert reverse("login") in response.url
@pytest.mark.integration
@pytest.mark.django_db
def test_browser_favicon_fallback_uses_local_svg(client):
response = client.get("/favicon.ico")
assert response.status_code == 301
assert response.url.endswith("/static/favicon.svg")
@pytest.mark.integration
@pytest.mark.django_db
def test_today_loads_for_user(client, user, profile):
@@ -21,6 +32,130 @@ def test_today_loads_for_user(client, user, profile):
assert "Vandaag" in response.content.decode()
@pytest.mark.integration
@pytest.mark.django_db
def test_job_list_defaults_to_ranked_it_focus_but_keeps_explicit_archive(
client, user, profile, job
):
non_it = JobPosting.objects.create(
employer=job.employer,
original_title="Spontane sollicitatie - logopedist",
normalized_title="spontane sollicitatie logopedist",
canonical_url="https://jobs.example.org/vacatures/logopedist",
canonical_key="9" * 64,
content_hash="8" * 64,
description_text="Zorgfunctie met toegang tot digitale patiëntendossiers.",
status=JobPosting.Status.ACTIVE,
)
misleading_it_title = JobPosting.objects.create(
employer=job.employer,
original_title="IT Recruitment Specialist",
normalized_title="it recruitment specialist",
canonical_url="https://jobs.example.org/vacatures/it-recruitment",
canonical_key="3" * 64,
content_hash="2" * 64,
status=JobPosting.Status.ACTIVE,
)
ScoreRun.objects.create(
job=job,
profile=profile,
profile_version=profile.version,
score=60,
confidence=0.9,
recommendation=ScoreRun.Recommendation.WEAK,
positives=["IT-focus bevestigd via de functietitel."],
)
ScoreRun.objects.create(
job=non_it,
profile=profile,
profile_version=profile.version,
score=99,
confidence=0.9,
recommendation=ScoreRun.Recommendation.POSSIBLE,
)
hidden_it = JobPosting.objects.create(
employer=job.employer,
original_title="Cloud Engineer buiten de zoekradius",
normalized_title="cloud engineer buiten de zoekradius",
canonical_url="https://jobs.example.org/vacatures/cloud-ver",
canonical_key="5" * 64,
content_hash="4" * 64,
status=JobPosting.Status.ACTIVE,
)
ScoreRun.objects.create(
job=hidden_it,
profile=profile,
profile_version=profile.version,
score=98,
confidence=0.9,
recommendation=ScoreRun.Recommendation.HIDDEN,
hard_exclusions=["Buiten de zoekradius."],
)
client.force_login(user)
focused = client.get(reverse("jobs:list"))
focused_body = focused.content.decode()
assert job.original_title in focused_body
assert non_it.original_title not in focused_body
assert misleading_it_title.original_title not in focused_body
assert hidden_it.original_title not in focused_body
assert focused.context["it_job_count"] == 2
assert focused.context["filtered_non_it_count"] == 2
archive = client.get(reverse("jobs:list"), {"focus": "all", "match": "all"})
archive_body = archive.content.decode()
assert non_it.original_title in archive_body
assert misleading_it_title.original_title in archive_body
assert hidden_it.original_title in archive_body
@pytest.mark.integration
@pytest.mark.django_db
def test_today_uses_latest_user_score_and_excludes_non_it(client, user, profile, job):
non_it = JobPosting.objects.create(
employer=job.employer,
original_title="Technieker Facilitaire Diensten",
normalized_title="technieker facilitaire diensten",
canonical_url="https://jobs.example.org/vacatures/facilitair",
canonical_key="7" * 64,
content_hash="6" * 64,
status=JobPosting.Status.ACTIVE,
first_seen=timezone.now(),
)
ScoreRun.objects.create(
job=job,
profile=profile,
profile_version=profile.version,
score=70,
confidence=0.9,
recommendation=ScoreRun.Recommendation.POSSIBLE,
)
ScoreRun.objects.create(
job=job,
profile=profile,
profile_version=profile.version,
score=66,
confidence=0.9,
recommendation=ScoreRun.Recommendation.POSSIBLE,
)
ScoreRun.objects.create(
job=non_it,
profile=profile,
profile_version=profile.version,
score=95,
confidence=0.9,
recommendation=ScoreRun.Recommendation.STRONG,
)
client.force_login(user)
response = client.get(reverse("dashboard:today"))
cards = list(response.context["score_cards"])
assert [card.job_id for card in cards] == [job.id]
assert cards[0].score == 66
assert response.context["filtered_non_it_jobs"] == 1
@pytest.mark.integration
@pytest.mark.django_db
@override_settings(
+39
View File
@@ -0,0 +1,39 @@
from apps.jobs.services.relevance import assess_it_relevance, profile_requires_it_focus
def test_title_led_it_relevance_accepts_clear_it_roles():
for title in (
"Modern Workplace Engineer",
"Product owner ICT (100%)",
"Functioneel Analist",
"Senior .Net Developer",
"Senior Mobile Developer",
"Deeltijds docent informatica, networking & security",
):
assessment = assess_it_relevance(title)
assert assessment.relevant, title
assert assessment.signals
def test_title_led_it_relevance_rejects_non_it_and_ambiguous_roles():
for title in (
"Technieker Facilitaire Diensten",
"Spontane sollicitatie - logopedist",
"Doctoraatsbursaal trombo-inflammatie bij hemodialyse",
"Stafmedewerker Onderwijs faculteit Rechten",
"Project Manager",
"Business Developer",
"IT Recruitment Specialist",
):
assessment = assess_it_relevance(title)
assert not assessment.relevant, title
assert assessment.reason
def test_active_it_profile_requires_deterministic_it_focus(profile):
assert profile_requires_it_focus(profile)
profile.name = "Algemeen profiel"
profile.desired_titles = ["verpleegkundige"]
profile.desired_skills = []
assert not profile_requires_it_focus(profile)
+12
View File
@@ -63,6 +63,18 @@ def test_hard_title_exclusion_wins(matching_job, profile):
assert any("sales" in reason.lower() for reason in result.hard_exclusions)
def test_non_it_vacancy_is_hidden_for_it_profile(matching_job, profile):
matching_job.original_title = "Spontane sollicitatie - logopedist"
matching_job.normalized_title = "spontane sollicitatie logopedist"
matching_job.description_text = "Werk met het digitale patiëntendossier in ons ziekenhuis."
result = calculate_score(matching_job, profile)
assert result.recommendation == ScoreRun.Recommendation.HIDDEN
assert any("Geen aantoonbaar IT-signaal" in reason for reason in result.hard_exclusions)
assert result.evidence["it_relevance"]["relevant"] is False
def test_distance_boundary_inclusief(matching_job, profile, monkeypatch):
profile.max_distance_km = 50
profile.save(update_fields=["max_distance_km"])