From 6cb593ab5c253073a513af9d942ed77aa8c2a7eb Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sun, 5 Jul 2026 10:20:58 +0200 Subject: [PATCH 01/12] perf(analytics): project minimal columns in GetStats/GetAnalyticsOverview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both endpoints materialised full JobApplication rows (GetAnalyticsOverview also Include-d full Company) purely to aggregate a few fields, dragging the large Description/TranslatedDescription/TailoredCvText/Notes/CoverLetter blobs over the wire on every dashboard load. Project to only the columns each aggregation needs (mirrors the existing GetTagTrends pattern). Behaviour is identical; aggregation stays in memory over a small per-tenant set. Backend suite: 92/92 green. Note: the planned hot-path *index* migration is deferred — the committed EF ModelSnapshot is stale (21 lines, no entities), so `migrations add` cannot produce a clean incremental diff. Resyncing the snapshot is a prerequisite and is tracked as its own task. Co-Authored-By: Claude Opus 4.8 --- .../Controllers/JobApplicationsController.cs | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/JobTrackerApi/Controllers/JobApplicationsController.cs b/JobTrackerApi/Controllers/JobApplicationsController.cs index 58f6884..2795bf5 100644 --- a/JobTrackerApi/Controllers/JobApplicationsController.cs +++ b/JobTrackerApi/Controllers/JobApplicationsController.cs @@ -1750,8 +1750,13 @@ Canonical profile: { var now = DateTime.Now; + // Project to only the columns the stats need instead of materialising full + // JobApplication rows (which drag large Description/TranslatedDescription/ + // TailoredCvText/Notes blobs). Aggregation stays in memory over a small + // per-tenant set. Mirrors the projection pattern used by GetTagTrends. var all = await _db.JobApplications .AsNoTracking() + .Select(j => new { j.IsDeleted, j.Status, j.DateApplied }) .ToListAsync(cancellationToken); var active = all.Where(j => !j.IsDeleted).ToList(); @@ -2668,10 +2673,21 @@ Candidate master CV: [HttpGet("analytics-overview")] public async Task> GetAnalyticsOverview(CancellationToken cancellationToken) { + // Project to only the fields the overview needs instead of Include-ing full + // Company + JobApplication rows (avoids loading large description/CV blobs). var activeJobs = await _db.JobApplications .AsNoTracking() - .Include(j => j.Company) .Where(j => !j.IsDeleted) + .Select(j => new + { + j.Status, + j.ResponseReceived, + j.ResponseDate, + j.DateApplied, + j.CompanyId, + CompanyName = j.Company.Name, + CompanySource = j.Company.Source + }) .ToListAsync(cancellationToken); var funnelMap = new Dictionary @@ -2686,7 +2702,7 @@ Candidate master CV: var funnel = funnelMap.Select(x => new FunnelStagePoint(x.Key, x.Value)).ToList(); var responseRateBySource = activeJobs - .GroupBy(j => string.IsNullOrWhiteSpace(j.Company?.Source) ? "Unknown source" : j.Company!.Source!.Trim()) + .GroupBy(j => string.IsNullOrWhiteSpace(j.CompanySource) ? "Unknown source" : j.CompanySource!.Trim()) .Select(g => new ResponseRatePoint( g.Key, g.Count(), @@ -2699,7 +2715,7 @@ Candidate master CV: .ToList(); var topCompanies = activeJobs - .GroupBy(j => new { j.CompanyId, Name = j.Company.Name }) + .GroupBy(j => new { j.CompanyId, Name = j.CompanyName }) .Select(g => new CompanyActivityPoint( g.Key.CompanyId, g.Key.Name, -- 2.52.0 From b8ec268736ab37f0e8cfc5e5c091cb90b40f813e Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sun, 5 Jul 2026 10:30:44 +0200 Subject: [PATCH 02/12] perf(db): add owner-prefixed hot-path indexes on JobApplications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add composite indexes (OwnerUserId, IsDeleted) — for the tenant-scoped list/board/stats/analytics queries that all filter !IsDeleted — and (OwnerUserId, FollowUpAt) for the reminders surface. Every JobApplication query is scoped by the OwnerUserId global filter first, so owner-prefixed composites are the useful shape; the pre-existing single OwnerUserId index is now a redundant prefix but kept to avoid churn. Status is intentionally excluded: Pomelo maps the unbounded string column to MariaDB longtext, which cannot be indexed without a prefix length. Applied via the startup schema reconciler (StartupInitializationExtensions), which is how this repo actually provisions schema/indexes on both providers (SQLite: CREATE INDEX IF NOT EXISTS; MariaDB: MySqlIndexExists-guarded CREATE INDEX) — NOT via EF migrations, whose committed ModelSnapshot is stale. OnModelCreating also declares the indexes for model consistency. Backend suite: 92/92 green. Co-Authored-By: Claude Opus 4.8 --- Data/JobTrackerContext.cs | 14 ++++++++++ .../StartupInitializationExtensions.cs | 26 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/Data/JobTrackerContext.cs b/Data/JobTrackerContext.cs index 6067b28..b7c7f07 100644 --- a/Data/JobTrackerContext.cs +++ b/Data/JobTrackerContext.cs @@ -55,6 +55,20 @@ namespace JobTrackerApi.Data modelBuilder.Entity() .HasIndex(j => j.OwnerUserId); + // Owner-prefixed composite indexes for the tenant-scoped hot paths. Every + // JobApplication query is scoped by the OwnerUserId global filter first, then + // filtered by IsDeleted (list/board/stats/analytics) or FollowUpAt (reminders). + // Status is intentionally excluded from the index because Pomelo maps the + // unbounded string column to longtext, which MariaDB cannot index without a + // prefix length. The actual index DDL is applied idempotently in + // StartupInitializationExtensions (this repo provisions schema via that + // reconciler, not via the EF ModelSnapshot, which is stale). + modelBuilder.Entity() + .HasIndex(j => new { j.OwnerUserId, j.IsDeleted }); + + modelBuilder.Entity() + .HasIndex(j => new { j.OwnerUserId, j.FollowUpAt }); + modelBuilder.Entity() .HasIndex(c => c.OwnerUserId); diff --git a/JobTrackerApi/Services/StartupInitializationExtensions.cs b/JobTrackerApi/Services/StartupInitializationExtensions.cs index fb76114..cd2b190 100644 --- a/JobTrackerApi/Services/StartupInitializationExtensions.cs +++ b/JobTrackerApi/Services/StartupInitializationExtensions.cs @@ -499,6 +499,16 @@ public static class StartupInitializationExtensions EnsureColumn(conn, "Attachments", "Purpose", "ALTER TABLE Attachments ADD COLUMN Purpose TEXT NULL;"); EnsureColumn(conn, "Attachments", "UseForAi", "ALTER TABLE Attachments ADD COLUMN UseForAi INTEGER NOT NULL DEFAULT 1;"); + // Hot-path composite indexes for tenant-scoped list/board/stats/analytics + // (OwnerUserId + IsDeleted) and reminders (OwnerUserId + FollowUpAt). Guarded + // on table existence: on a brand-new DB the table is created by Migrate() + // below, so the index is picked up on the next start. + if (HasTable(conn, "JobApplications")) + { + Exec(conn, """CREATE INDEX IF NOT EXISTS "IX_JobApplications_OwnerUserId_IsDeleted" ON "JobApplications" ("OwnerUserId", "IsDeleted");"""); + Exec(conn, """CREATE INDEX IF NOT EXISTS "IX_JobApplications_OwnerUserId_FollowUpAt" ON "JobApplications" ("OwnerUserId", "FollowUpAt");"""); + } + // Ensure data folder exists before creating/opening SQLite files. Directory.CreateDirectory(paths.DataRoot); } @@ -819,6 +829,22 @@ public static class StartupInitializationExtensions cmd.ExecuteNonQuery(); } + // Hot-path composite indexes for tenant-scoped list/board/stats/analytics + // (OwnerUserId + IsDeleted) and reminders (OwnerUserId + FollowUpAt). + if (!MySqlIndexExists(conn, "JobApplications", "IX_JobApplications_OwnerUserId_IsDeleted")) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "CREATE INDEX `IX_JobApplications_OwnerUserId_IsDeleted` ON `JobApplications` (`OwnerUserId`, `IsDeleted`);"; + cmd.ExecuteNonQuery(); + } + + if (!MySqlIndexExists(conn, "JobApplications", "IX_JobApplications_OwnerUserId_FollowUpAt")) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "CREATE INDEX `IX_JobApplications_OwnerUserId_FollowUpAt` ON `JobApplications` (`OwnerUserId`, `FollowUpAt`);"; + cmd.ExecuteNonQuery(); + } + if (!MySqlIndexExists(conn, "CvUploadArtifacts", "IX_CvUploadArtifacts_OwnerUserId_UploadedAtUtc")) { using var cmd = conn.CreateCommand(); -- 2.52.0 From 824251d328f21a2f6708bff4545563544d17dcd7 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sun, 5 Jul 2026 10:40:55 +0200 Subject: [PATCH 03/12] feat(ai): provider router (ollama|gemini|groq) for heavy CV calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The structured /cv/* calls funnel through a provider router so production can offload a weak local GPU (GTX 1060) to a cloud provider without any .NET change. Default stays "ollama" (keyless/local) and /summarize remains local distilbart. - AI_PROVIDER=ollama|gemini|groq dispatch inside _ollama_generate_json/_text (entry-point names kept, so no call sites change; Ollama path is byte-identical). - Gemini (x-goog-api-key header, not URL query) and Groq (OpenAI-compatible chat/completions) added via stdlib urllib — zero new dependencies. - /health reports ai_provider + ai_provider_configured. - Keys read from env only; never logged/committed. - Compose + .env.example pass AI_PROVIDER/GEMINI_*/GROQ_* through. Tests: 11 passed (default Ollama unchanged, Gemini/Groq dispatch, missing-key 503, health reports provider). Co-Authored-By: Claude Opus 4.8 --- .env.example | 10 ++ docker-compose.yml | 7 ++ tools/summarizer/app.py | 153 ++++++++++++++++++++--------- tools/summarizer/tests/test_app.py | 104 ++++++++++++++++++++ 4 files changed, 226 insertions(+), 48 deletions(-) diff --git a/.env.example b/.env.example index 6c6c2c9..2546581 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,16 @@ AI_SERVICE_BASE_URL=http://ai-service:8001 OLLAMA_BASE_URL=http://ollama:11434 OLLAMA_MODEL=qwen2.5:7b +# AI provider for the heavy /cv/* calls: ollama (default, local) | gemini | groq. +# /summarize always stays local (distilbart). To offload a weak production GPU, +# set AI_PROVIDER=gemini (or groq) and provide the matching key below. +# Keys are read from the environment only — never commit real keys. +AI_PROVIDER=ollama +GEMINI_API_KEY= +GEMINI_MODEL=gemini-2.0-flash +GROQ_API_KEY= +GROQ_MODEL=llama-3.3-70b-versatile + # Optional: only needed if you want the UI to call a non-default API base URL. # In production the UI defaults to `/api`. REACT_APP_API_BASE_URL= diff --git a/docker-compose.yml b/docker-compose.yml index 9672408..786b55b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -74,6 +74,13 @@ services: environment: - OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-http://ollama:11434} - OLLAMA_MODEL=${OLLAMA_MODEL:-qwen2.5:7b} + # AI provider for heavy /cv/* calls: ollama (default) | gemini | groq. + # Set AI_PROVIDER=gemini + GEMINI_API_KEY in prod to offload a weak local GPU. + - AI_PROVIDER=${AI_PROVIDER:-ollama} + - GEMINI_API_KEY=${GEMINI_API_KEY:-} + - GEMINI_MODEL=${GEMINI_MODEL:-gemini-2.0-flash} + - GROQ_API_KEY=${GROQ_API_KEY:-} + - GROQ_MODEL=${GROQ_MODEL:-llama-3.3-70b-versatile} ports: - "8001:8001" depends_on: diff --git a/tools/summarizer/app.py b/tools/summarizer/app.py index 81215a4..c0b4dfd 100644 --- a/tools/summarizer/app.py +++ b/tools/summarizer/app.py @@ -26,6 +26,18 @@ OCR_LANGUAGES = "eng" IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp"} OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://127.0.0.1:11434").rstrip("/") OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "") + +# AI provider router. Structured /cv/* calls (the heavy ones) dispatch through the +# active provider so production can offload a weak local GPU to a cloud provider. +# Default stays "ollama" so the service works keyless/local. /summarize stays local +# (distilbart) regardless of this setting. +AI_PROVIDER = (os.getenv("AI_PROVIDER", "ollama").strip().lower() or "ollama") +GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "").strip() +GEMINI_MODEL = os.getenv("GEMINI_MODEL", "gemini-2.0-flash").strip() +GEMINI_BASE_URL = os.getenv("GEMINI_BASE_URL", "https://generativelanguage.googleapis.com").rstrip("/") +GROQ_API_KEY = os.getenv("GROQ_API_KEY", "").strip() +GROQ_MODEL = os.getenv("GROQ_MODEL", "llama-3.3-70b-versatile").strip() +GROQ_BASE_URL = os.getenv("GROQ_BASE_URL", "https://api.groq.com/openai/v1").rstrip("/") SKIP_MODEL_LOAD = os.getenv("AI_SERVICE_SKIP_MODEL_LOAD", "") == "1" EAGER_MODEL_LOAD = os.getenv("AI_SERVICE_EAGER_MODEL_LOAD", "") == "1" @@ -174,6 +186,8 @@ async def health(): "model_disabled": MODEL_DISABLED, "summarize_available": MODEL_LOADED and not MODEL_DISABLED, "model_load_error": MODEL_LOAD_ERROR, + "ai_provider": AI_PROVIDER, + "ai_provider_configured": _provider_configured(), **_ollama_status(), } @@ -390,37 +404,106 @@ def _model_summarize(text: str, max_length: int, min_length: int) -> str: return tokenizer.decode(outputs[0], skip_special_tokens=True).strip() -def _ollama_generate_json(prompt: str): +_PROVIDER_DISPLAY = {"ollama": "Ollama", "gemini": "Gemini", "groq": "Groq"} + + +def _provider_display(provider: str) -> str: + return _PROVIDER_DISPLAY.get(provider, provider or "AI provider") + + +def _provider_configured() -> bool: + if AI_PROVIDER == "gemini": + return bool(GEMINI_API_KEY) + if AI_PROVIDER == "groq": + return bool(GROQ_API_KEY) + return bool(OLLAMA_MODEL) + + +def _http_post_json(url: str, payload: dict, headers: dict, timeout: int) -> dict: + data = json.dumps(payload).encode("utf-8") + req = urllib_request.Request( + url, + data=data, + headers={"Content-Type": "application/json", **headers}, + method="POST", + ) + with urllib_request.urlopen(req, timeout=timeout) as response: + return json.loads(response.read().decode("utf-8")) + + +def _ollama_generate(prompt: str, *, json_mode: bool, temperature: float, timeout: int) -> str: if not OLLAMA_MODEL: raise HTTPException(status_code=503, detail="OLLAMA_MODEL is not configured.") - - payload = json.dumps({ + payload = { "model": OLLAMA_MODEL, "prompt": prompt, "stream": False, - "format": "json", - "options": {"temperature": 0.1} - }).encode("utf-8") + "options": {"temperature": temperature}, + } + if json_mode: + payload["format"] = "json" + body = _http_post_json(f"{OLLAMA_BASE_URL}/api/generate", payload, {}, timeout) + return (body.get("response") or "").strip() - req = urllib_request.Request( - f"{OLLAMA_BASE_URL}/api/generate", - data=payload, - headers={"Content-Type": "application/json"}, - method="POST", - ) +def _gemini_generate(prompt: str, *, json_mode: bool, temperature: float, timeout: int) -> str: + if not GEMINI_API_KEY: + raise HTTPException(status_code=503, detail="GEMINI_API_KEY is not configured.") + generation_config = {"temperature": temperature} + if json_mode: + generation_config["responseMimeType"] = "application/json" + payload = { + "contents": [{"role": "user", "parts": [{"text": prompt}]}], + "generationConfig": generation_config, + } + # Pass the key via header (not the URL query string, which can leak into logs). + url = f"{GEMINI_BASE_URL}/v1beta/models/{GEMINI_MODEL}:generateContent" + body = _http_post_json(url, payload, {"x-goog-api-key": GEMINI_API_KEY}, timeout) + candidates = body.get("candidates") or [] + if not candidates: + return "" + parts = (candidates[0].get("content") or {}).get("parts") or [] + return "".join(part.get("text", "") for part in parts).strip() + + +def _groq_generate(prompt: str, *, json_mode: bool, temperature: float, timeout: int) -> str: + if not GROQ_API_KEY: + raise HTTPException(status_code=503, detail="GROQ_API_KEY is not configured.") + payload = { + "model": GROQ_MODEL, + "messages": [{"role": "user", "content": prompt}], + "temperature": temperature, + } + if json_mode: + payload["response_format"] = {"type": "json_object"} + url = f"{GROQ_BASE_URL}/chat/completions" + body = _http_post_json(url, payload, {"Authorization": f"Bearer {GROQ_API_KEY}"}, timeout) + choices = body.get("choices") or [] + if not choices: + return "" + return ((choices[0].get("message") or {}).get("content") or "").strip() + + +def _provider_generate(prompt: str, *, json_mode: bool, temperature: float, timeout: int) -> str: + provider = AI_PROVIDER try: - with urllib_request.urlopen(req, timeout=120) as response: - body = json.loads(response.read().decode("utf-8")) + if provider == "gemini": + return _gemini_generate(prompt, json_mode=json_mode, temperature=temperature, timeout=timeout) + if provider == "groq": + return _groq_generate(prompt, json_mode=json_mode, temperature=temperature, timeout=timeout) + return _ollama_generate(prompt, json_mode=json_mode, temperature=temperature, timeout=timeout) + except HTTPException: + raise except HTTPError as ex: - raise HTTPException(status_code=502, detail=f"Ollama request failed with {ex.code}.") + raise HTTPException(status_code=502, detail=f"{_provider_display(provider)} request failed with {ex.code}.") except URLError as ex: - raise HTTPException(status_code=503, detail=f"Ollama is unreachable: {ex.reason}.") + raise HTTPException(status_code=503, detail=f"{_provider_display(provider)} is unreachable: {ex.reason}.") - raw = (body.get("response") or "").strip() + +def _ollama_generate_json(prompt: str): + raw = _provider_generate(prompt, json_mode=True, temperature=0.1, timeout=120) if not raw: - raise HTTPException(status_code=502, detail="Ollama returned an empty response.") - + raise HTTPException(status_code=502, detail=f"{_provider_display(AI_PROVIDER)} returned an empty response.") try: return json.loads(raw) except json.JSONDecodeError: @@ -428,39 +511,13 @@ def _ollama_generate_json(prompt: str): end = raw.rfind("}") if start >= 0 and end > start: return json.loads(raw[start:end + 1]) - raise HTTPException(status_code=502, detail="Ollama did not return valid JSON.") + raise HTTPException(status_code=502, detail=f"{_provider_display(AI_PROVIDER)} did not return valid JSON.") def _ollama_generate_text(prompt: str) -> str: - if not OLLAMA_MODEL: - raise HTTPException(status_code=503, detail="OLLAMA_MODEL is not configured.") - - payload = json.dumps({ - "model": OLLAMA_MODEL, - "prompt": prompt, - "stream": False, - "options": {"temperature": 0.2} - }).encode("utf-8") - - req = urllib_request.Request( - f"{OLLAMA_BASE_URL}/api/generate", - data=payload, - headers={"Content-Type": "application/json"}, - method="POST", - ) - - try: - with urllib_request.urlopen(req, timeout=180) as response: - body = json.loads(response.read().decode("utf-8")) - except HTTPError as ex: - raise HTTPException(status_code=502, detail=f"Ollama request failed with {ex.code}.") - except URLError as ex: - raise HTTPException(status_code=503, detail=f"Ollama is unreachable: {ex.reason}.") - - raw = (body.get("response") or "").strip() + raw = _provider_generate(prompt, json_mode=False, temperature=0.2, timeout=180) if not raw: - raise HTTPException(status_code=502, detail="Ollama returned an empty rewrite.") - + raise HTTPException(status_code=502, detail=f"{_provider_display(AI_PROVIDER)} returned an empty rewrite.") return raw diff --git a/tools/summarizer/tests/test_app.py b/tools/summarizer/tests/test_app.py index fbfb1ae..100d3eb 100644 --- a/tools/summarizer/tests/test_app.py +++ b/tools/summarizer/tests/test_app.py @@ -1,4 +1,5 @@ import importlib +import json import sys from pathlib import Path @@ -141,3 +142,106 @@ def test_classify_block_defaults_missing_section_to_other(monkeypatch): assert payload["bullets"] == [] assert payload["summary"] == [] assert payload["skills"] == [] + + +# --- AI provider router ------------------------------------------------------- + +class _FakeResponse: + def __init__(self, payload): + self._data = json.dumps(payload).encode("utf-8") + + def read(self): + return self._data + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + +def _install_fake_urlopen(monkeypatch, module, response_payload, captured): + def fake_urlopen(req, timeout=None): + captured["url"] = req.full_url + captured["headers"] = {k.lower(): v for k, v in req.header_items()} + captured["body"] = json.loads(req.data.decode("utf-8")) + return _FakeResponse(response_payload) + + monkeypatch.setattr(module.urllib_request, "urlopen", fake_urlopen) + + +def test_provider_defaults_to_ollama_and_is_unchanged(monkeypatch): + monkeypatch.delenv("AI_PROVIDER", raising=False) + monkeypatch.setenv("OLLAMA_BASE_URL", "http://ollama-host:11434") + module = load_app_module(monkeypatch, ollama_model="qwen2.5:7b") + assert module.AI_PROVIDER == "ollama" + + captured = {} + _install_fake_urlopen(monkeypatch, module, {"response": '{"score": 7}'}, captured) + + assert module._ollama_generate_json("hi") == {"score": 7} + assert captured["url"] == "http://ollama-host:11434/api/generate" + assert captured["body"]["model"] == "qwen2.5:7b" + assert captured["body"]["format"] == "json" + assert captured["body"]["options"]["temperature"] == 0.1 + + +def test_provider_gemini_dispatch(monkeypatch): + monkeypatch.setenv("AI_PROVIDER", "gemini") + monkeypatch.setenv("GEMINI_API_KEY", "test-key") + monkeypatch.setenv("GEMINI_MODEL", "gemini-2.0-flash") + module = load_app_module(monkeypatch) + + captured = {} + payload = {"candidates": [{"content": {"parts": [{"text": '{"score": 9}'}]}}]} + _install_fake_urlopen(monkeypatch, module, payload, captured) + + assert module._ollama_generate_json("hi") == {"score": 9} + assert "generativelanguage" in captured["url"] + assert "gemini-2.0-flash:generateContent" in captured["url"] + assert "key=" not in captured["url"] # key must not be in the URL + assert captured["headers"].get("x-goog-api-key") == "test-key" + assert captured["body"]["generationConfig"]["responseMimeType"] == "application/json" + + +def test_provider_groq_dispatch(monkeypatch): + monkeypatch.setenv("AI_PROVIDER", "groq") + monkeypatch.setenv("GROQ_API_KEY", "test-key") + module = load_app_module(monkeypatch) + + captured = {} + payload = {"choices": [{"message": {"content": "rewritten CV text"}}]} + _install_fake_urlopen(monkeypatch, module, payload, captured) + + assert module._ollama_generate_text("rewrite this") == "rewritten CV text" + assert captured["url"].endswith("/chat/completions") + assert captured["headers"].get("authorization") == "Bearer test-key" + assert captured["body"]["messages"][0]["content"] == "rewrite this" + + +def test_provider_missing_cloud_key_raises_503(monkeypatch): + monkeypatch.setenv("AI_PROVIDER", "gemini") + monkeypatch.delenv("GEMINI_API_KEY", raising=False) + module = load_app_module(monkeypatch) + + from fastapi import HTTPException + + try: + module._ollama_generate_json("hi") + except HTTPException as ex: + assert ex.status_code == 503 + assert "GEMINI_API_KEY" in ex.detail + else: + raise AssertionError("expected HTTPException for missing GEMINI_API_KEY") + + +def test_health_reports_active_provider(monkeypatch): + monkeypatch.setenv("AI_PROVIDER", "gemini") + monkeypatch.setenv("GEMINI_API_KEY", "test-key") + module = load_app_module(monkeypatch) + client = TestClient(module.app) + + payload = client.get("/health").json() + + assert payload["ai_provider"] == "gemini" + assert payload["ai_provider_configured"] is True -- 2.52.0 From 2989a6fa2cc40827792191e0c45a44478f28e064 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sun, 5 Jul 2026 10:51:08 +0200 Subject: [PATCH 04/12] refactor(analytics): extract AnalyticsService from JobApplicationsController First Wave 2 (safe refactor) slice. Move the read-only stats/overview aggregation out of the 3.3k-line JobApplicationsController into a dedicated, injectable AnalyticsService, and lift its response DTOs (JobStats, FunnelStagePoint, ResponseRatePoint, CompanyActivityPoint, AnalyticsOverviewDto) into Models/AnalyticsDtos.cs. - GetStats: ~44 lines -> 3 (delegates to AnalyticsService.GetStatsAsync). - GetAnalyticsOverview: ~82 lines -> 3 (delegates to GetAnalyticsOverviewAsync). - Registered AddScoped(); controller keeps an optional ctor param with a `?? new AnalyticsService(db)` fallback so the 6 test sites that construct the controller directly keep compiling. - Logic is byte-identical (same tenant-scoped context, same projections) so behaviour is preserved. Backend suite: 92/92 green. Co-Authored-By: Claude Opus 4.8 --- .../Controllers/JobApplicationsController.cs | 140 +----------------- JobTrackerApi/Program.cs | 1 + JobTrackerApi/Services/AnalyticsService.cs | 140 ++++++++++++++++++ Models/AnalyticsDtos.cs | 30 ++++ 4 files changed, 176 insertions(+), 135 deletions(-) create mode 100644 JobTrackerApi/Services/AnalyticsService.cs create mode 100644 Models/AnalyticsDtos.cs diff --git a/JobTrackerApi/Controllers/JobApplicationsController.cs b/JobTrackerApi/Controllers/JobApplicationsController.cs index 2795bf5..35bcf26 100644 --- a/JobTrackerApi/Controllers/JobApplicationsController.cs +++ b/JobTrackerApi/Controllers/JobApplicationsController.cs @@ -23,8 +23,9 @@ namespace JobTrackerApi.Controllers private readonly ILogger _logger; private readonly ICvTemplateRenderer _cvTemplateRenderer; private readonly ICvPdfExporter _cvPdfExporter; + private readonly AnalyticsService _analytics; - public JobApplicationsController(JobTrackerContext db, ISummarizerService summarizer, IAppEmailSender email, UserManager users, ILogger logger, ICvTemplateRenderer? cvTemplateRenderer = null, ICvPdfExporter? cvPdfExporter = null) + public JobApplicationsController(JobTrackerContext db, ISummarizerService summarizer, IAppEmailSender email, UserManager users, ILogger logger, ICvTemplateRenderer? cvTemplateRenderer = null, ICvPdfExporter? cvPdfExporter = null, AnalyticsService? analytics = null) { _db = db; _summarizer = summarizer; @@ -33,6 +34,7 @@ namespace JobTrackerApi.Controllers _logger = logger; _cvTemplateRenderer = cvTemplateRenderer ?? new CvTemplateRenderer(); _cvPdfExporter = cvPdfExporter ?? new ThrowingCvPdfExporter(); + _analytics = analytics ?? new AnalyticsService(db); } private sealed class ThrowingCvPdfExporter : ICvPdfExporter @@ -1736,51 +1738,9 @@ Canonical profile: return Ok(all); } - public sealed record JobStats( - int Total, - int Active, - int Deleted, - Dictionary ByStatus, - int AppliedLast30Days, - double AverageDaysSinceApplied - ); - [HttpGet("stats")] public async Task> GetStats(CancellationToken cancellationToken) - { - var now = DateTime.Now; - - // Project to only the columns the stats need instead of materialising full - // JobApplication rows (which drag large Description/TranslatedDescription/ - // TailoredCvText/Notes blobs). Aggregation stays in memory over a small - // per-tenant set. Mirrors the projection pattern used by GetTagTrends. - var all = await _db.JobApplications - .AsNoTracking() - .Select(j => new { j.IsDeleted, j.Status, j.DateApplied }) - .ToListAsync(cancellationToken); - - var active = all.Where(j => !j.IsDeleted).ToList(); - - var byStatus = active - .GroupBy(j => string.IsNullOrWhiteSpace(j.Status) ? "Unknown" : j.Status) - .OrderByDescending(g => g.Count()) - .ToDictionary(g => g.Key, g => g.Count()); - - var appliedLast30Days = active.Count(j => (now - j.DateApplied).TotalDays <= 30); - - var avgDays = active.Count == 0 - ? 0 - : active.Average(j => Math.Max(0, (now - j.DateApplied).TotalDays)); - - return Ok(new JobStats( - Total: all.Count, - Active: active.Count, - Deleted: all.Count - active.Count, - ByStatus: byStatus, - AppliedLast30Days: appliedLast30Days, - AverageDaysSinceApplied: Math.Round(avgDays, 1) - )); - } + => Ok(await _analytics.GetStatsAsync(cancellationToken)); public sealed record AnalyticsPoint(string Month, int Applied, int Responses); [HttpGet("analytics")] @@ -1977,19 +1937,8 @@ Canonical profile: return Ok(outList); } - public sealed record FunnelStagePoint(string Label, int Count); - public sealed record ResponseRatePoint(string Label, int Total, int Responses, double Rate); - public sealed record CompanyActivityPoint(int CompanyId, string Company, int Count, int Responses, double ResponseRate); public sealed record TagTrendSeries(string Tag, List Counts); public sealed record TagTrendPoint(string Month, List Counts); - public sealed record AnalyticsOverviewDto( - List Funnel, - List ResponseRateBySource, - List TopCompanies, - double? MedianDaysToFirstResponse, - int TotalResponses, - int TotalActive - ); public sealed record DuplicateCandidateDto(int Id, string JobTitle, string Company, string? JobUrl, string Status, DateTime DateApplied, string Reason); public sealed record DuplicateCheckResult(bool HasDuplicates, List Matches); public sealed record FollowUpDraftDto(string Subject, string Body, string Reason, DateTime SuggestedSendOn, string ContextSummary, List ContextSignals, string? ThreadSubject, string? LastCorrespondenceFrom, DateTime? LastCorrespondenceAt); @@ -2672,86 +2621,7 @@ Candidate master CV: [HttpGet("analytics-overview")] public async Task> GetAnalyticsOverview(CancellationToken cancellationToken) - { - // Project to only the fields the overview needs instead of Include-ing full - // Company + JobApplication rows (avoids loading large description/CV blobs). - var activeJobs = await _db.JobApplications - .AsNoTracking() - .Where(j => !j.IsDeleted) - .Select(j => new - { - j.Status, - j.ResponseReceived, - j.ResponseDate, - j.DateApplied, - j.CompanyId, - CompanyName = j.Company.Name, - CompanySource = j.Company.Source - }) - .ToListAsync(cancellationToken); - - var funnelMap = new Dictionary - { - ["Applied"] = activeJobs.Count(j => string.Equals(j.Status, "Applied", StringComparison.OrdinalIgnoreCase)), - ["Interview"] = activeJobs.Count(j => string.Equals(j.Status, "Interview", StringComparison.OrdinalIgnoreCase) || string.Equals(j.Status, "Interviewing", StringComparison.OrdinalIgnoreCase)), - ["Offer"] = activeJobs.Count(j => string.Equals(j.Status, "Offer", StringComparison.OrdinalIgnoreCase)), - ["Rejected"] = activeJobs.Count(j => string.Equals(j.Status, "Rejected", StringComparison.OrdinalIgnoreCase)), - ["Ghosted"] = activeJobs.Count(j => string.Equals(j.Status, "Ghosted", StringComparison.OrdinalIgnoreCase)), - }; - - var funnel = funnelMap.Select(x => new FunnelStagePoint(x.Key, x.Value)).ToList(); - - var responseRateBySource = activeJobs - .GroupBy(j => string.IsNullOrWhiteSpace(j.CompanySource) ? "Unknown source" : j.CompanySource!.Trim()) - .Select(g => new ResponseRatePoint( - g.Key, - g.Count(), - g.Count(x => x.ResponseReceived || x.ResponseDate is not null), - Math.Round(g.Count(x => x.ResponseReceived || x.ResponseDate is not null) * 100d / Math.Max(1, g.Count()), 1) - )) - .OrderByDescending(x => x.Total) - .ThenByDescending(x => x.Rate) - .Take(6) - .ToList(); - - var topCompanies = activeJobs - .GroupBy(j => new { j.CompanyId, Name = j.CompanyName }) - .Select(g => new CompanyActivityPoint( - g.Key.CompanyId, - g.Key.Name, - g.Count(), - g.Count(x => x.ResponseReceived || x.ResponseDate is not null), - Math.Round(g.Count(x => x.ResponseReceived || x.ResponseDate is not null) * 100d / Math.Max(1, g.Count()), 1) - )) - .OrderByDescending(x => x.Count) - .ThenByDescending(x => x.ResponseRate) - .Take(8) - .ToList(); - - var responseDays = activeJobs - .Where(j => (j.ResponseReceived || j.ResponseDate is not null) && j.ResponseDate is not null) - .Select(j => Math.Max(0, (j.ResponseDate!.Value - j.DateApplied).TotalDays)) - .OrderBy(x => x) - .ToList(); - - double? medianDays = null; - if (responseDays.Count > 0) - { - var mid = responseDays.Count / 2; - medianDays = responseDays.Count % 2 == 0 - ? Math.Round((responseDays[mid - 1] + responseDays[mid]) / 2d, 1) - : Math.Round(responseDays[mid], 1); - } - - return Ok(new AnalyticsOverviewDto( - Funnel: funnel, - ResponseRateBySource: responseRateBySource, - TopCompanies: topCompanies, - MedianDaysToFirstResponse: medianDays, - TotalResponses: activeJobs.Count(j => j.ResponseReceived || j.ResponseDate is not null), - TotalActive: activeJobs.Count - )); - } + => Ok(await _analytics.GetAnalyticsOverviewAsync(cancellationToken)); [HttpGet("tag-trends")] public async Task> GetTagTrends( diff --git a/JobTrackerApi/Program.cs b/JobTrackerApi/Program.cs index a76b189..b914b42 100644 --- a/JobTrackerApi/Program.cs +++ b/JobTrackerApi/Program.cs @@ -153,6 +153,7 @@ builder.Services.AddHttpClient("ai-service", client => }); builder.Services.AddMemoryCache(); +builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/JobTrackerApi/Services/AnalyticsService.cs b/JobTrackerApi/Services/AnalyticsService.cs new file mode 100644 index 0000000..3b95ecd --- /dev/null +++ b/JobTrackerApi/Services/AnalyticsService.cs @@ -0,0 +1,140 @@ +using Microsoft.EntityFrameworkCore; +using JobTrackerApi.Data; +using JobTrackerApi.Models; + +namespace JobTrackerApi.Services +{ + /// + /// Read-only analytics/statistics aggregation extracted from JobApplicationsController. + /// Uses the tenant-scoped , so the global OwnerUserId + /// query filters apply automatically. Behaviour is identical to the former inline + /// controller methods (GetStats / GetAnalyticsOverview). + /// + public sealed class AnalyticsService + { + private readonly JobTrackerContext _db; + + public AnalyticsService(JobTrackerContext db) + { + _db = db; + } + + public async Task GetStatsAsync(CancellationToken cancellationToken) + { + var now = DateTime.Now; + + // Project to only the columns the stats need instead of materialising full + // JobApplication rows (which drag large Description/TranslatedDescription/ + // TailoredCvText/Notes blobs). Aggregation stays in memory over a small + // per-tenant set. + var all = await _db.JobApplications + .AsNoTracking() + .Select(j => new { j.IsDeleted, j.Status, j.DateApplied }) + .ToListAsync(cancellationToken); + + var active = all.Where(j => !j.IsDeleted).ToList(); + + var byStatus = active + .GroupBy(j => string.IsNullOrWhiteSpace(j.Status) ? "Unknown" : j.Status) + .OrderByDescending(g => g.Count()) + .ToDictionary(g => g.Key, g => g.Count()); + + var appliedLast30Days = active.Count(j => (now - j.DateApplied).TotalDays <= 30); + + var avgDays = active.Count == 0 + ? 0 + : active.Average(j => Math.Max(0, (now - j.DateApplied).TotalDays)); + + return new JobStats( + Total: all.Count, + Active: active.Count, + Deleted: all.Count - active.Count, + ByStatus: byStatus, + AppliedLast30Days: appliedLast30Days, + AverageDaysSinceApplied: Math.Round(avgDays, 1) + ); + } + + public async Task GetAnalyticsOverviewAsync(CancellationToken cancellationToken) + { + // Project to only the fields the overview needs instead of Include-ing full + // Company + JobApplication rows (avoids loading large description/CV blobs). + var activeJobs = await _db.JobApplications + .AsNoTracking() + .Where(j => !j.IsDeleted) + .Select(j => new + { + j.Status, + j.ResponseReceived, + j.ResponseDate, + j.DateApplied, + j.CompanyId, + CompanyName = j.Company.Name, + CompanySource = j.Company.Source + }) + .ToListAsync(cancellationToken); + + var funnelMap = new Dictionary + { + ["Applied"] = activeJobs.Count(j => string.Equals(j.Status, "Applied", StringComparison.OrdinalIgnoreCase)), + ["Interview"] = activeJobs.Count(j => string.Equals(j.Status, "Interview", StringComparison.OrdinalIgnoreCase) || string.Equals(j.Status, "Interviewing", StringComparison.OrdinalIgnoreCase)), + ["Offer"] = activeJobs.Count(j => string.Equals(j.Status, "Offer", StringComparison.OrdinalIgnoreCase)), + ["Rejected"] = activeJobs.Count(j => string.Equals(j.Status, "Rejected", StringComparison.OrdinalIgnoreCase)), + ["Ghosted"] = activeJobs.Count(j => string.Equals(j.Status, "Ghosted", StringComparison.OrdinalIgnoreCase)), + }; + + var funnel = funnelMap.Select(x => new FunnelStagePoint(x.Key, x.Value)).ToList(); + + var responseRateBySource = activeJobs + .GroupBy(j => string.IsNullOrWhiteSpace(j.CompanySource) ? "Unknown source" : j.CompanySource!.Trim()) + .Select(g => new ResponseRatePoint( + g.Key, + g.Count(), + g.Count(x => x.ResponseReceived || x.ResponseDate is not null), + Math.Round(g.Count(x => x.ResponseReceived || x.ResponseDate is not null) * 100d / Math.Max(1, g.Count()), 1) + )) + .OrderByDescending(x => x.Total) + .ThenByDescending(x => x.Rate) + .Take(6) + .ToList(); + + var topCompanies = activeJobs + .GroupBy(j => new { j.CompanyId, Name = j.CompanyName }) + .Select(g => new CompanyActivityPoint( + g.Key.CompanyId, + g.Key.Name, + g.Count(), + g.Count(x => x.ResponseReceived || x.ResponseDate is not null), + Math.Round(g.Count(x => x.ResponseReceived || x.ResponseDate is not null) * 100d / Math.Max(1, g.Count()), 1) + )) + .OrderByDescending(x => x.Count) + .ThenByDescending(x => x.ResponseRate) + .Take(8) + .ToList(); + + var responseDays = activeJobs + .Where(j => (j.ResponseReceived || j.ResponseDate is not null) && j.ResponseDate is not null) + .Select(j => Math.Max(0, (j.ResponseDate!.Value - j.DateApplied).TotalDays)) + .OrderBy(x => x) + .ToList(); + + double? medianDays = null; + if (responseDays.Count > 0) + { + var mid = responseDays.Count / 2; + medianDays = responseDays.Count % 2 == 0 + ? Math.Round((responseDays[mid - 1] + responseDays[mid]) / 2d, 1) + : Math.Round(responseDays[mid], 1); + } + + return new AnalyticsOverviewDto( + Funnel: funnel, + ResponseRateBySource: responseRateBySource, + TopCompanies: topCompanies, + MedianDaysToFirstResponse: medianDays, + TotalResponses: activeJobs.Count(j => j.ResponseReceived || j.ResponseDate is not null), + TotalActive: activeJobs.Count + ); + } + } +} diff --git a/Models/AnalyticsDtos.cs b/Models/AnalyticsDtos.cs new file mode 100644 index 0000000..046cdfb --- /dev/null +++ b/Models/AnalyticsDtos.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; + +namespace JobTrackerApi.Models +{ + // Read-only analytics/statistics response DTOs. Extracted from + // JobApplicationsController so the aggregation logic can live in AnalyticsService. + public sealed record JobStats( + int Total, + int Active, + int Deleted, + Dictionary ByStatus, + int AppliedLast30Days, + double AverageDaysSinceApplied + ); + + public sealed record FunnelStagePoint(string Label, int Count); + + public sealed record ResponseRatePoint(string Label, int Total, int Responses, double Rate); + + public sealed record CompanyActivityPoint(int CompanyId, string Company, int Count, int Responses, double ResponseRate); + + public sealed record AnalyticsOverviewDto( + List Funnel, + List ResponseRateBySource, + List TopCompanies, + double? MedianDaysToFirstResponse, + int TotalResponses, + int TotalActive + ); +} -- 2.52.0 From e5e2c65709b8ee07758a4105cd8888d264829fd3 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sun, 5 Jul 2026 11:13:22 +0200 Subject: [PATCH 05/12] feat(ui): adopt JobTrack mockup design tokens (indigo brand, light dashboard) First UI slice toward the product mockups. Shifts the shared MUI theme to match the mockup identity without restructuring components: - Default accent -> indigo #6366f1 (drives primary buttons, active nav, and the Applied/Interview status colours). Users with a custom accent keep theirs. - Light app background -> #F4F6FB with white paper, giving the layered dashboard look (cards/inputs sit above a soft grey canvas). - Global corner radius 8 -> 10 to match the mockups' rounder cards. - Accent picker: offer indigo/cyan/violet presets first (kept #15803d). Behaviour-only theme change. Frontend suite: 38/39 pass; the 1 failure (end-to-end-trust-loop tailored-CV) is pre-existing on this branch (verified by running it on the clean tree) and unrelated. Co-Authored-By: Claude Opus 4.8 --- job-tracker-ui/src/components/SettingsView.tsx | 2 +- job-tracker-ui/src/theme.ts | 14 ++++++++------ job-tracker-ui/src/themePrefs.ts | 2 +- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/job-tracker-ui/src/components/SettingsView.tsx b/job-tracker-ui/src/components/SettingsView.tsx index 30ee4d9..f5bde8d 100644 --- a/job-tracker-ui/src/components/SettingsView.tsx +++ b/job-tracker-ui/src/components/SettingsView.tsx @@ -45,7 +45,7 @@ function TabPanel({ value, index, children }: { value: number; index: number; ch return {children}; } -const ACCENTS = ["#15803d", "#16a34a", "#22c55e", "#0f766e", "#2563eb", "#65a30d", "#8b5cf6", "#f97316"]; +const ACCENTS = ["#6366f1", "#22d3ee", "#2563eb", "#8b5cf6", "#15803d", "#16a34a", "#0f766e", "#f97316"]; const NOTIFICATION_PREFS_KEY = "settings.notificationPrefs"; type NotificationPrefs = { diff --git a/job-tracker-ui/src/theme.ts b/job-tracker-ui/src/theme.ts index d3a85a5..39de651 100644 --- a/job-tracker-ui/src/theme.ts +++ b/job-tracker-ui/src/theme.ts @@ -24,7 +24,7 @@ function buildLightPalette(accentColor: string): PaletteLike { const disabledBackground = "#E4E1E6"; return { - primary: buildPrimary(accentColor || "#15803D"), + primary: buildPrimary(accentColor || "#6366F1"), secondary: { lighter: "#E0E0FF", light: "#C3C4E4", @@ -78,9 +78,11 @@ function buildLightPalette(accentColor: string): PaletteLike { disabled, }, divider, - background: { default: background, paper: background }, + // Soft grey app background with white paper gives the layered dashboard look + // from the product mockups; cards/inputs (paper) sit above it. + background: { default: "#F4F6FB", paper: background }, action: { - hover: alpha(accentColor || "#15803D", 0.05), + hover: alpha(accentColor || "#6366F1", 0.05), disabled: alpha(disabled, 0.6), disabledBackground: alpha(disabledBackground, 0.9), }, @@ -99,7 +101,7 @@ function buildDarkPalette(accentColor: string): PaletteLike { const disabledBackground = alpha("#FFFFFF", 0.08); return { - primary: buildPrimary(accentColor || "#15803D"), + primary: buildPrimary(accentColor || "#6366F1"), secondary: { lighter: alpha(secondaryMain, 0.22), light: alpha(secondaryMain, 0.14), @@ -155,7 +157,7 @@ function buildDarkPalette(accentColor: string): PaletteLike { divider, background: { default: bg, paper }, action: { - hover: alpha(accentColor || "#15803D", 0.16), + hover: alpha(accentColor || "#6366F1", 0.16), disabled: alpha("#FFFFFF", 0.5), disabledBackground, }, @@ -216,7 +218,7 @@ export const getTheme = (_mode: "light" | "dark", accentColor: string) => { light: { palette: lightPalette, customShadows: buildCustomShadows(lightPalette) }, dark: { palette: darkPalette, customShadows: buildCustomShadows(darkPalette) }, }, - shape: { borderRadius: 8 }, + shape: { borderRadius: 10 }, typography: buildTypography() as any, } as any) as any; diff --git a/job-tracker-ui/src/themePrefs.ts b/job-tracker-ui/src/themePrefs.ts index f694ce4..45bc434 100644 --- a/job-tracker-ui/src/themePrefs.ts +++ b/job-tracker-ui/src/themePrefs.ts @@ -23,7 +23,7 @@ export function setThemeModePref(v: ThemeModePref) { export function getAccentColor(): string { const raw = window.localStorage.getItem(k("accentColor")); if (raw && /^#[0-9a-fA-F]{6}$/.test(raw)) return raw; - return "#15803d"; + return "#6366f1"; } export function setAccentColor(v: string) { -- 2.52.0 From eed9b1fa807d27f329f0f7039344e13f9dbb1a32 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sun, 5 Jul 2026 14:57:22 +0200 Subject: [PATCH 06/12] fix(profile): revoke CV-preview blob URLs on unmount, not on every change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PDF-carousel cleanup effect had `[pdfCarousel]` deps, so its cleanup ran on every carousel change and revoked the *previous* array's object URLs — which are still referenced by unchanged items in the new array. Building a multi-template deck therefore left every preview except the last with a revoked (broken) blob URL. Drop paths are already handled explicitly in savePdfToCarousel (replace) and resetPdfCarousel (clear), so blanket per-change revocation was both harmful and redundant. Fix: track the latest carousel in a ref and revoke outstanding URLs only on unmount (empty-deps effect). No leak either way — unmount still frees them. Found during the memory-leak/resource audit; this is a resource-release correctness bug (over-eager revocation), not a leak. profile-page.test: 5/5 green (with an adequate timeout; the suite's 5s-timeout flakiness is pre-existing and unrelated). Co-Authored-By: Claude Opus 4.8 --- job-tracker-ui/src/pages/ProfilePage.tsx | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/job-tracker-ui/src/pages/ProfilePage.tsx b/job-tracker-ui/src/pages/ProfilePage.tsx index 37ff125..3b35b00 100644 --- a/job-tracker-ui/src/pages/ProfilePage.tsx +++ b/job-tracker-ui/src/pages/ProfilePage.tsx @@ -267,15 +267,27 @@ export default function ProfilePage() { const [currentPassword, setCurrentPassword] = useState(""); const [newPassword, setNewPassword] = useState(""); + // Keep a ref to the latest carousel so the unmount cleanup can revoke the + // outstanding preview object URLs without re-running on every change. + const pdfCarouselRef = useRef([]); useEffect(() => { + pdfCarouselRef.current = pdfCarousel; + }, [pdfCarousel]); + + useEffect(() => { + // Revoke any remaining preview object URLs only on unmount. Per-change + // revocation is already handled explicitly in savePdfToCarousel (replace) and + // resetPdfCarousel (clear); doing it here on every pdfCarousel change revoked + // URLs that were still referenced by other items in the deck, breaking their + // previews. return () => { - pdfCarousel.forEach((item) => { + pdfCarouselRef.current.forEach((item) => { if (item.pdfUrl) { window.URL.revokeObjectURL(item.pdfUrl); } }); }; - }, [pdfCarousel]); + }, []); const loadProfile = useCallback(async () => { setLoading(true); -- 2.52.0 From 39266c0935936545399b0bee4f6cebb60875cc3b Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sun, 5 Jul 2026 14:59:28 +0200 Subject: [PATCH 07/12] docs(perf): memory-leak investigation reports (no leak found) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Evidence-based investigation across every leak vector (timers, listeners, object URLs, observers, websockets, static server collections, IMemoryCache, Python caches). Verdict: no confirmed memory leak — the codebase has disciplined cleanup. One resource-release correctness bug (over-eager blob-URL revocation in the CV carousel) was found and fixed (eed9b1f). Adds docs/performance/: MEMORY_LEAK_REPORT.md, ROOT_CAUSE_ANALYSIS.md, PERFORMANCE_IMPROVEMENTS.md. Co-Authored-By: Claude Opus 4.8 --- docs/performance/MEMORY_LEAK_REPORT.md | 83 ++++++++++++++++++++ docs/performance/PERFORMANCE_IMPROVEMENTS.md | 60 ++++++++++++++ docs/performance/ROOT_CAUSE_ANALYSIS.md | 67 ++++++++++++++++ 3 files changed, 210 insertions(+) create mode 100644 docs/performance/MEMORY_LEAK_REPORT.md create mode 100644 docs/performance/PERFORMANCE_IMPROVEMENTS.md create mode 100644 docs/performance/ROOT_CAUSE_ANALYSIS.md diff --git a/docs/performance/MEMORY_LEAK_REPORT.md b/docs/performance/MEMORY_LEAK_REPORT.md new file mode 100644 index 0000000..9f528de --- /dev/null +++ b/docs/performance/MEMORY_LEAK_REPORT.md @@ -0,0 +1,83 @@ +# Memory Leak Report — Job Tracker + +**Date:** 2026-07-05 +**Investigator role:** Senior Performance Engineer (memory/browser internals/full-stack) +**Verdict:** **No confirmed memory leak.** One *resource-release correctness* bug (over-eager blob-URL +revocation) was found and fixed; it is the opposite of a leak. See [ROOT_CAUSE_ANALYSIS.md](ROOT_CAUSE_ANALYSIS.md) +and [PERFORMANCE_IMPROVEMENTS.md](PERFORMANCE_IMPROVEMENTS.md). + +> Method & honesty note. The app is a data-driven SPA that renders only after the backend answers +> `/auth/config` + `/auth/me`; headless (no backend/DB) it sits on a "Loading…" screen, so live +> DevTools heap-snapshot/allocation-timeline profiling of populated screens was **not** performed in this +> environment. Evidence here is therefore **static code analysis of every known leak vector** plus the +> existing automated test suite. Where a runtime confirmation is still advisable, it is called out +> explicitly. Per the mission's Final Rule, nothing below is reported as a leak unless the code path +> actually retains memory — and none did. + +--- + +## Phase 1–2 — Does a leak exist? Can it be reproduced? + +No leak was reproduced or evidenced. The classic React/browser leak vectors were each checked in code and +found to have correct teardown. "Memory grows while using the app" (the usual trigger for this kind of +investigation) is explained by **expected behaviour** — MUI/emulator caches, route-level component state, +and delayed GC — not by retained graphs. There is no growing global collection, no unremoved listener, no +uncleared timer, and no real-time connection to leak. + +## Phase 3 / 3.5 — Vector-by-vector evidence + +| Vector | Finding | Evidence | Verdict | +|---|---|---|---| +| **Timers / intervals** | Both `setInterval`s clear on cleanup | `App.tsx:154-155` (reminders, 60s → `clearInterval`); `ProfilePage.tsx:319-323` (extraction poll, 4s → `clearInterval`) | ✅ no leak | +| **`setTimeout`** | Used only for one-shot object-URL revokes | `BackupCard.tsx:29`, `Attachments.tsx:193`, `ImportExportJobs.tsx:21` | ✅ no leak | +| **Event listeners** | Every `addEventListener` has a matching `removeEventListener` in the effect cleanup | `App.tsx:174-175` (auth-changed), `App.tsx:185-186` (keydown), `CropImageDialog.tsx:114-124` (mouse/touch drag ×4) | ✅ no leak | +| **Object URLs (media)** | Created URLs are revoked on cleanup/timeout | `CropImageDialog.tsx:59/65`, `Attachments.tsx:111/181/193/201`, `BackupCard.tsx:18/29`, `ImportExportJobs.tsx:16/21`, `JobDetailsDialog.tsx:507/514`, `ProfilePage.tsx` (see fix) | ✅ no leak (1 over-revoke bug fixed) | +| **Observers** | None used | grep: no `ResizeObserver` / `IntersectionObserver` / `MutationObserver` in `src/` | ✅ n/a | +| **WebSocket / SSE / SignalR** | None used | grep: no `new WebSocket` / `EventSource` / SignalR client anywhere | ✅ n/a | +| **Signal/event subscriptions** | Only the `window` `"auth-changed"` custom event; unsubscribed on cleanup | `App.tsx:157-176` | ✅ no leak | +| **Global/module state (client)** | No module-level mutable collection that grows unbounded | grep for module-scope `Map`/array caches — none accumulating | ✅ no leak | +| **Client caches (localStorage)** | Bounded keys (prefs, columns, saved views); no per-event append | `App.tsx`, `SettingsView.tsx`, `SavedViewsMenu.tsx`, `themePrefs.ts` | ✅ no leak | +| **React effects w/o cleanup** | All effects reviewed return cleanup where they acquire resources | see rows above | ✅ no leak | +| **Server static collections** | All `static` collections are **fixed lookup tables** or **method return types**, never growing fields | `AttachmentsController`, `AuthController`, `ProfileCvController`, `HumanLanguageCatalog`, `StructuredCvProfileJson` | ✅ no leak | +| **Server `IMemoryCache`** | Bounded: OAuth state entries expire in 15 min and are removed on consume | `GmailOAuthService.cs:72` (`TimeSpan.FromMinutes(15)`), `:133-138` (`TryGetValue`+`Remove`) | ✅ no leak | +| **AI service (Python) caches** | `cachetools.TTLCache` (bounded by TTL + maxsize) | `tools/summarizer/app.py:4` | ✅ no leak | +| **Server timers / background** | Hosted services use scoped DI + `PeriodicTimer`/delays; no accumulating handlers | `FollowUpReminderHostedService`, `RulesHostedService`, `JobEnrichmentHostedService`, etc. | ✅ no leak | + +## Phase 3.5 — Repeated/duplicate work audit + +- **Reminders poll** (`App.tsx:151`, every 60s): correct URL `/jobapplications/reminders`, cheap, cleaned + up. (An earlier read rendered the path with backslashes — a display artifact; the source uses forward + slashes. **No bug.**) +- **Extraction-run poll** (`ProfilePage.tsx:315-324`, every 4s): effect deps `[extractionRuns, loadProfile]` + and `extractionRuns` changes each poll, so the interval is torn down + recreated every 4s while a run is + active. **Not a leak** (cleanup runs); benign churn that self-terminates when runs finish. Minor — see + [PERFORMANCE_IMPROVEMENTS.md](PERFORMANCE_IMPROVEMENTS.md). +- No duplicate subscriptions, no retry storms, no infinite render loops observed. + +## Phase 4 — Root cause +No leak → no leak root cause. The single defect found is an *over-release* (revoking blob URLs still in +use), root-caused in [ROOT_CAUSE_ANALYSIS.md](ROOT_CAUSE_ANALYSIS.md). + +## Phase 5 — Fix +`fix(profile): revoke CV-preview blob URLs on unmount, not on every change` (commit `eed9b1f`). Smallest +change: track the carousel in a ref and revoke only on unmount. + +## Phase 6 — Verification +`profile-page.test.tsx` passes **5/5** with an adequate test timeout after the fix. The broader suite's +intermittent timeouts are a **pre-existing** flakiness of the heavy RTL suites (verified: they fail +identically on the clean tree; three of them don't touch `ProfilePage`). + +## Phase 7 — Regression audit +Swept all object-URL, timer, and listener sites (table above). No other instance of the over-revoke +pattern, and no missing-cleanup pattern, was found. + +## Remaining risks / recommendations +- Live heap-snapshot profiling on a **populated** session (real backend) is still worth doing once, to + confirm the static conclusion under real navigation — see [PERFORMANCE_IMPROVEMENTS.md](PERFORMANCE_IMPROVEMENTS.md). +- Keep the disciplined cleanup pattern (this codebase is already good at it). + +## Security-audit note (standing instruction) +The single code change is a client-side blob-URL revocation-timing fix: no auth/authz surface, no new user +input, no data exposure, no injection vector, no secret handling. Nothing for the security lens to flag. +Existing protections (HttpOnly-cookie + CSRF auth, SSRF blocklist, global query-filter tenancy) are +untouched. diff --git a/docs/performance/PERFORMANCE_IMPROVEMENTS.md b/docs/performance/PERFORMANCE_IMPROVEMENTS.md new file mode 100644 index 0000000..976cfad --- /dev/null +++ b/docs/performance/PERFORMANCE_IMPROVEMENTS.md @@ -0,0 +1,60 @@ +# Performance Improvements — Job Tracker + +**Companion to:** [MEMORY_LEAK_REPORT.md](MEMORY_LEAK_REPORT.md) · [ROOT_CAUSE_ANALYSIS.md](ROOT_CAUSE_ANALYSIS.md) + +## Changes made (this pass) +| Change | File | Effect | Verified | +|---|---|---|---| +| Revoke CV-preview blob URLs on unmount only (ref-based), not on every carousel change | `job-tracker-ui/src/pages/ProfilePage.tsx` | Fixes broken previews on multi-template decks; still frees URLs on unmount | `profile-page.test.tsx` 5/5 | + +> Context: this was the only defect found in a full resource audit. The codebase already practises +> disciplined cleanup (timers cleared, listeners removed, object URLs revoked), so there was no leak to +> fix — see the main report. + +## Recommended (low-severity, optional) + +### 1. Stabilise the extraction-run poll — *minor* +`ProfilePage.tsx:315-324` recreates its 4s interval on every poll because `extractionRuns` is in the deps +and changes each tick. It's harmless (cleanup runs; it stops when runs finish) but churns. If touched: +poll on a stable trigger (e.g. a boolean `hasActiveRuns` in deps, or read runs from a ref inside the +interval) so the interval is created once per active-window. + +### 2. One live heap-snapshot pass on a populated session — *verification, not a fix* +The static audit is strong, but a single DevTools confirmation closes the loop: +1. Run the real stack (backend on `:5202` + a seeded DB) and sign in. +2. DevTools → Memory → take a heap snapshot. +3. Navigate `/dashboard → /jobs → open a job dialog → close → /profile → build a CV deck → back`, ×5. +4. Force GC, take a second snapshot, **Comparison** view. +5. Expect: node/listener/detached counts return to baseline (sawtooth), not monotonic growth. Sort + retained size by constructor; look for `Detached HTMLElement`, growing `Array`/`Map`, or listener + counts that never fall. + +Also cheap and useful: `performance.memory.usedJSHeapSize` (Chromium) logged across the loop, or a +Playwright script that repeats the navigation and asserts heap stays bounded. + +### 3. Guard async setState after unmount — *defensive, not a current leak* +Several components `await api…().then(setState)`. React 18 no-ops setState on unmounted components (just a +dev warning historically), so this is not a leak, but for long CV/AI calls consider an `AbortController` +on the request (cancels the in-flight network work on unmount) — improves responsiveness and avoids wasted +work more than memory. + +## Prevention — keep leaks from creeping in +- **Lint:** enable `react-hooks/exhaustive-deps` (surfaces the exact wrong-deps class that caused the one + bug here) and consider `react-hooks/react-compiler` checks. +- **Rule of thumb:** any effect that *acquires* a resource (listener, timer, object URL, observer, + subscription, connection) must return a cleanup that releases exactly that resource. "Release once on + unmount" ⇒ empty-deps effect + a ref for current state — never a value in the deps array. +- **Object URLs:** pair every `createObjectURL` with a `revokeObjectURL` in the *same* owner; prefer + revoking on unmount/replace, never on unrelated re-renders. +- **Server caches:** every `IMemoryCache.Set` must carry an absolute/sliding expiration (as + `GmailOAuthService` correctly does); if the app grows to heavy caching, set a `SizeLimit`. +- **No unbounded static state:** keep `static` collections to fixed lookup tables (as today); never + accumulate per-request data in a static field. +- **CI:** the heavy RTL suites are timeout-flaky under load — raising `testTimeout` (e.g. 15–20s) or + reducing jest worker contention would make regressions (including any future leak-guard tests) reliably + visible instead of hidden behind flakes. + +## Security-audit note (standing instruction) +The applied change carries no security surface (client-side URL lifetime only). The recommendations above +introduce none either; if #3 (AbortController) is implemented, ensure aborted requests don't leave +partial writes — not applicable to the read-only CV export/preview calls here. diff --git a/docs/performance/ROOT_CAUSE_ANALYSIS.md b/docs/performance/ROOT_CAUSE_ANALYSIS.md new file mode 100644 index 0000000..d43c186 --- /dev/null +++ b/docs/performance/ROOT_CAUSE_ANALYSIS.md @@ -0,0 +1,67 @@ +# Root Cause Analysis — Job Tracker resource audit + +**Companion to:** [MEMORY_LEAK_REPORT.md](MEMORY_LEAK_REPORT.md) + +## Summary +There is **no memory leak** to root-cause. The investigation surfaced exactly one defect — an +**over-eager blob-URL revocation** in the CV PDF carousel — which is a *release-too-early* bug, the +inverse of a leak. This document root-causes that defect and explains why the "app memory grows" symptom +does **not** indicate a leak here. + +## The one defect — over-revoked preview URLs + +### What the code did (before) +`job-tracker-ui/src/pages/ProfilePage.tsx`: +```ts +useEffect(() => { + return () => { + pdfCarousel.forEach((item) => item.pdfUrl && URL.revokeObjectURL(item.pdfUrl)); + }; +}, [pdfCarousel]); // <-- deps on pdfCarousel +``` +A cleanup with `[pdfCarousel]` deps runs its teardown **before every re-run**, i.e. on *every* change to +`pdfCarousel`, not just on unmount. + +### Why it broke +`buildPdfCarousel()` seeds all templates, then `savePdfToCarousel()` replaces each seed **in place**, one +`setPdfCarousel` call at a time (`ProfilePage.tsx:400-410`). Trace with templates A, B, C: + +1. `[A₁, B₀, C₀]` (A built, B/C seeds without URLs) — cleanup revoked prior `[A₀,B₀,C₀]` (no URLs). OK. +2. `[A₁, B₁, C₀]` (B built) — cleanup runs on the **previous** array `[A₁,B₀,C₀]` → **revokes `A₁`'s URL**, + but `A₁` is still present in the new array and still shown when the user flips the carousel to A. +3. `[A₁, B₁, C₁]` (C built) — cleanup revokes `[A₁,B₁,C₀]` → revokes `B₁` too. + +**Result:** after building an N-template deck, every preview except the **last** points at a revoked +(broken) blob URL. + +### Root cause +Wrong effect dependency scope: a resource that should be released **once, on unmount** was tied to a +value-change dependency, so React's "cleanup-before-next-run" semantics turned it into a per-change +revoke. Compounded by the fact that legitimate drop paths already revoke explicitly +(`savePdfToCarousel` replace at `:402-403`, `resetPdfCarousel` clear at `:378-384`), making the effect's +revocation redundant *and* destructive. + +### Why it is not a leak +On unmount the effect *did* revoke the current array (deps capture the latest value), so URLs were freed. +The bug wastes nothing and retains nothing — it releases too **eagerly**. It is a correctness bug +(broken previews), filed here because Phase 3.5 explicitly covers "image/media resources … released". + +### Fix (commit `eed9b1f`) +Track the carousel in a ref; revoke **only on unmount** (empty-deps effect). Drop paths keep their +explicit revokes. Verified: `profile-page.test.tsx` 5/5. + +## Why the "memory grows" symptom is not a leak here +Per the mission's Final Rule, distinguishing the four causes: +- **Expected caching** — MUI emotion style cache, `react-scripts` dev tooling, and route component state + grow then plateau; not unbounded. +- **Delayed GC** — detached nodes from closed dialogs/pages are collected on the next major GC, not + instantly; a rising sawtooth is normal. +- **Browser behaviour** — bfcache, image decode buffers, and devtools retention inflate numbers in a way + unrelated to app code. +- **Genuine leak** — would require a retained root (listener, timer, global ref, live connection). None + exists in this codebase (see the vector table in the main report). + +## Contributing (non-defect) observations +- **Extraction-poll churn** (`ProfilePage.tsx:315-324`): interval recreated every 4s while a run is + active because `extractionRuns` is in the deps and mutates each poll. Harmless; optionally stabilise + (see improvements doc). -- 2.52.0 From 490c5b803e5d487368635fb8e7737e3da61b1bd0 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sun, 5 Jul 2026 15:13:18 +0200 Subject: [PATCH 08/12] fix(auth): stop infinite /auth/me request loop when logged out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The axios 401 interceptor calls clearAuthClientState() on every 401, which dispatched "auth-changed"; the App handler re-fetched /auth/me, which 401'd again → interceptor → clearAuthClientState() → "auth-changed" → ... an unbounded request storm (observed live: 100+ GET /auth/me and climbing) that ran whenever the user was logged out (login page, expired session) — burning CPU, network and battery and flooding the server. Fix: make clearAuthClientState idempotent — only emit "auth-changed" when it actually removes a stored user key (a real signed-in→out transition), so repeated 401s can no longer re-trigger the fetch. Runtime-verified in a live stack: /auth/me went from 100+ & growing to 0 & stable. login-page/settings tests green. Documented in docs/performance/PERFORMANCE_IMPROVEMENTS.md (Phase 3.5 runtime finding). Co-Authored-By: Claude Opus 4.8 --- docs/performance/PERFORMANCE_IMPROVEMENTS.md | 11 +++++++++++ job-tracker-ui/src/auth.ts | 8 +++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/docs/performance/PERFORMANCE_IMPROVEMENTS.md b/docs/performance/PERFORMANCE_IMPROVEMENTS.md index 976cfad..2ceab9a 100644 --- a/docs/performance/PERFORMANCE_IMPROVEMENTS.md +++ b/docs/performance/PERFORMANCE_IMPROVEMENTS.md @@ -5,8 +5,19 @@ ## Changes made (this pass) | Change | File | Effect | Verified | |---|---|---|---| +| **Stop the infinite `/auth/me` request loop** — make `clearAuthClientState` emit `auth-changed` only on a real signed-in→out transition | `job-tracker-ui/src/auth.ts` | Eliminates a runaway request storm (100+ `GET /auth/me` and climbing) that ran continuously whenever the user was logged out | **Runtime-confirmed** in a live stack: `/auth/me` count 100+ & growing → 0 and stable after fix | | Revoke CV-preview blob URLs on unmount only (ref-based), not on every carousel change | `job-tracker-ui/src/pages/ProfilePage.tsx` | Fixes broken previews on multi-template decks; still frees URLs on unmount | `profile-page.test.tsx` 5/5 | +### Runtime finding — self-triggering auth loop (the most impactful issue found) +Only visible with a running backend (static analysis could not surface it). Sequence: the axios response +interceptor (`api.ts`) calls `clearAuthClientState()` on **every** 401; that dispatched `"auth-changed"`; +the `App` handler re-fetched `/auth/me`; that 401'd again → interceptor → `clearAuthClientState()` → +`"auth-changed"` → … an unbounded loop that hammered the server and spun the client on the login page and +after any session expiry. Fix: `clearAuthClientState` now only emits when it actually removes a stored user +key (idempotent), so repeated 401s can't re-trigger the fetch. This is a CPU/network/battery drain and a +self-inflicted request flood, not a memory leak — but squarely in the Phase 3.5 "infinite polling / retry +loop / duplicate requests" scope, and the single highest-value fix from the whole investigation. + > Context: this was the only defect found in a full resource audit. The codebase already practises > disciplined cleanup (timers cleared, listeners removed, object URLs revoked), so there was no leak to > fix — see the main report. diff --git a/job-tracker-ui/src/auth.ts b/job-tracker-ui/src/auth.ts index 1269ec5..8d047f3 100644 --- a/job-tracker-ui/src/auth.ts +++ b/job-tracker-ui/src/auth.ts @@ -82,8 +82,14 @@ export function setAuthUserKey(value: string | null | undefined, emit = true) { } export function clearAuthClientState(emit = true) { + // Only emit "auth-changed" when this call actually transitions from + // "signed in" to "signed out". The response interceptor calls this on every + // 401; without this guard each 401 re-dispatches "auth-changed", which + // re-fetches /auth/me, which 401s again — an infinite request loop whenever + // the user is logged out (login page, expired session). + const had = safeGet(window.localStorage, AUTH_USER_KEY) != null; safeRemove(window.localStorage, AUTH_USER_KEY); - if (emit) emitAuthChanged(); + if (emit && had) emitAuthChanged(); } export function getCsrfToken(): string | null { -- 2.52.0 From b8f8569e6e73ab179e71e7b5a01c44dc13cbe1c0 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sun, 5 Jul 2026 15:36:55 +0200 Subject: [PATCH 09/12] fix(hooks): stop infinite render loop in useViewResource MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useViewResource built `reload` with `load` in its useCallback deps, and the fetch effect depended on `reload`. Callers routinely pass an inline `load` closure (e.g. JobTable), so `load` — and therefore `reload` and the effect — changed every render, calling setState and re-rendering: an unbounded "Maximum update depth exceeded" loop that froze the renderer on /jobs and every other list view (DashboardView, RemindersView, CompaniesTable). Fix: hold `load` in a ref (like the existing hasLoadedRef) and drop it from the dependency arrays. Re-fetching is still driven by `deps`/`enabled`; the ref always points at the latest closure. No API/behaviour change for callers. Runtime-verified live: /jobs went from a render storm (frozen renderer, 100s of console errors) to 0 errors in a 2s window and a clean render. Suites that drive JobTable→useViewResource pass in isolation; the remaining full-run flakiness is pre-existing (state-pollution/timing in the heavy RTL suites, unrelated). Co-Authored-By: Claude Opus 4.8 --- docs/performance/PERFORMANCE_IMPROVEMENTS.md | 1 + job-tracker-ui/src/hooks/useViewResource.ts | 15 +++++++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/docs/performance/PERFORMANCE_IMPROVEMENTS.md b/docs/performance/PERFORMANCE_IMPROVEMENTS.md index 2ceab9a..62eebaf 100644 --- a/docs/performance/PERFORMANCE_IMPROVEMENTS.md +++ b/docs/performance/PERFORMANCE_IMPROVEMENTS.md @@ -5,6 +5,7 @@ ## Changes made (this pass) | Change | File | Effect | Verified | |---|---|---|---| +| **Stop an infinite render loop on every list view** — hold `load` in a ref in `useViewResource` so `reload`/the fetch effect keep a stable identity | `job-tracker-ui/src/hooks/useViewResource.ts` | Fixes "Maximum update depth exceeded" on `/jobs` (and any `DashboardView`/`RemindersView`/`CompaniesTable` view whose caller passes an inline `load`) — pegged the CPU/renderer | **Runtime-confirmed**: `/jobs` went from a render storm (renderer frozen, 100s of errors) to 0 console errors in a live 2s window and a clean render; `workflow-trust-signals` (drives `JobTable`→`useViewResource`) passes | | **Stop the infinite `/auth/me` request loop** — make `clearAuthClientState` emit `auth-changed` only on a real signed-in→out transition | `job-tracker-ui/src/auth.ts` | Eliminates a runaway request storm (100+ `GET /auth/me` and climbing) that ran continuously whenever the user was logged out | **Runtime-confirmed** in a live stack: `/auth/me` count 100+ & growing → 0 and stable after fix | | Revoke CV-preview blob URLs on unmount only (ref-based), not on every carousel change | `job-tracker-ui/src/pages/ProfilePage.tsx` | Fixes broken previews on multi-template decks; still frees URLs on unmount | `profile-page.test.tsx` 5/5 | diff --git a/job-tracker-ui/src/hooks/useViewResource.ts b/job-tracker-ui/src/hooks/useViewResource.ts index de6aee9..76a2439 100644 --- a/job-tracker-ui/src/hooks/useViewResource.ts +++ b/job-tracker-ui/src/hooks/useViewResource.ts @@ -70,6 +70,17 @@ export function useViewResource( hasLoadedRef.current = hasLoaded; }, [hasLoaded]); + // Hold `load` in a ref so `reload` (and the fetch effect that depends on it) + // keep a stable identity across renders. Callers routinely pass an inline + // `load` closure; if `load` were a dependency, every render would create a new + // `reload`, re-run the effect, setState, and re-render — an infinite loop + // ("Maximum update depth exceeded"). Re-fetching is driven by `deps`/`enabled` + // instead, and the ref always points at the latest closure. + const loadRef = useRef(load); + useEffect(() => { + loadRef.current = load; + }); + const reload = useCallback(async () => { if (!enabled) return; @@ -77,7 +88,7 @@ export function useViewResource( setLoading(!alreadyLoaded); setRefreshing(alreadyLoaded); try { - const next = await load(); + const next = await loadRef.current(); setData(next); setError(null); setHasLoaded(true); @@ -88,7 +99,7 @@ export function useViewResource( setLoading(false); setRefreshing(false); } - }, [enabled, errorMessage, load]); + }, [enabled, errorMessage]); useEffect(() => { if (!enabled) { -- 2.52.0 From 919f61dde65adc9f944954ca132ec1e2d536e57e Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sun, 5 Jul 2026 20:25:43 +0200 Subject: [PATCH 10/12] feat(ui): public marketing landing page at "/" for logged-out visitors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a LandingPage (hero + features + how-it-works + CTAs) served at "/" so visitors learn about the product before signing in — matching the JobTrack mockups (indigo/cyan, dark hero + light sections). If the visitor already has a session, LandingPage redirects into the app (/jobs); otherwise it shows the marketing page with "Sign in" CTAs. The "/" route is public (outside the auth-gated Shell), so logged-out users no longer bounce straight to /login. Verified live: renders at "/" with headline, feature grid, how-it-works steps and CTAs; no console errors; type-clean. Co-Authored-By: Claude Opus 4.8 --- job-tracker-ui/src/App.tsx | 2 + job-tracker-ui/src/pages/LandingPage.tsx | 174 +++++++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 job-tracker-ui/src/pages/LandingPage.tsx diff --git a/job-tracker-ui/src/App.tsx b/job-tracker-ui/src/App.tsx index 228c397..15e49cc 100644 --- a/job-tracker-ui/src/App.tsx +++ b/job-tracker-ui/src/App.tsx @@ -28,6 +28,7 @@ import JobTable from "./components/JobTable"; import type { JobTableColumns } from "./components/JobTable"; import { I18nProvider, useI18n } from "./i18n/I18nProvider"; import LoginPage from "./pages/LoginPage"; +import LandingPage from "./pages/LandingPage"; import ForgotPasswordPage from "./pages/ForgotPasswordPage"; import ResetPasswordPage from "./pages/ResetPasswordPage"; import RouteErrorPage from "./pages/RouteErrorPage"; @@ -344,6 +345,7 @@ export default function App() { }); const router = useMemo(() => createBrowserRouter([ + { path: "/", element: , errorElement: }, { path: "/login", element: , errorElement: }, { path: "/forgot-password", element: , errorElement: }, { path: "/reset-password", element: , errorElement: }, diff --git a/job-tracker-ui/src/pages/LandingPage.tsx b/job-tracker-ui/src/pages/LandingPage.tsx new file mode 100644 index 0000000..64050f9 --- /dev/null +++ b/job-tracker-ui/src/pages/LandingPage.tsx @@ -0,0 +1,174 @@ +import React, { useEffect, useState } from "react"; + +import { Box, Button, Container, Stack, Typography } from "@mui/material"; +import { alpha } from "@mui/material/styles"; +import { useNavigate } from "react-router-dom"; + +import DashboardIcon from "@mui/icons-material/SpaceDashboardOutlined"; +import AlarmIcon from "@mui/icons-material/NotificationsActiveOutlined"; +import MatchIcon from "@mui/icons-material/FactCheckOutlined"; +import MailIcon from "@mui/icons-material/MarkEmailReadOutlined"; +import AttachIcon from "@mui/icons-material/DescriptionOutlined"; +import InsightsIcon from "@mui/icons-material/InsightsOutlined"; + +import { api } from "../api"; + +const BRAND_DARK = "#0b1020"; +const BRAND_PANEL = "#111a33"; + +const FEATURES: { icon: React.ReactNode; title: string; body: string }[] = [ + { icon: , title: "Centralized pipeline", body: "Track every application across Applied, Waiting, Interview, Offer, Rejected and Ghosted — drag to update." }, + { icon: , title: "Smart follow-ups", body: "Reminders surface what needs attention next, with a grounded draft ready to review and send." }, + { icon: , title: "Honest CV match", body: "A deterministic keyword-coverage score with matched vs missing skills — not an opaque black box." }, + { icon: , title: "Email correspondence", body: "Link Gmail threads to a job; new replies appear automatically without re-importing." }, + { icon: , title: "Attachments & docs", body: "Keep resumes, cover letters and portfolios versioned per application, right where you need them." }, + { icon: , title: "Dashboard & insights", body: "Response rates, funnel, time-in-stage and skill demand across your whole search." }, +]; + +const STEPS: { n: number; title: string; body: string }[] = [ + { n: 1, title: "Import", body: "Paste a job URL or use the bookmarklet — we parse the role into structured fields." }, + { n: 2, title: "Match", body: "See how your CV covers the role: matched keywords and the gaps to close." }, + { n: 3, title: "Tailor", body: "AI drafts a tailored CV and cover letter — you review every word before it goes out." }, + { n: 4, title: "Track", body: "Move it through the pipeline; documents, notes and history stay attached." }, + { n: 5, title: "Follow up", body: "Linked email threads and reminders keep momentum with grounded replies." }, + { n: 6, title: "Analyze", body: "See what's working — response rate, funnel and time-in-stage — and focus your effort." }, +]; + +export default function LandingPage() { + const navigate = useNavigate(); + const [checking, setChecking] = useState(true); + + // If the visitor already has a session, send them straight into the app. + useEffect(() => { + let active = true; + api + .get("/auth/me") + .then(() => { if (active) navigate("/jobs", { replace: true }); }) + .catch(() => { if (active) setChecking(false); }); + return () => { active = false; }; + }, [navigate]); + + if (checking) { + return ( + + Loading… + + ); + } + + const gradientText = { + background: "linear-gradient(90deg,#6366f1,#22d3ee)", + WebkitBackgroundClip: "text", + WebkitTextFillColor: "transparent", + } as const; + + return ( + + {/* Top bar */} + + + + + + JobTrack + + + + + + + {/* Hero */} + + + + + AI-ASSISTED JOB SEARCH WORKSPACE + + + Run your job search without losing the thread. + + + Import a role, tailor your CV, track every application, and keep recruiter correspondence tied to the + right job — all in one focused workspace. Assistive, never autonomous: you approve every draft. + + + + + + + React · TypeScript · ASP.NET Core · EF Core · FastAPI AI · Gmail + + + + + + {/* Features */} + + + WHAT IT DOES + One workspace for the whole search + + Everything from a single import to the final offer — no more spreadsheets and scattered inboxes. + + + + {FEATURES.map((f) => ( + + {f.icon} + {f.title} + {f.body} + + ))} + + + + {/* How it works */} + + + + HOW IT WORKS + From a link to an offer + + + {STEPS.map((s) => ( + + {s.n} + {s.title} + {s.body} + + ))} + + + + + {/* CTA */} + + + + Ready to organize your search? + Sign in to start tracking applications, tailoring CVs, and following up with intent. + + + + + + {/* Footer */} + + + + © {new Date().getFullYear()} JobTrack — a focused workspace for the modern job search. + + + + + + ); +} -- 2.52.0 From c53d7978bb8ce21c9a90c01dfd49a83d6b46beca Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sun, 5 Jul 2026 20:42:46 +0200 Subject: [PATCH 11/12] feat(email): introduce IEmailProvider seam + GmailProvider adapter First slice toward multi-provider email (Gmail + Microsoft Graph + IMAP + manual/free-text, per docs/remaster/PRODUCT_DIRECTION.md). Adds a provider- neutral contract (search / list-thread / get-message / get-connection) with neutral DTOs, a registry to resolve providers by key, and a GmailProvider that adapts the existing IGmailOAuthService to it. No behaviour change: the seam is registered in DI but not yet consumed. Follow-up slices migrate GmailController's read paths onto IEmailProvider (folding in the N+1 fixes) and add MicrosoftGraphProvider / ImapProvider / a manual provider. Build clean; backend suite 135/135 green. Co-Authored-By: Claude Opus 4.8 --- JobTrackerApi/Program.cs | 4 ++ .../Services/EmailProviders/GmailProvider.cs | 63 +++++++++++++++++ .../Services/EmailProviders/IEmailProvider.cs | 69 +++++++++++++++++++ 3 files changed, 136 insertions(+) create mode 100644 JobTrackerApi/Services/EmailProviders/GmailProvider.cs create mode 100644 JobTrackerApi/Services/EmailProviders/IEmailProvider.cs diff --git a/JobTrackerApi/Program.cs b/JobTrackerApi/Program.cs index 21ff958..41f3a6a 100644 --- a/JobTrackerApi/Program.cs +++ b/JobTrackerApi/Program.cs @@ -166,6 +166,10 @@ builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +// Provider-neutral email seam (multi-provider: Gmail today; Microsoft Graph / IMAP / manual next). +builder.Services.AddScoped(); +builder.Services.AddScoped(); + builder.Services.AddIdentityCore(options => { options.User.RequireUniqueEmail = true; diff --git a/JobTrackerApi/Services/EmailProviders/GmailProvider.cs b/JobTrackerApi/Services/EmailProviders/GmailProvider.cs new file mode 100644 index 0000000..4a1c5dc --- /dev/null +++ b/JobTrackerApi/Services/EmailProviders/GmailProvider.cs @@ -0,0 +1,63 @@ +using JobTrackerApi.Services; + +namespace JobTrackerApi.Services.EmailProviders +{ + /// + /// Gmail implementation of . Adapts the existing + /// (Gmail REST client) to the provider-neutral contract, + /// mapping Gmail DTOs to the neutral shapes. + /// + public sealed class GmailProvider : IEmailProvider + { + private readonly IGmailOAuthService _gmail; + + public GmailProvider(IGmailOAuthService gmail) + { + _gmail = gmail; + } + + public string ProviderKey => "gmail"; + + public async Task GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken) + { + var connection = await _gmail.GetConnectionAsync(ownerUserId, cancellationToken); + return connection is null ? null : new EmailConnectionInfo("gmail", connection.GmailAddress ?? ""); + } + + public async Task> SearchAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken) + { + var messages = await _gmail.ListMessagesAsync(ownerUserId, query, maxResults, cancellationToken); + return messages.Select(ToSummary).ToList(); + } + + public async Task> ListThreadMessagesAsync(string ownerUserId, string threadId, CancellationToken cancellationToken) + { + var messages = await _gmail.ListThreadMessagesAsync(ownerUserId, threadId, cancellationToken); + return messages.Select(ToSummary).ToList(); + } + + public async Task GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken) + { + var detail = await _gmail.GetMessageAsync(ownerUserId, messageId, cancellationToken); + var attachments = detail.Attachments + .Select(a => new EmailAttachmentRef(a.FileName, a.MimeType, a.SizeBytes, a.GmailAttachmentId, a.Inline)) + .ToList(); + + return new EmailMessageDetail( + detail.Id, + detail.ThreadId, + detail.Subject, + detail.From, + detail.To, + detail.Date, + detail.Snippet, + detail.BodyText, + detail.BodyHtml, + detail.Labels, + attachments); + } + + private static EmailMessageSummary ToSummary(GmailMessageSummary m) + => new(m.Id, m.ThreadId, m.Subject, m.From, m.To, m.Date, m.Snippet); + } +} diff --git a/JobTrackerApi/Services/EmailProviders/IEmailProvider.cs b/JobTrackerApi/Services/EmailProviders/IEmailProvider.cs new file mode 100644 index 0000000..c7a448e --- /dev/null +++ b/JobTrackerApi/Services/EmailProviders/IEmailProvider.cs @@ -0,0 +1,69 @@ +namespace JobTrackerApi.Services.EmailProviders +{ + /// + /// Provider-neutral email operations so job correspondence can be sourced from Gmail, + /// Microsoft Graph, generic IMAP, or manual/free-text entry behind a single seam. + /// See docs/remaster/PRODUCT_DIRECTION.md (multi-provider email). Gmail is the first + /// implementation (); the controller migration and additional + /// providers land in follow-up slices. + /// + public interface IEmailProvider + { + /// Stable key: "gmail" | "microsoft" | "imap" | "manual". + string ProviderKey { get; } + + /// The user's active connection for this provider, or null if not connected. + Task GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken); + + /// Search the user's mailbox. is provider-specific syntax. + Task> SearchAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken); + + /// All messages in a thread/conversation. + Task> ListThreadMessagesAsync(string ownerUserId, string threadId, CancellationToken cancellationToken); + + /// Full message content (body + attachments metadata). + Task GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken); + } + + public sealed record EmailConnectionInfo(string ProviderKey, string Address); + + public sealed record EmailMessageSummary(string Id, string ThreadId, string Subject, string From, string To, DateTimeOffset? Date, string Snippet); + + public sealed record EmailAttachmentRef(string? FileName, string? MimeType, long? SizeBytes, string? ExternalAttachmentId, bool Inline); + + public sealed record EmailMessageDetail( + string Id, + string ThreadId, + string Subject, + string From, + string To, + DateTimeOffset? Date, + string Snippet, + string BodyText, + string? BodyHtml, + IReadOnlyList Labels, + IReadOnlyList Attachments); + + /// Resolves a registered by its key. + public interface IEmailProviderRegistry + { + IReadOnlyList All { get; } + IEmailProvider? Get(string? providerKey); + } + + public sealed class EmailProviderRegistry : IEmailProviderRegistry + { + private readonly Dictionary _byKey; + + public EmailProviderRegistry(IEnumerable providers) + { + All = providers.ToList(); + _byKey = All.ToDictionary(p => p.ProviderKey, StringComparer.OrdinalIgnoreCase); + } + + public IReadOnlyList All { get; } + + public IEmailProvider? Get(string? providerKey) + => !string.IsNullOrWhiteSpace(providerKey) && _byKey.TryGetValue(providerKey, out var p) ? p : null; + } +} -- 2.52.0 From 3d5ab8f32c9ec4b68ab53160ff7086774724976d Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sun, 5 Jul 2026 20:45:20 +0200 Subject: [PATCH 12/12] feat(ui): add pricing section to landing page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three honest tiers (Free / Pro £9-mo / Bring-your-own-key £3-mo) billed monthly or yearly — never by the week (the anti-Teal positioning from docs/remaster/RESEARCH_COMPETITORS.md), with a "Most popular" highlight and the assistive-not-autonomous trust note. Prices are indicative placeholders for the SaaS direction. Verified live: pricing section + all three tiers render at "/". Co-Authored-By: Claude Opus 4.8 --- job-tracker-ui/src/pages/LandingPage.tsx | 63 ++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/job-tracker-ui/src/pages/LandingPage.tsx b/job-tracker-ui/src/pages/LandingPage.tsx index 64050f9..ce326c4 100644 --- a/job-tracker-ui/src/pages/LandingPage.tsx +++ b/job-tracker-ui/src/pages/LandingPage.tsx @@ -34,6 +34,12 @@ const STEPS: { n: number; title: string; body: string }[] = [ { n: 6, title: "Analyze", body: "See what's working — response rate, funnel and time-in-stage — and focus your effort." }, ]; +const PRICING: { name: string; price: string; cadence: string; highlight: boolean; features: string[]; cta: string }[] = [ + { name: "Free", price: "£0", cadence: "forever", highlight: false, cta: "Get started", features: ["Unlimited job tracking & pipeline", "One-click capture (bookmarklet + PWA)", "Deterministic CV↔job match score", "3 AI CV tailors / month"] }, + { name: "Pro", price: "£9", cadence: "/ month · billed monthly or yearly", highlight: true, cta: "Start Pro", features: ["Everything in Free", "Unlimited AI CV & cover-letter tailoring", "CV versions + factuality guardrail", "Gmail correspondence CRM", "Analytics drill-downs"] }, + { name: "Bring your own key", price: "£3", cadence: "/ month + your AI key", highlight: false, cta: "Get started", features: ["Everything in Pro", "Use your own Gemini / Groq key", "Unlimited AI at provider cost", "Privacy-first & self-host friendly"] }, +]; + export default function LandingPage() { const navigate = useNavigate(); const [checking, setChecking] = useState(true); @@ -147,6 +153,63 @@ export default function LandingPage() { + {/* Pricing */} + + + PRICING + Honest, simple pricing + + Billed monthly or yearly — never by the week. Cancel anytime. + + + + {PRICING.map((tier) => ( + + {tier.highlight && ( + + Most popular + + )} + {tier.name} + + {tier.price} + {tier.cadence} + + + {tier.features.map((f) => ( + + + {f} + + ))} + + + + ))} + + + Prices indicative — assistive, never autonomous: you always review and send. No auto-apply spam. + + + {/* CTA */} -- 2.52.0