From 3371455255f149473be1d7f3357c284ab400a48a Mon Sep 17 00:00:00 2001 From: Jens Date: Wed, 22 Jul 2026 19:56:15 +0200 Subject: [PATCH] feat: replace dashboard and vacancy intelligence --- apps/core/views.py | 64 +----------- apps/jobs/services/cockpit.py | 177 +++++++++++++++++++++++++++++++++ apps/jobs/views.py | 75 +++++--------- docs/ai/BACKLOG.yaml | 7 +- static/css/pages.css | 79 +++++++++++++++ static/css/responsive.css | 12 +++ templates/dashboard/today.html | 63 ++++-------- templates/jobs/detail.html | 42 +++++--- templates/jobs/list.html | 85 ++++++---------- 9 files changed, 388 insertions(+), 216 deletions(-) create mode 100644 apps/jobs/services/cockpit.py diff --git a/apps/core/views.py b/apps/core/views.py index a74f8c2..ddf4789 100644 --- a/apps/core/views.py +++ b/apps/core/views.py @@ -4,14 +4,13 @@ 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, OuterRef, Q, Subquery +from django.db.models import Count 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.jobs.models import JobPosting +from apps.jobs.services.cockpit import build_dashboard_cockpit from apps.profiles.models import SearchProfile from apps.sources.models import Source from apps.sources.services.health import collect_source_health @@ -86,61 +85,8 @@ 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( - 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. - seen: set[str] = set() - cards = [] - for score in latest_scores[:250]: - key = str(score.job_id) - if key in seen: - continue - seen.add(key) - 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, - "priority_applications": Application.objects.filter(user=self.request.user) - .exclude(status__in=[Application.Status.REJECTED, Application.Status.WITHDRAWN]) - .select_related("job", "job__employer") - .order_by("follow_up_date", "-updated_at")[:3], - "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( - total=Count("id"), - unhealthy=Count("id", filter=~Q(status=Source.Status.ACTIVE)), - ), - } - ) + context.update(build_dashboard_cockpit(self.request.user, profile)) + context["active_profile"] = profile return context diff --git a/apps/jobs/services/cockpit.py b/apps/jobs/services/cockpit.py new file mode 100644 index 0000000..cde2bff --- /dev/null +++ b/apps/jobs/services/cockpit.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +from typing import Any + +from django.contrib.auth import get_user_model +from django.db.models import ( + CharField, + Count, + DateTimeField, + DecimalField, + F, + JSONField, + OuterRef, + Q, + QuerySet, + Subquery, +) +from django.utils import timezone + +from apps.profiles.models import SearchProfile +from apps.sources.models import Source, SourceRun + +from ..models import Application, ApplicationTimelineEvent, JobPosting, ScoreRun +from .relevance import it_relevance_query + +User = get_user_model() + + +def annotate_latest_profile_score( + queryset: QuerySet[JobPosting], profile: SearchProfile | None +) -> QuerySet[JobPosting]: + """Annotate jobs with one deterministic latest score for the active profile.""" + + if profile is None: + return queryset + latest = ScoreRun.objects.filter(job=OuterRef("pk"), profile=profile).order_by("-created_at") + return queryset.annotate( + match_score=Subquery( + latest.values("score")[:1], + output_field=DecimalField(max_digits=5, decimal_places=2), + ), + match_confidence=Subquery( + latest.values("confidence")[:1], + output_field=DecimalField(max_digits=4, decimal_places=3), + ), + match_recommendation=Subquery( + latest.values("recommendation")[:1], output_field=CharField() + ), + match_positives=Subquery(latest.values("positives")[:1], output_field=JSONField()), + match_concerns=Subquery(latest.values("concerns")[:1], output_field=JSONField()), + match_hard_exclusions=Subquery( + latest.values("hard_exclusions")[:1], output_field=JSONField() + ), + match_scored_at=Subquery(latest.values("created_at")[:1], output_field=DateTimeField()), + ) + + +def _recent_dashboard_activity(user: User) -> list[dict[str, Any]]: + activity: list[dict[str, Any]] = [] + for run in SourceRun.objects.select_related("source").order_by("-started_at")[:4]: + activity.append( + { + "occurred_at": run.started_at, + "kind": "source", + "title": f"Bronrun {run.get_status_display().lower()}", + "detail": run.source.name, + "tone": "positive" if run.status == SourceRun.Status.SUCCESS else "attention", + } + ) + for event in ( + ApplicationTimelineEvent.objects.filter(user=user) + .select_related("application__job") + .order_by("-created_at")[:4] + ): + activity.append( + { + "occurred_at": event.created_at, + "kind": "application", + "title": event.get_event_type_display(), + "detail": event.application.job.original_title, + "tone": "neutral", + } + ) + return sorted(activity, key=lambda item: item["occurred_at"], reverse=True)[:5] + + +def build_dashboard_cockpit(user: User, profile: SearchProfile | None) -> dict[str, Any]: + """Build truthful dashboard metrics and bounded read lists.""" + + active_jobs = JobPosting.objects.filter(status=JobPosting.Status.ACTIVE) + active_it_jobs = active_jobs.filter(it_relevance_query()) + today = timezone.localdate() + latest_scores = ScoreRun.objects.none() + if profile is not None: + 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") + .filter( + profile=profile, + pk=Subquery(latest_score_id), + job__status=JobPosting.Status.ACTIVE, + recommendation__in=[ + ScoreRun.Recommendation.STRONG, + ScoreRun.Recommendation.POSSIBLE, + ], + hard_exclusions=[], + ) + .filter(it_relevance_query("job__")) + ) + + sources = Source.objects.exclude(domain="jobs.example.org", status=Source.Status.DISABLED) + source_counts = sources.aggregate( + total=Count("id"), + active=Count("id", filter=Q(status=Source.Status.ACTIVE)), + attention=Count("id", filter=~Q(status=Source.Status.ACTIVE)), + ) + open_applications = Application.objects.filter(user=user).exclude( + status__in=[Application.Status.REJECTED, Application.Status.WITHDRAWN] + ) + new_today = active_it_jobs.filter(first_seen__date=today).count() + strong_today = latest_scores.filter( + recommendation=ScoreRun.Recommendation.STRONG, + job__first_seen__date=today, + ).count() + filtered_count = active_jobs.exclude(it_relevance_query()).count() + duplicate_count = JobPosting.objects.filter(status=JobPosting.Status.DUPLICATE).count() + + if strong_today: + day_conclusion = f"{strong_today} sterke match(es) verdienen vandaag eerst je aandacht." + elif new_today: + day_conclusion = f"De radar vond vandaag {new_today} nieuwe relevante vacature(s)." + else: + day_conclusion = "Geen nieuw sterk signaal; je bestaande opvolging blijft prioritair." + + return { + "score_cards": list(latest_scores.order_by("-score", "-created_at")[:8]), + "priority_applications": list( + open_applications.select_related("job", "job__employer").order_by( + F("follow_up_date").asc(nulls_last=True), "-updated_at" + )[:4] + ), + "active_jobs": active_it_jobs.count(), + "filtered_non_it_jobs": filtered_count, + "duplicate_jobs": duplicate_count, + "new_today": new_today, + "strong_today": strong_today, + "applications_open": open_applications.count(), + "source_counts": source_counts, + "recent_activity": _recent_dashboard_activity(user), + "day_conclusion": day_conclusion, + "latest_scan": SourceRun.objects.order_by("-started_at").first(), + } + + +def build_job_intelligence( + *, job: JobPosting, profile: SearchProfile | None, user: User +) -> dict[str, Any]: + """Return the bounded, user-scoped read model for explorer and detail.""" + + score = ( + ScoreRun.objects.filter(job=job, profile=profile).order_by("-created_at").first() + if profile + else None + ) + return { + "score": score, + "application": Application.objects.filter(user=user, job=job).first(), + "duplicate_count": job.duplicates.count(), + "source_count": job.source_aliases.count(), + "version_count": job.versions.count(), + "latest_versions": list(job.versions.all()[:5]), + "provenance_rows": list(job.provenance.select_related("source_alias__source")[:12]), + } diff --git a/apps/jobs/views.py b/apps/jobs/views.py index de3ddca..09f851f 100644 --- a/apps/jobs/views.py +++ b/apps/jobs/views.py @@ -3,16 +3,7 @@ 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, - DateTimeField, - DecimalField, - F, - JSONField, - OuterRef, - Q, - Subquery, -) +from django.db.models import Count, F, Q from django.http import Http404, HttpResponse from django.shortcuts import get_object_or_404, redirect from django.urls import reverse @@ -30,6 +21,7 @@ from .services.applications import ( delete_application_dossier, track_application_changes, ) +from .services.cockpit import annotate_latest_profile_score, build_job_intelligence from .services.feedback import record_feedback from .services.relevance import it_relevance_query from .services.skill_demand import build_skill_demand_report @@ -70,40 +62,15 @@ class JobListView(LoginRequiredMixin, ListView): VALID_SORTS = {"match", "newest", "closing"} def get_queryset(self): - queryset = JobPosting.objects.select_related("employer").all() + queryset = JobPosting.objects.select_related("employer").annotate( + duplicate_count=Count("duplicates", distinct=True) + ) profile = SearchProfile.objects.filter(user=self.request.user, is_active=True).first() - if profile: - latest_score = ScoreRun.objects.filter(job=OuterRef("pk"), profile=profile).order_by( - "-created_at" - ) - queryset = queryset.annotate( - match_score=Subquery( - latest_score.values("score")[:1], - output_field=DecimalField(max_digits=5, decimal_places=2), - ), - match_confidence=Subquery( - latest_score.values("confidence")[:1], - output_field=DecimalField(max_digits=4, decimal_places=3), - ), - 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() - ), - ) + queryset = annotate_latest_profile_score(queryset, profile) query = self.request.GET.get("q", "").strip() status = self.request.GET.get("status", "active").strip() workplace = self.request.GET.get("workplace", "").strip() + channel = self.request.GET.get("channel", "").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() @@ -120,6 +87,10 @@ class JobListView(LoginRequiredMixin, ListView): queryset = queryset.filter(status=status) if workplace: queryset = queryset.filter(workplace_type=workplace) + if channel == "direct": + queryset = queryset.filter(direct_employer=True, recruiter=False) + elif channel == "recruiter": + queryset = queryset.filter(recruiter=True) if focus != "all": queryset = queryset.filter(it_relevance_query()) if profile and recommendation == "radar": @@ -162,8 +133,21 @@ class JobListView(LoginRequiredMixin, ListView): else "radar" ), "current_sort": self.request.GET.get("sort", "match"), + "current_channel": self.request.GET.get("channel", ""), } ) + jobs = list(context["jobs"]) + selected_id = self.request.GET.get("selected", "") + selected_job = next((job for job in jobs if str(job.pk) == selected_id), None) + if selected_job is None and jobs: + selected_job = jobs[0] + context["selected_job"] = selected_job + if selected_job is not None: + context["selected_intelligence"] = build_job_intelligence( + job=selected_job, + profile=context["active_profile"], + user=self.request.user, + ) return context @@ -180,16 +164,9 @@ class JobDetailView(LoginRequiredMixin, DetailView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) profile = SearchProfile.objects.filter(user=self.request.user, is_active=True).first() - context["score"] = ( - ScoreRun.objects.filter(job=self.object, profile=profile) - .order_by("-created_at") - .first() - if profile - else None + context.update( + build_job_intelligence(job=self.object, profile=profile, user=self.request.user) ) - context["application"] = Application.objects.filter( - user=self.request.user, job=self.object - ).first() context["latest_feedback"] = Feedback.objects.filter( user=self.request.user, job=self.object ).first() diff --git a/docs/ai/BACKLOG.yaml b/docs/ai/BACKLOG.yaml index 0bcca5c..c1ef612 100644 --- a/docs/ai/BACKLOG.yaml +++ b/docs/ai/BACKLOG.yaml @@ -1611,7 +1611,7 @@ tasks: met gesplitst design system, mobile navigatie en generieke foutstates; relevante tests groen. - id: VR-210 title: Stitch dashboard en vacature-intelligence - status: ready + status: done priority: P0 requirement_ids: - PR-040 @@ -1638,6 +1638,11 @@ tasks: - templates/dashboard/today.html - templates/jobs/list.html - templates/jobs/detail.html + result: + completed_at: '2026-07-22' + note: Dashboard, vacatureverkenner en detail volledig naar Stitch-composities vervangen met echte cockpitaggregaties, + laatste profielscore, master/detailselectie, provenance, versies, duplicaten en veilige acties; relevante + scoring/relevance/viewtests groen. - id: VR-211 title: Stitch pipeline, radarprofiel en brongezondheid status: ready diff --git a/static/css/pages.css b/static/css/pages.css index 64c5ac6..c196968 100644 --- a/static/css/pages.css +++ b/static/css/pages.css @@ -66,3 +66,82 @@ .command-panel { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; } .command-panel pre { margin: 6px 0 0; padding: 10px; background: var(--surface-lowest); color: var(--emerald); } .auth-card .stack label { color: var(--text); font-size: 13px; } +.mini-timeline { display: grid; gap: 4px; } +.mini-event { display: flex; align-items: flex-start; gap: 10px; padding: 9px 0; border-top: 1px solid color-mix(in srgb, var(--outline) 30%, transparent); } +.mini-event strong, .mini-event small { display: block; } +.mini-event strong { font-size: 12px; } +.mini-event small { margin-top: 3px; font-size: 10px; } +.active-filters { display: flex; flex-wrap: wrap; align-items: center; gap: 7px; margin: -12px 0 18px; } +.active-filters > a { margin-left: auto; color: var(--cyan-dim); font: 11px var(--font-mono); } +.explorer-shell { display: grid; grid-template-columns: minmax(430px, .78fr) minmax(500px, 1.22fr); align-items: start; gap: 16px; } +.explorer-list { display: grid; gap: 8px; } +.explorer-card { position: relative; display: grid; grid-template-columns: 64px minmax(0, 1fr) 24px; align-items: center; gap: 13px; min-height: 132px; padding: 15px; border: 1px solid color-mix(in srgb, var(--outline) 42%, transparent); background: var(--surface-container); transition: border-color .15s, background .15s, transform .15s; } +.explorer-card:hover { transform: translateX(2px); border-color: var(--outline-strong); } +.explorer-card.is-selected { border-color: color-mix(in srgb, var(--cyan) 48%, var(--outline)); background: color-mix(in srgb, var(--cyan) 5%, var(--surface-container)); box-shadow: var(--shadow-signal); } +.explorer-card-link { position: absolute; z-index: 1; inset: 0; } +.explorer-score { display: grid; width: 58px; height: 58px; place-items: center; align-content: center; border: 1px solid color-mix(in srgb, var(--cyan) 38%, var(--outline)); background: var(--surface-low); } +.explorer-score strong { color: var(--cyan); font: 650 22px/1 var(--font-headline); } +.explorer-score small { margin-top: 4px; font: 9px var(--font-mono); text-transform: uppercase; } +.explorer-card-body { min-width: 0; } +.explorer-card-body h3 { margin: 7px 0 2px; overflow: hidden; font-size: 16px; text-overflow: ellipsis; white-space: nowrap; } +.explorer-card-body p { margin-bottom: 7px; color: var(--text-muted); } +.card-signal { display: block; overflow: hidden; margin-top: 8px; color: var(--emerald); text-overflow: ellipsis; white-space: nowrap; } +.explorer-arrow { color: var(--text-faint); } +.explorer-intelligence { position: sticky; top: calc(var(--topbar-height) + 18px); } +.intelligence-panel { overflow: hidden; border: 1px solid color-mix(in srgb, var(--cyan) 24%, var(--outline)); background: var(--surface-low); box-shadow: var(--shadow-signal); } +.intelligence-hero { position: relative; overflow: hidden; padding: 24px; border-bottom: 1px solid color-mix(in srgb, var(--outline) 42%, transparent); background: linear-gradient(140deg, color-mix(in srgb, var(--surface-container) 88%, var(--cyan)), var(--surface-container)); } +.intelligence-hero::after { position: absolute; right: -40px; bottom: -80px; width: 190px; height: 190px; content: ""; border: 1px solid color-mix(in srgb, var(--cyan) 14%, transparent); border-radius: 50%; box-shadow: inset 0 0 0 32px color-mix(in srgb, var(--cyan) 2%, transparent), inset 0 0 0 63px color-mix(in srgb, var(--cyan) 3%, transparent); } +.intelligence-hero > * { position: relative; z-index: 1; } +.intelligence-hero h2 { max-width: 620px; margin: 14px 0 4px; font-size: clamp(23px, 2vw, 32px); } +.intelligence-score { display: flex; align-items: center; gap: 13px; margin-top: 21px; } +.intelligence-score > strong { color: var(--cyan); font: 650 44px/1 var(--font-headline); } +.intelligence-score span, .intelligence-score small { display: block; } +.intelligence-score span { font-weight: 600; } +.intelligence-score small { margin-top: 3px; font: 10px var(--font-mono); } +.intel-facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 0; } +.intel-facts > div { min-height: 72px; padding: 13px 16px; border-right: 1px solid color-mix(in srgb, var(--outline) 28%, transparent); border-bottom: 1px solid color-mix(in srgb, var(--outline) 28%, transparent); } +.intel-facts dt { color: var(--text-faint); font: 10px var(--font-mono); letter-spacing: .06em; text-transform: uppercase; } +.intel-facts dd { margin: 5px 0 0; color: var(--text-strong); } +.intel-signals { padding: 16px 19px; border-bottom: 1px solid color-mix(in srgb, var(--outline) 30%, transparent); } +.intel-signals ul { margin: 9px 0 0; padding-left: 18px; } +.intel-signals li::marker { color: var(--emerald); } +.intel-signals.concerns li::marker { color: var(--amber); } +.intelligence-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 9px; padding: 16px; } +.back-link { display: inline-flex; margin-bottom: 16px; color: var(--text-muted); font: 11px var(--font-mono); } +.detail-hero { display: flex; align-items: flex-end; justify-content: space-between; gap: 30px; min-height: 270px; } +.detail-employer { color: var(--text-muted); font-size: 18px; } +.hero-score { min-width: 170px; padding: 18px; border: 1px solid color-mix(in srgb, var(--cyan) 45%, var(--outline)); background: color-mix(in srgb, var(--surface-lowest) 82%, transparent); text-align: center; } +.hero-score strong, .hero-score span, .hero-score small { display: block; } +.hero-score strong { color: var(--cyan); font: 650 48px/1 var(--font-headline); } +.hero-score span { margin-top: 8px; color: var(--text-strong); font-weight: 650; } +.hero-score small { margin-top: 5px; font: 10px var(--font-mono); } +.hero-score.is-pending strong { color: var(--text-faint); } +.intelligence-strip { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); border: solid color-mix(in srgb, var(--outline) 42%, transparent); border-width: 0 1px 1px; background: var(--surface-low); } +.intelligence-strip > div { min-height: 92px; padding: 16px; border-right: 1px solid color-mix(in srgb, var(--outline) 32%, transparent); } +.intelligence-strip > div:last-child { border-right: 0; } +.intelligence-strip strong, .intelligence-strip small { display: block; } +.intelligence-strip strong { margin: 8px 0 3px; color: var(--text-strong); font-size: 17px; } +.score-components { display: grid; gap: 9px; margin: 18px 0; } +.score-components > div { display: grid; grid-template-columns: 150px minmax(100px, 1fr) 36px; align-items: center; gap: 10px; } +.score-components span { color: var(--text-muted); font: 11px var(--font-mono); } +.progress { height: 5px; overflow: hidden; background: var(--surface-highest); } +.progress i { display: block; height: 100%; background: linear-gradient(90deg, var(--cyan-dim), var(--emerald)); } +.signal-columns { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; } +.signal-columns > div { padding: 15px; background: var(--surface-low); } +.skill-bands { display: grid; gap: 16px; margin: 16px 0; } +.tag-cloud { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 8px; } +.prose { color: var(--text); line-height: 1.75; } +.prose h2, .prose h3 { margin-top: 24px; } +.source-list { display: grid; gap: 0; padding: 0; list-style: none; } +.source-list li { display: flex; min-height: 48px; align-items: center; justify-content: space-between; gap: 14px; border-top: 1px solid color-mix(in srgb, var(--outline) 30%, transparent); } +.source-list a { display: inline-flex; align-items: center; gap: 7px; color: var(--cyan-dim); } +.source-list span { color: var(--text-muted); font: 10px var(--font-mono); } +.provenance-details { margin-top: 15px; } +.provenance-details summary { padding: 11px; border: 1px solid var(--outline); background: var(--surface-low); } +.data-table-wrap { overflow-x: auto; } +.data-table { width: 100%; border-collapse: collapse; } +.data-table th, .data-table td { padding: 11px 13px; border-bottom: 1px solid color-mix(in srgb, var(--outline) 35%, transparent); text-align: left; vertical-align: top; } +.data-table th { color: var(--text-faint); font: 600 10px var(--font-mono); letter-spacing: .08em; text-transform: uppercase; } +.version-list { display: grid; } +.version-list article { display: grid; grid-template-columns: 150px 1fr; gap: 16px; padding: 11px 0; border-top: 1px solid color-mix(in srgb, var(--outline) 30%, transparent); } +.version-list time { color: var(--text-muted); font: 11px var(--font-mono); } diff --git a/static/css/responsive.css b/static/css/responsive.css index 3af6a32..cea0b7a 100644 --- a/static/css/responsive.css +++ b/static/css/responsive.css @@ -8,6 +8,7 @@ @media (max-width: 1450px) { .filter-console { grid-template-columns: minmax(240px, 2fr) repeat(3, minmax(130px, 1fr)); } .filter-console > :nth-last-child(-n+3) { grid-row: 2; } + .explorer-shell { grid-template-columns: minmax(390px, .9fr) minmax(430px, 1.1fr); } } @media (max-width: 1180px) { @@ -17,6 +18,8 @@ .health-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .filter-console { grid-template-columns: repeat(3, minmax(0, 1fr)); } .filter-console > * { grid-row: auto !important; } + .explorer-shell { grid-template-columns: 1fr; } + .explorer-intelligence { position: static; } } @media (max-width: 820px) { @@ -45,6 +48,9 @@ .source-alert-grid { grid-template-columns: 1fr; } .feed-card, .result-card { grid-template-columns: 1fr; } .feed-card-actions, .result-actions { min-width: 0; flex-direction: row; justify-content: flex-start; border-top: 1px solid color-mix(in srgb, var(--outline) 34%, transparent); border-left: 0; } + .detail-hero { align-items: flex-start; flex-direction: column; min-height: 0; } + .hero-score { min-width: 0; width: 100%; text-align: left; } + .intelligence-strip { grid-template-columns: repeat(2, 1fr); } } @media (max-width: 560px) { @@ -63,6 +69,12 @@ .page-actions, .job-actions, .form-actions { width: 100%; } .page-actions .button, .form-actions .button { flex: 1; } .sticky-actions { bottom: 76px; width: 100%; overflow-x: auto; justify-content: flex-start; } + .explorer-card { grid-template-columns: 54px minmax(0, 1fr); padding: 12px; } + .explorer-score { width: 50px; height: 50px; } + .explorer-arrow { display: none; } + .intelligence-actions, .intel-facts, .signal-columns, .intelligence-strip { grid-template-columns: 1fr; } + .score-components > div { grid-template-columns: 100px minmax(70px, 1fr) 30px; } + .source-list li, .version-list article { align-items: flex-start; grid-template-columns: 1fr; flex-direction: column; padding: 11px 0; } .auth-shell { padding: 14px; } .auth-card { padding: 22px 18px; } } diff --git a/templates/dashboard/today.html b/templates/dashboard/today.html index c1cb2a5..0a3fcec 100644 --- a/templates/dashboard/today.html +++ b/templates/dashboard/today.html @@ -1,64 +1,45 @@ {% extends "base.html" %} {% block title %}Vandaag · VacatureRadar{% endblock %} {% block content %} -