From 029df89265d7d072755f05ad1757380da4f35e15 Mon Sep 17 00:00:00 2001 From: Jens Date: Wed, 22 Jul 2026 15:16:15 +0200 Subject: [PATCH] feat: ship premium IT-focused vacancy radar --- CHANGELOG.md | 16 ++ apps/core/context_processors.py | 2 +- apps/core/views.py | 38 +++- apps/jobs/services/relevance.py | 180 ++++++++++++++++++ apps/jobs/services/scoring.py | 12 ++ apps/jobs/views.py | 93 ++++++++- config/urls.py | 7 + docs/ai/BACKLOG.yaml | 44 +++++ docs/ai/PROJECT_STATE.md | 18 +- docs/design/DESIGN_SYSTEM.md | 5 +- docs/design/STITCH_INTEGRATION_PLAN.md | 13 +- docs/design/UX_SPEC.md | 6 + docs/design/validation/VR-206_AUDIT.md | 65 +++++++ docs/quality/THREAT_MODEL.md | 1 + docs/quality/TRACEABILITY_MATRIX.md | 6 +- static/css/app.css | 98 +++++++++- templates/dashboard/today.html | 6 +- templates/jobs/detail.html | 8 +- templates/jobs/list.html | 78 +++++--- .../integration/test_tasks_and_more_views.py | 9 + tests/integration/test_views.py | 135 +++++++++++++ tests/unit/test_relevance.py | 39 ++++ tests/unit/test_scoring.py | 12 ++ 23 files changed, 838 insertions(+), 53 deletions(-) create mode 100644 apps/jobs/services/relevance.py create mode 100644 docs/design/validation/VR-206_AUDIT.md create mode 100644 tests/unit/test_relevance.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3458797..c1fa1bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/apps/core/context_processors.py b/apps/core/context_processors.py index 2f65284..135ee63 100644 --- a/apps/core/context_processors.py +++ b/apps/core/context_processors.py @@ -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"] = ( diff --git a/apps/core/views.py b/apps/core/views.py index b3d5275..a74f8c2 100644 --- a/apps/core/views.py +++ b/apps/core/views.py @@ -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( diff --git a/apps/jobs/services/relevance.py b/apps/jobs/services/relevance.py new file mode 100644 index 0000000..fe429f7 --- /dev/null +++ b/apps/jobs/services/relevance.py @@ -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"(? 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} + ) diff --git a/apps/jobs/services/scoring.py b/apps/jobs/services/scoring.py index 07b6a1f..3167668 100644 --- a/apps/jobs/services/scoring.py +++ b/apps/jobs/services/scoring.py @@ -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, diff --git a/apps/jobs/views.py b/apps/jobs/views.py index 2fe2670..717f748 100644 --- a/apps/jobs/views.py +++ b/apps/jobs/views.py @@ -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): diff --git a/config/urls.py b/config/urls.py index 5999752..6841b25 100644 --- a/config/urls.py +++ b/config/urls.py @@ -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/", diff --git a/docs/ai/BACKLOG.yaml b/docs/ai/BACKLOG.yaml index 9c55277..349747a 100644 --- a/docs/ai/BACKLOG.yaml +++ b/docs/ai/BACKLOG.yaml @@ -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 diff --git a/docs/ai/PROJECT_STATE.md b/docs/ai/PROJECT_STATE.md index 22a3d51..5e03a68 100644 --- a/docs/ai/PROJECT_STATE.md +++ b/docs/ai/PROJECT_STATE.md @@ -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. diff --git a/docs/design/DESIGN_SYSTEM.md b/docs/design/DESIGN_SYSTEM.md index bb14ff7..5d52821 100644 --- a/docs/design/DESIGN_SYSTEM.md +++ b/docs/design/DESIGN_SYSTEM.md @@ -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 20–24px 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 diff --git a/docs/design/STITCH_INTEGRATION_PLAN.md b/docs/design/STITCH_INTEGRATION_PLAN.md index c024933..5ca8143 100644 --- a/docs/design/STITCH_INTEGRATION_PLAN.md +++ b/docs/design/STITCH_INTEGRATION_PLAN.md @@ -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`. diff --git a/docs/design/UX_SPEC.md b/docs/design/UX_SPEC.md index 97ae463..d3417aa 100644 --- a/docs/design/UX_SPEC.md +++ b/docs/design/UX_SPEC.md @@ -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 diff --git a/docs/design/validation/VR-206_AUDIT.md b/docs/design/validation/VR-206_AUDIT.md new file mode 100644 index 0000000..65cde0d --- /dev/null +++ b/docs/design/validation/VR-206_AUDIT.md @@ -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. diff --git a/docs/quality/THREAT_MODEL.md b/docs/quality/THREAT_MODEL.md index 0c014b1..43cf81a 100644 --- a/docs/quality/THREAT_MODEL.md +++ b/docs/quality/THREAT_MODEL.md @@ -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 diff --git a/docs/quality/TRACEABILITY_MATRIX.md b/docs/quality/TRACEABILITY_MATRIX.md index ebbe32c..794a931 100644 --- a/docs/quality/TRACEABILITY_MATRIX.md +++ b/docs/quality/TRACEABILITY_MATRIX.md @@ -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`. diff --git a/static/css/app.css b/static/css/app.css index 2454e65..01e4250 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -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; } +} diff --git a/templates/dashboard/today.html b/templates/dashboard/today.html index 75fa9c3..c1cb2a5 100644 --- a/templates/dashboard/today.html +++ b/templates/dashboard/today.html @@ -17,7 +17,7 @@
Nieuwe signalen{{ new_today }}vandaag gevonden
-
Actieve vacatures{{ active_jobs }}actueel in de radar
+
Actieve IT-vacatures{{ active_jobs }}niet-IT blijft uit beeld
Open dossiers{{ applications_open }}sollicitaties in opvolging
Bronstatus{{ source_counts.total|default:0 }}{{ source_counts.unhealthy|default:0 }} vragen aandacht
@@ -34,7 +34,7 @@
-
{{ score.get_recommendation_display }}Datakwaliteit {{ score.confidence|floatformat:2 }}
+ {% widthratio score.confidence 1 100 as confidence_percent %}
{{ score.get_recommendation_display }}Datakwaliteit {{ confidence_percent }}%

{{ score.job.original_title }}

{{ score.job.employer_name }}{% if score.job.raw_location %} · {{ score.job.raw_location }}{% endif %}

@@ -56,7 +56,7 @@ diff --git a/templates/jobs/detail.html b/templates/jobs/detail.html index 334f656..9908489 100644 --- a/templates/jobs/detail.html +++ b/templates/jobs/detail.html @@ -5,19 +5,19 @@
-

{{ job.get_status_display }}{% if job.date_posted %} · gepubliceerd {{ job.date_posted|date:"d/m/Y" }}{% endif %}

{{ job.original_title }}

{{ job.employer_name }}{% if job.raw_location %} · {{ job.raw_location }}{% endif %}

{{ job.get_workplace_type_display }}{% for type in job.employment_types %}{{ type }}{% endfor %}{% if job.hours_text %}{{ job.hours_text }}{% endif %}{% if job.valid_through %}sluit {{ job.valid_through|date:"d/m/Y" }}{% endif %}
- {% if score %}
{{ score.score|floatformat:0 }}%{{ score.get_recommendation_display }}confidence {{ score.confidence|floatformat:2 }}
{% endif %} +

{{ job.get_status_display }}{% if job.date_posted %} · gepubliceerd {{ job.date_posted|date:"d/m/Y" }}{% endif %}

{% if score.evidence.it_relevance.relevant %}IT-focus bevestigd{% endif %}{% if job.direct_employer %}Directe werkgever{% endif %}

{{ job.original_title }}

{{ job.employer_name }}{% if job.raw_location %} · {{ job.raw_location }}{% endif %}

{{ job.get_workplace_type_display }}{% for type in job.employment_types %}{{ type }}{% endfor %}{% if job.hours_text %}{{ job.hours_text }}{% endif %}{% if job.valid_through %}sluit {{ job.valid_through|date:"d/m/Y" }}{% endif %}
+ {% if score %}{% widthratio score.confidence 1 100 as score_confidence_percent %}
{{ score.score|floatformat:0 }}%{{ score.get_recommendation_display }}Datakwaliteit {{ score_confidence_percent }}%
{% endif %}
Matchkwaliteit{% if score %}{{ score.score|floatformat:0 }}%{{ score.get_recommendation_display }}{% else %}—Nog niet beoordeeld{% endif %}
-
Datakwaliteit{{ job.extraction_confidence|floatformat:2 }}Extractieconfidence
+ {% widthratio job.extraction_confidence 1 100 as extraction_confidence_percent %}
Datakwaliteit{{ extraction_confidence_percent }}%Extractiezekerheid
Werkmodel{{ job.get_workplace_type_display }}{{ job.hours_text|default:"Uren onbekend" }}
{% if score %}
Deterministische analyse

Waarom deze score?

De inhoudelijke match en betrouwbaarheid worden afzonderlijk beoordeeld.

{{ score.get_recommendation_display }}
{% for name,value in score.components.items %}
{{ name|capfirst }}
{{ value|floatformat:1 }}
{% endfor %}

Waarom passend

    {% for item in score.positives %}
  • {{ item }}
  • {% empty %}
  • Nog onvoldoende positieve signalen.
  • {% endfor %}

Aandachtspunten

    {% for item in score.concerns %}
  • {{ item }}
  • {% empty %}
  • Geen specifieke aandachtspunten gedetecteerd.
  • {% endfor %}
{% if score.hard_exclusions %}
Harde uitsluitingen: {{ score.hard_exclusions|join:", " }}
{% endif %}
{% else %}

Nog geen persoonlijke score

Deze vacature heeft nog geen score voor je actieve zoekprofiel. De broninformatie en vacaturetekst blijven wel beschikbaar.

{% endif %}
Vacaturetekst

Over de functie

{% if job.description_html_sanitized %}
{{ job.description_html_sanitized|safe }}
{% elif job.description_text %}

{{ job.description_text|linebreaksbr }}

{% else %}

Geen vacatureomschrijving beschikbaar.

{% endif %}
-
Transparantie

Bron en historie

Eerst gezien
{{ job.first_seen|date:"d/m/Y H:i" }}
Laatst gezien
{{ job.last_seen|date:"d/m/Y H:i" }}
Laatst gewijzigd
{{ job.last_changed|date:"d/m/Y H:i" }}
Extractieconfidence
{{ job.extraction_confidence|floatformat:2 }}

Ook gevonden via

+
Transparantie

Bron en historie

Eerst gezien
{{ job.first_seen|date:"d/m/Y H:i" }}
Laatst gezien
{{ job.last_seen|date:"d/m/Y H:i" }}
Laatst gewijzigd
{{ job.last_changed|date:"d/m/Y H:i" }}
Extractiezekerheid
{{ extraction_confidence_percent }}%

Ook gevonden via

diff --git a/templates/jobs/list.html b/templates/jobs/list.html index 7dc14c4..38914d0 100644 --- a/templates/jobs/list.html +++ b/templates/jobs/list.html @@ -1,36 +1,68 @@ {% extends "base.html" %} {% block title %}Vacatures · VacatureRadar{% endblock %} {% block content %} -