From d8967f29f04814a8ae97998b46dfddbc63c0ea91 Mon Sep 17 00:00:00 2001 From: hermes Date: Thu, 30 Jul 2026 20:53:21 +0000 Subject: [PATCH] WB1: add >=20 tests for cluster, tailor-cv, deadline integration --- apps/api/tests/test_v11b_wb1.py | 739 +++++++++++++++++++++++++++++++ apps/web/src/views/Research.vue | 14 - apps/web/src/views/TodayView.vue | 2 +- 3 files changed, 740 insertions(+), 15 deletions(-) create mode 100644 apps/api/tests/test_v11b_wb1.py diff --git a/apps/api/tests/test_v11b_wb1.py b/apps/api/tests/test_v11b_wb1.py new file mode 100644 index 0000000..6a4c82d --- /dev/null +++ b/apps/api/tests/test_v11b_wb1.py @@ -0,0 +1,739 @@ +"""Tests for v1.1 wave B WB1: dedupe + tailor + deadline integration. + +Covers: +- Cluster assignment on posting creation (manual POST /postings) +- Cluster stability across re-imports (same posting URL -> same cluster_id) +- GET /clusters endpoint shape (cluster_id, postings with id/title/company/source/url/score) +- Tailor CV happy path (artifact created, change_log, keyword_coverage) +- Tailor CV hallucination rejection (fabricated mock returning unmapped bullet -> 502) +- Keyword coverage numbers vs fixture +- Deadline persisted on scoring (single + batch) +- /today deadlines filter window (next 7 days) + +Total: >= 20 new tests. +""" + +from __future__ import annotations + +import os +from datetime import date, datetime, timedelta, timezone +from unittest.mock import patch + +import pytest +from fastapi.testclient import TestClient + +from app.db import execute, fetch_one, repo_app, repo_profile + + +@pytest.fixture() +def client(): + from app.main import app + return TestClient(app) + + +# --- Helpers --- + +def _create_posting_direct( + company: str = "TechCorp", + title: str = "Senior Python Developer", + url: str = "https://example.com/1", + description: str = "We need a Python developer with FastAPI experience.", + source: str = "manual_url", +) -> dict: + """Create a posting directly via repo.""" + posting = repo_app.create_job_posting( + source=source, + url=url, + company=company, + title=title, + location="Malmo", + description=description, + raw={}, + ) + repo_app.create_application(posting["id"]) + return posting + + +def _create_app_with_profile_and_sections( + client, + company: str = "TechCorp", + title: str = "Senior Python Developer", + url: str = "https://example.com/tc1", + description: str = "Python FastAPI PostgreSQL Docker Kubernetes AWS", +) -> str: + """Create a profile with sections + posting + application. Returns app_id.""" + # Create profile + client.get("/api/profile") + client.put("/api/profile", json={ + "full_name": "Test User", + "email": "test@test.com", + "headline": "Backend Developer", + "summary": "Experienced backend developer.", + }) + # Create sections + client.post("/api/profile/sections", json={ + "kind": "experience", + "title": "Backend Developer", + "org": "TechCorp", + "bullets": [ + "Led migration of monolith to microservices using Fast API", + "Reduced API latency by 40% through query optimization and caching", + ], + "tags": ["python", "fastapi"], + }) + client.post("/api/profile/sections", json={ + "kind": "skills", + "title": "Technical Skills", + "bullets": ["Python", "PostgreSQL", "Docker", "FastAPI"], + "tags": ["python", "docker"], + }) + # Create posting + application + posting = _create_posting_direct(company=company, title=title, url=url, description=description) + # Re-fetch to get the app + apps = repo_app.list_applications() + for a in apps: + if a["job_posting_id"] == posting["id"]: + return a["id"] + raise RuntimeError("Application not found") + + +def _make_posting_dict(posting_id: str, company: str, title: str, description: str) -> dict: + """Build a posting dict suitable for cluster().""" + return { + "id": posting_id, + "employer": company, + "title": title, + "description": description, + } + + +# ======================================================================== +# Cluster assignment on create (4 tests) +# ======================================================================== + +class TestClusterAssignmentOnCreate: + def test_single_posting_gets_cluster_id(self, client): + """A posting created via POST /postings gets a cluster_id assigned.""" + resp = client.post("/api/postings", json={"url": "https://example.com/cluster/1"}) + assert resp.status_code == 201 + + postings = client.get("/api/postings").json() + assert len(postings) >= 1 + # The first posting might get c1 or no cluster (if it's the only one, cluster() gives it c1) + # With matching available, even 1 posting gets cluster c1 + if len(postings) == 1: + # Single posting: cluster() returns {"c1": [id]} + assert postings[0]["cluster_id"] is not None + + def test_duplicate_postings_same_cluster(self, client): + """Two identical postings (same company, title, description) get same cluster_id.""" + _create_posting_direct( + company="Acme Corp", + title="Software Engineer", + url="https://example.com/dup/1", + description="We need a Python developer with Docker experience.", + ) + _create_posting_direct( + company="Acme Corp", + title="Software Engineer", + url="https://example.com/dup/2", + description="We need a Python developer with Docker experience.", + ) + + # Run cluster assignment manually + from app.main import _assign_cluster_id + from app.db import repo_app + all_postings = repo_app.list_postings() + for p in all_postings: + _assign_cluster_id(p["id"]) + + postings = repo_app.list_postings() + cluster_ids = [p["cluster_id"] for p in postings if p["cluster_id"]] + # Both should have the same cluster_id + assert len(cluster_ids) >= 2 + assert len(set(cluster_ids)) == 1 + + def test_different_postings_different_clusters(self, client): + """Completely different postings get different cluster_ids.""" + _create_posting_direct( + company="CompanyA", + title="Chef", + url="https://example.com/diff/1", + description="Looking for an experienced chef.", + ) + _create_posting_direct( + company="CompanyB", + title="Pilot", + url="https://example.com/diff/2", + description="Commercial airline pilot needed.", + ) + + from app.main import _assign_cluster_id + from app.db import repo_app + all_postings = repo_app.list_postings() + for p in all_postings: + _assign_cluster_id(p["id"]) + + postings = repo_app.list_postings() + cluster_ids = [p["cluster_id"] for p in postings if p["cluster_id"]] + if len(cluster_ids) >= 2: + assert len(set(cluster_ids)) >= 2 + + def test_cluster_id_in_get_postings(self, client): + """GET /postings returns cluster_id field.""" + _create_posting_direct( + company="TestCo", + title="Dev", + url="https://example.com/field/1", + description="Test description", + ) + + resp = client.get("/api/postings") + assert resp.status_code == 200 + data = resp.json() + assert len(data) >= 1 + assert "cluster_id" in data[0] + + +# ======================================================================== +# Cluster stability across re-imports (3 tests) +# ======================================================================== + +class TestClusterStability: + def test_reimport_preserves_cluster_id(self, client): + """Re-importing a posting (same URL) keeps the cluster_id stable.""" + # First import + posting1 = _create_posting_direct( + company="StableCorp", + title="Engineer", + url="https://example.com/stable/1", + description="Stable description for engineer role.", + ) + + from app.main import _assign_cluster_id + _assign_cluster_id(posting1["id"]) + + p1 = repo_app.get_job_posting(posting1["id"]) + original_cluster_id = p1.get("cluster_id") + + # Re-import: same URL -> ON CONFLICT DO UPDATE, returns same row + posting2 = repo_app.create_job_posting( + source="manual_url", + url="https://example.com/stable/1", + company="StableCorp", + title="Engineer", + description="Stable description for engineer role.", + raw={}, + ) + assert posting2["id"] == posting1["id"] + + p2 = repo_app.get_job_posting(posting2["id"]) + assert p2.get("cluster_id") == original_cluster_id + + def test_new_duplicate_joins_existing_cluster(self, client): + """A new posting that's a duplicate of an existing one joins its cluster_id.""" + # First posting + p1 = _create_posting_direct( + company="JoinCorp", + title="Backend Developer", + url="https://example.com/join/1", + description="Python developer with PostgreSQL and Docker.", + ) + from app.main import _assign_cluster_id + _assign_cluster_id(p1["id"]) + + p1_row = repo_app.get_job_posting(p1["id"]) + original_cluster = p1_row.get("cluster_id") + + # Second posting (duplicate) + p2 = _create_posting_direct( + company="JoinCorp", + title="Backend Developer", + url="https://example.com/join/2", + description="Python developer with PostgreSQL and Docker.", + ) + _assign_cluster_id(p2["id"]) + + p2_row = repo_app.get_job_posting(p2["id"]) + assert p2_row.get("cluster_id") == original_cluster + + def test_third_duplicate_extends_cluster(self, client): + """Third duplicate posting joins the same cluster as the first two.""" + desc = "Senior Python developer with FastAPI and PostgreSQL experience." + p1 = _create_posting_direct( + company="ExtCorp", title="Senior Python Developer", + url="https://example.com/ext/1", description=desc, + ) + from app.main import _assign_cluster_id + _assign_cluster_id(p1["id"]) + c1 = repo_app.get_job_posting(p1["id"]).get("cluster_id") + + p2 = _create_posting_direct( + company="ExtCorp", title="Senior Python Developer", + url="https://example.com/ext/2", description=desc, + ) + _assign_cluster_id(p2["id"]) + + p3 = _create_posting_direct( + company="ExtCorp", title="Senior Python Developer", + url="https://example.com/ext/3", description=desc, + ) + _assign_cluster_id(p3["id"]) + + c2 = repo_app.get_job_posting(p2["id"]).get("cluster_id") + c3 = repo_app.get_job_posting(p3["id"]).get("cluster_id") + assert c1 == c2 == c3 + + +# ======================================================================== +# GET /clusters endpoint shape (3 tests) +# ======================================================================== + +class TestClustersEndpoint: + def test_clusters_returns_list(self, client): + """GET /clusters returns a list of cluster objects.""" + resp = client.get("/api/clusters") + assert resp.status_code == 200 + assert isinstance(resp.json(), list) + + def test_clusters_shape(self, client): + """Each cluster has cluster_id and postings with required fields.""" + # Create two duplicate postings + desc = "Full stack developer with React and Node.js experience needed." + p1 = _create_posting_direct( + company="ShapeCorp", title="Full Stack Developer", + url="https://example.com/shape/1", description=desc, + ) + from app.main import _assign_cluster_id + _assign_cluster_id(p1["id"]) + + p2 = _create_posting_direct( + company="ShapeCorp", title="Full Stack Developer", + url="https://example.com/shape/2", description=desc, + ) + _assign_cluster_id(p2["id"]) + + resp = client.get("/api/clusters") + assert resp.status_code == 200 + data = resp.json() + assert len(data) >= 1 + + cluster = data[0] + assert "cluster_id" in cluster + assert "postings" in cluster + assert isinstance(cluster["postings"], list) + assert len(cluster["postings"]) >= 2 + + p = cluster["postings"][0] + assert "id" in p + assert "title" in p + assert "company" in p + assert "source" in p + assert "url" in p + assert "score" in p + + def test_clusters_empty_when_no_cluster_ids(self, client): + """GET /clusters returns empty list when no postings have cluster_ids.""" + # Create a posting but don't assign cluster_id + _create_posting_direct( + company="NoCluster", title="Dev", + url="https://example.com/nocluster/1", description="Something unique.", + ) + # Don't call _assign_cluster_id + + resp = client.get("/api/clusters") + assert resp.status_code == 200 + data = resp.json() + # Should be empty since no cluster_ids assigned + assert len(data) == 0 + + +# ======================================================================== +# Tailor CV happy path (3 tests) +# ======================================================================== + +class TestTailorCvHappyPath: + def test_tailor_cv_returns_artifact_and_coverage(self, client): + """POST /applications/{id}/tailor-cv returns artifact_id, change_log, keyword_coverage.""" + app_id = _create_app_with_profile_and_sections(client) + + resp = client.post(f"/api/applications/{app_id}/tailor-cv") + assert resp.status_code == 200 + data = resp.json() + + assert "artifact_id" in data + assert data["artifact_id"] is not None + assert "change_log" in data + assert isinstance(data["change_log"], list) + assert len(data["change_log"]) >= 1 + assert "keyword_coverage" in data + kc = data["keyword_coverage"] + assert "matched" in kc + assert "missing" in kc + assert "ratio" in kc + + def test_tailor_cv_artifact_stored(self, client): + """The tailored CV artifact appears in the application's artifacts list.""" + app_id = _create_app_with_profile_and_sections(client) + + resp = client.post(f"/api/applications/{app_id}/tailor-cv") + assert resp.status_code == 200 + artifact_id = resp.json()["artifact_id"] + + artifacts = client.get(f"/api/applications/{app_id}/artifacts").json() + cv_artifacts = [a for a in artifacts if a["kind"] == "cv"] + assert len(cv_artifacts) >= 1 + assert any(a["id"] == artifact_id for a in cv_artifacts) + ai_artifact = [a for a in cv_artifacts if a["id"] == artifact_id][0] + assert ai_artifact["origin"] == "ai_drafted" + + def test_tailor_cv_404_nonexistent(self, client): + """Tailor CV on nonexistent application returns 404.""" + resp = client.post("/api/applications/00000000-0000-0000-0000-000000000000/tailor-cv") + assert resp.status_code == 404 + + +# ======================================================================== +# Tailor CV hallucination rejection (3 tests) +# ======================================================================== + +class TestTailorCvHallucinationGuard: + def test_hallucination_rejection_502(self, client): + """When mock returns bullet with no source mapping, return 502.""" + app_id = _create_app_with_profile_and_sections(client) + + # Patch the mock to return a fabricated bullet + import app.llm as llm_mod + original = llm_mod.MOCK_OUTPUTS.get("cv_tailor", {}).copy() + try: + llm_mod.MOCK_OUTPUTS["cv_tailor"] = { + "tailored_cv": { + "summary": "Developer", + "skills": ["Python"], + "experience": [ + { + "company": "FakeCorp", + "role": "Fake Role", + "bullets": [ + "Completely fabricated achievement that has no overlap with any source bullet xyzqwerty", + ], + }, + ], + }, + "change_log": [{"action": "invented", "detail": "Made up a bullet"}], + } + resp = client.post(f"/api/applications/{app_id}/tailor-cv") + assert resp.status_code == 502 + detail = resp.json()["detail"] + assert "hallucination_guard" in str(detail) + finally: + llm_mod.MOCK_OUTPUTS["cv_tailor"] = original + + def test_hallucination_rejection_with_empty_source_bullets(self, client): + """When there are no source bullets, hallucination guard is not triggered (no source to map to).""" + # Create profile with no sections + client.get("/api/profile") + client.put("/api/profile", json={ + "full_name": "Test User", + "email": "test@test.com", + }) + # Create posting + app + posting = _create_posting_direct( + company="NoSourceCo", + title="Dev", + url="https://example.com/nosource/1", + description="Python developer", + ) + apps = repo_app.list_applications() + app_id = None + for a in apps: + if a["job_posting_id"] == posting["id"]: + app_id = a["id"] + break + + # When no source bullets exist, the guard doesn't trigger (source_bullets is empty) + resp = client.post(f"/api/applications/{app_id}/tailor-cv") + # Should succeed since source_bullets is empty -> guard not triggered + assert resp.status_code == 200 + + def test_hallucination_rejection_preserves_existing_output(self, client): + """After a 502 hallucination rejection, a subsequent valid call works.""" + app_id = _create_app_with_profile_and_sections(client) + + # First: trigger hallucination + import app.llm as llm_mod + original = llm_mod.MOCK_OUTPUTS.get("cv_tailor", {}).copy() + try: + llm_mod.MOCK_OUTPUTS["cv_tailor"] = { + "tailored_cv": { + "summary": "Dev", + "skills": ["Python"], + "experience": [ + { + "company": "X", + "role": "X", + "bullets": ["Fabricated xyzqwerty zzz new content"], + }, + ], + }, + "change_log": [], + } + resp1 = client.post(f"/api/applications/{app_id}/tailor-cv") + assert resp1.status_code == 502 + + # Restore and retry + llm_mod.MOCK_OUTPUTS["cv_tailor"] = original + resp2 = client.post(f"/api/applications/{app_id}/tailor-cv") + assert resp2.status_code == 200 + finally: + llm_mod.MOCK_OUTPUTS["cv_tailor"] = original + + +# ======================================================================== +# Keyword coverage numbers vs fixture (2 tests) +# ======================================================================== + +class TestKeywordCoverage: + def test_keyword_coverage_has_matched_and_missing(self, client): + """Keyword coverage from tailor-cv contains matched and missing keywords.""" + app_id = _create_app_with_profile_and_sections( + client, + description="Python FastAPI PostgreSQL Docker Kubernetes AWS Java Spring", + ) + + resp = client.post(f"/api/applications/{app_id}/tailor-cv") + assert resp.status_code == 200 + kc = resp.json()["keyword_coverage"] + + assert "matched" in kc + assert "missing" in kc + assert "ratio" in kc + assert isinstance(kc["matched"], list) + assert isinstance(kc["missing"], list) + assert isinstance(kc["ratio"], (int, float)) + assert 0.0 <= kc["ratio"] <= 1.0 + + def test_keyword_coverage_ratio_is_reasonable(self, client): + """With matching CV keywords, coverage ratio should be > 0.""" + app_id = _create_app_with_profile_and_sections( + client, + description="Python FastAPI PostgreSQL Docker Kubernetes AWS", + ) + + resp = client.post(f"/api/applications/{app_id}/tailor-cv") + assert resp.status_code == 200 + kc = resp.json()["keyword_coverage"] + + # The mock CV has Python, Fast API, PostgreSQL, Docker, Kubernetes, AWS + # which should match most posting keywords + assert kc["ratio"] > 0.0 + + +# ======================================================================== +# Deadline persisted on scoring (3 tests) +# ======================================================================== + +class TestDeadlinePersisted: + def test_deadline_extracted_on_single_score(self, client): + """Scoring a posting also runs deadline_extract and persists apply_by.""" + posting = _create_posting_direct( + company="DeadlineCo", + title="Dev", + url="https://example.com/deadline/1", + description="Apply by 2026-12-31.", + ) + apps = repo_app.list_applications() + app_id = None + for a in apps: + if a["job_posting_id"] == posting["id"]: + app_id = a["id"] + break + + # Mock deadline_extract to return a date + import app.llm as llm_mod + original = llm_mod.MOCK_OUTPUTS.get("deadline_extract", {}).copy() + try: + llm_mod.MOCK_OUTPUTS["deadline_extract"] = {"apply_by": "2026-12-31"} + resp = client.post(f"/api/postings/{posting['id']}/score") + assert resp.status_code == 200 + + # Verify apply_by was persisted + p = repo_app.get_job_posting(posting["id"]) + assert p["apply_by"] == "2026-12-31" + finally: + llm_mod.MOCK_OUTPUTS["deadline_extract"] = original + + def test_deadline_null_does_not_persist(self, client): + """When deadline_extract returns null apply_by, nothing is persisted.""" + posting = _create_posting_direct( + company="NoDeadlineCo", + title="Dev", + url="https://example.com/nodeadline/1", + description="No deadline mentioned.", + ) + + # Default mock returns apply_by=None + resp = client.post(f"/api/postings/{posting['id']}/score") + assert resp.status_code == 200 + + p = repo_app.get_job_posting(posting["id"]) + assert p["apply_by"] is None + + def test_deadline_extracted_on_batch_score(self, client): + """Batch scoring also runs deadline_extract and persists apply_by.""" + posting = _create_posting_direct( + company="BatchDeadlineCo", + title="Dev", + url="https://example.com/batchdeadline/1", + description="Apply by 2026-11-15.", + ) + apps = repo_app.list_applications() + app_id = None + for a in apps: + if a["job_posting_id"] == posting["id"]: + app_id = a["id"] + break + + import app.llm as llm_mod + original = llm_mod.MOCK_OUTPUTS.get("deadline_extract", {}).copy() + try: + llm_mod.MOCK_OUTPUTS["deadline_extract"] = {"apply_by": "2026-11-15"} + resp = client.post("/api/scoring/batch", json={"application_ids": [app_id]}) + assert resp.status_code == 200 + + p = repo_app.get_job_posting(posting["id"]) + assert p["apply_by"] == "2026-11-15" + finally: + llm_mod.MOCK_OUTPUTS["deadline_extract"] = original + + +# ======================================================================== +# /today deadlines filter window (3 tests) +# ======================================================================== + +class TestTodayDeadlines: + def test_today_returns_deadlines_field(self, client): + """/today response includes deadlines field.""" + resp = client.get("/api/today") + assert resp.status_code == 200 + data = resp.json() + assert "deadlines" in data + assert isinstance(data["deadlines"], list) + + def test_today_deadlines_within_7_days(self, client): + """Deadlines within next 7 days appear in /today.""" + # Create posting with apply_by in 3 days + posting = _create_posting_direct( + company="WeekCo", + title="Dev", + url="https://example.com/week/1", + description="Dev role.", + ) + apps = repo_app.list_applications() + app_id = None + for a in apps: + if a["job_posting_id"] == posting["id"]: + app_id = a["id"] + break + + future_date = date.today() + timedelta(days=3) + repo_app.update_posting_apply_by(posting["id"], future_date) + + resp = client.get("/api/today") + assert resp.status_code == 200 + deadlines = resp.json()["deadlines"] + assert len(deadlines) >= 1 + + dl = [d for d in deadlines if d["application_id"] == app_id] + assert len(dl) == 1 + assert dl[0]["title"] == "Dev" + assert dl[0]["company"] == "WeekCo" + assert dl[0]["apply_by"] == future_date.isoformat() + + def test_today_deadlines_excludes_beyond_7_days(self, client): + """Deadlines beyond 7 days do NOT appear in /today.""" + posting = _create_posting_direct( + company="FarCo", + title="Dev", + url="https://example.com/far/1", + description="Dev role.", + ) + apps = repo_app.list_applications() + app_id = None + for a in apps: + if a["job_posting_id"] == posting["id"]: + app_id = a["id"] + break + + far_date = date.today() + timedelta(days=30) + repo_app.update_posting_apply_by(posting["id"], far_date) + + resp = client.get("/api/today") + assert resp.status_code == 200 + deadlines = resp.json()["deadlines"] + far_deadlines = [d for d in deadlines if d["application_id"] == app_id] + assert len(far_deadlines) == 0 + + +# ======================================================================== +# Extra integration tests (2 tests) +# ======================================================================== + +class TestExtraIntegration: + def test_get_postings_has_apply_by_field(self, client): + """GET /postings includes apply_by field.""" + posting = _create_posting_direct( + company="ApplyByCo", + title="Dev", + url="https://example.com/applyby/1", + description="Dev role.", + ) + repo_app.update_posting_apply_by(posting["id"], date.today() + timedelta(days=5)) + + resp = client.get("/api/postings") + assert resp.status_code == 200 + data = resp.json() + p = [x for x in data if x["id"] == posting["id"]][0] + assert p["apply_by"] is not None + + def test_clusters_sorted_by_best_score_desc(self, client): + """Clusters are sorted by best score descending.""" + # Create cluster 1 with a high-score posting + desc1 = "Python developer with PostgreSQL and Docker experience needed." + p1 = _create_posting_direct( + company="HighScoreCo", title="Python Developer", + url="https://example.com/sort/1", description=desc1, + ) + from app.main import _assign_cluster_id + _assign_cluster_id(p1["id"]) + + # Score p1 + apps = repo_app.list_applications() + for a in apps: + if a["job_posting_id"] == p1["id"]: + repo_app.update_application_score(a["id"], 90, {"factors": {}}) + break + + # Create cluster 2 with a low-score posting + desc2 = "Marketing specialist for social media campaigns and content creation." + p2 = _create_posting_direct( + company="LowScoreCo", title="Marketing Specialist", + url="https://example.com/sort/2", description=desc2, + ) + _assign_cluster_id(p2["id"]) + + for a in apps: + if a["job_posting_id"] == p2["id"]: + repo_app.update_application_score(a["id"], 30, {"factors": {}}) + break + + resp = client.get("/api/clusters") + assert resp.status_code == 200 + data = resp.json() + if len(data) >= 2: + # Best scores should be descending + best_scores = [] + for c in data: + scores = [p.get("score") or 0 for p in c["postings"]] + best_scores.append(max(scores) if scores else 0) + assert best_scores[0] >= best_scores[1] \ No newline at end of file diff --git a/apps/web/src/views/Research.vue b/apps/web/src/views/Research.vue index 0aa60b5..b6c2c11 100644 --- a/apps/web/src/views/Research.vue +++ b/apps/web/src/views/Research.vue @@ -33,20 +33,6 @@ const groupedPostings = computed(() => { return Array.from(map.entries()).map(([cluster_id, items]) => ({ cluster_id, items })) }) -// Best score per cluster (from cluster endpoint or from scoreMap) -function clusterBestScore(clusterId: string): number | null { - const cluster = clusters.value.find((c) => c.cluster_id === clusterId) - if (cluster && cluster.postings.length > 0) { - return Math.max(...cluster.postings.map((p) => p.score)) - } - const items = groupedPostings.value.find((g) => g.cluster_id === clusterId)?.items - if (items) { - const scores = items.map((p) => scoreMap.value[p.id]?.score).filter((s): s is number => s != null) - return scores.length > 0 ? Math.max(...scores) : null - } - return null -} - // Alternates for a cluster (from GET /clusters endpoint) function clusterAlternatives(clusterId: string): Cluster['postings'] { const cluster = clusters.value.find((c) => c.cluster_id === clusterId) diff --git a/apps/web/src/views/TodayView.vue b/apps/web/src/views/TodayView.vue index 134d21e..362245c 100644 --- a/apps/web/src/views/TodayView.vue +++ b/apps/web/src/views/TodayView.vue @@ -9,7 +9,7 @@ import type { TodayResponse, TodayDeadline, EmailSuggestion, NotificationLogEntr const toast = useToastStore() const router = useRouter() -const today = ref(null) +const today = ref<(TodayResponse & { deadlines?: TodayDeadline[] }) | null>(null) const loading = ref(true) const deadlines = ref([]) const suggestions = ref([])