Compare commits

...

42 Commits

Author SHA1 Message Date
cesnimda 3d5ab8f32c feat(ui): add pricing section to landing page
CI and Deploy / test (pull_request) Successful in 2m8s
CI and Deploy / deploy (pull_request) Has been skipped
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 <noreply@anthropic.com>
2026-07-05 20:45:20 +02:00
cesnimda c53d7978bb 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 <noreply@anthropic.com>
2026-07-05 20:42:46 +02:00
cesnimda 919f61dde6 feat(ui): public marketing landing page at "/" for logged-out visitors
CI and Deploy / test (pull_request) Successful in 2m7s
CI and Deploy / deploy (pull_request) Has been skipped
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 <noreply@anthropic.com>
2026-07-05 20:25:43 +02:00
cesnimda 0ca8c95372 merge: reconcile perf/wave1-perf with main (Wave 0 features)
CI and Deploy / test (pull_request) Successful in 2m13s
CI and Deploy / deploy (pull_request) Has been skipped
Resolve conflicts from main's Wave 0 (PR #1) landing after this branch was cut:

- useViewResource.ts: main's e352aae already fixes the render loop the same way
  (load in a ref, dropped from deps) — took main's canonical version. My
  independent fix is superseded (my branch predated e352aae, which is why the
  loop reproduced live).
- JobApplicationsController.cs: keep BOTH main's IJobCvMatchService and my
  AnalyticsService (ctor gets both optional params). GetAnalyticsOverview stays
  delegated to AnalyticsService.
- Fold main's H3 additions into the extracted AnalyticsService: pipeline-driven
  funnel (JobPipeline.Normalize/Stages) + time-in-stage (StageAnalytics) and add
  StageDurationDto + TimeInStage to Models/AnalyticsDtos.cs, preserving the API
  contract the frontend expects.

Build clean; backend suite 135/135 green.
2026-07-05 20:16:40 +02:00
cesnimda b8f8569e6e fix(hooks): stop infinite render loop in useViewResource
CI and Deploy / test (pull_request) Successful in 2m0s
CI and Deploy / deploy (pull_request) Has been skipped
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 <noreply@anthropic.com>
2026-07-05 15:36:55 +02:00
cesnimda 490c5b803e fix(auth): stop infinite /auth/me request loop when logged out
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 <noreply@anthropic.com>
2026-07-05 15:13:18 +02:00
cesnimda 39266c0935 docs(perf): memory-leak investigation reports (no leak found)
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 <noreply@anthropic.com>
2026-07-05 14:59:28 +02:00
cesnimda eed9b1fa80 fix(profile): revoke CV-preview blob URLs on unmount, not on every change
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 <noreply@anthropic.com>
2026-07-05 14:57:22 +02:00
cesnimda e5e2c65709 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 <noreply@anthropic.com>
2026-07-05 11:13:22 +02:00
cesnimda 2989a6fa2c 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<AnalyticsService>(); 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 <noreply@anthropic.com>
2026-07-05 10:51:08 +02:00
cesnimda 824251d328 feat(ai): provider router (ollama|gemini|groq) for heavy CV calls
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 <noreply@anthropic.com>
2026-07-05 10:40:55 +02:00
cesnimda b8ec268736 perf(db): add owner-prefixed hot-path indexes on JobApplications
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 <noreply@anthropic.com>
2026-07-05 10:30:44 +02:00
cesnimda 6cb593ab5c perf(analytics): project minimal columns in GetStats/GetAnalyticsOverview
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 <noreply@anthropic.com>
2026-07-05 10:20:58 +02:00
cesnimda 31373be841 Merge pull request 'fix(deploy): make bundled Ollama opt-in to avoid duplicate container' (#2) from fix/ollama-no-duplicate into main
CI and Deploy / test (push) Successful in 2m22s
CI and Deploy / deploy (push) Successful in 17s
Reviewed-on: #2
2026-07-03 15:06:23 +02:00
cesnimda e1e508988a fix(deploy): make bundled Ollama opt-in to avoid duplicate container
CI and Deploy / test (pull_request) Successful in 2m4s
CI and Deploy / deploy (pull_request) Has been skipped
The compose file shipped its own ollama service, so 'docker compose pull'
during deploy re-downloaded the Ollama image and a deploy that starts the
AI stack would spin up a second Ollama alongside an existing/shared one.

- ollama service moved behind a 'bundled-ollama' compose profile, so it is
  excluded from the default pull/up (no duplicate, no re-download)
- ai-service no longer depends_on ollama and is documented to point at a
  shared instance via OLLAMA_BASE_URL (e.g. http://<host>:11435)
- deploy.sh no longer names ollama in 'compose up'

To run a self-contained Ollama: docker compose --profile bundled-ollama up

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 14:52:42 +02:00
cesnimda 316ef9ac1a Merge pull request 'Merge Request — Wave 0 quick wins + Tier-1/Tier-2 features' (#1) from chore/wave0-quick-wins into main
CI and Deploy / test (push) Successful in 2m10s
CI and Deploy / deploy (push) Has been cancelled
Reviewed-on: #1
2026-07-03 11:14:14 +02:00
cesnimda d61dd6310b fix(build): give the frontend build 1GB /dev/shm
CI and Deploy / test (pull_request) Successful in 2m2s
CI and Deploy / deploy (pull_request) Has been skipped
CRA's build runs fork-ts-checker in a forked process whose IPC needs
more than Docker's default 64MB /dev/shm; too little segfaults
'npm run build' (RpcIpcMessagePortClosedError / SIGSEGV) with no compile
error. Set shm_size on the frontend image build so production deploys
don't hit this. (CI runners need the same via their container options.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 10:53:06 +02:00
cesnimda 3bd7b4b7e4 docs: add merge-request summary for review
CI and Deploy / test (pull_request) Successful in 2m10s
CI and Deploy / deploy (pull_request) Has been skipped
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 04:29:27 +02:00
cesnimda 30bb6a942d feat: installable PWA with mobile share-target capture
- Corrected manifest (Jobbjakt branding, matching green theme, maskable
  icons, description/categories/scope/id).
- share_target (GET) maps a shared url/link into the same /?add= capture
  flow the bookmarklet uses, so mobile 'Share -> Jobbjakt' pre-fills Add
  Job.
- resolveCaptureUrl helper (tested) extracts the link from add or from a
  link embedded in shared text; App uses it and strips the params.
- Deliberately no offline service worker: the app deploys frequently and
  an aggressive cache would risk stale builds (documented in README).
- 4 unit tests; build compiles.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 04:16:44 +02:00
cesnimda fb11469a48 feat: quick-capture bookmarklet
One-click job capture from any posting, reusing the existing
jobimport/preview parser.

- AddJobModal accepts initialUrl and auto-imports once on open
- App reads a /?add=<encoded url> param, opens Add Job pre-filled, and
  strips the param from the address bar
- QuickCaptureCard in Settings offers a draggable bookmarklet (href set
  via ref since React blocks javascript: URLs) plus copyable code
- EN/NB translations; README feature note
- 2 frontend tests; full suite green (22 suites / 50 tests)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 04:12:36 +02:00
cesnimda 5a9245cf74 docs: add security review of session changes (Phase 6)
Scoped security review of Wave 0 + H1-H4: confirms tenant isolation on
new endpoints (query filters + tests), no injection/ReDoS, dev-only
OpenAPI. Flags DataProtection key rotation as the operator action item.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 03:56:58 +02:00
cesnimda 2996441f52 perf: drop duplicated company-existence query in job Create
The create path ran the same Companies.AnyAsync existence check twice.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 03:56:06 +02:00
cesnimda bd51c245d3 test(security): lock tenant isolation on match-score and status-suggestion
Cross-user access to the new endpoints returns NotFound (carried by the
JobTrackerContext global query filters). Regression guard for the class
of tenant-leak bugs found in the M013-M015 assessments.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 03:55:12 +02:00
cesnimda a1a3736cc4 feat(ui): human-confirmed status suggestion banner
When a job workspace opens, loads /status-suggestion and shows a
dismissible banner when a recent inbound email implies a status move
("This email looks like a move to Interview"). Applying it PATCHes the
status; nothing changes without the user's click.

- StatusSuggestion type + load-on-open effect + apply handler
- warning-toned banner shown above tab content on any tab
- EN/NB translations; README endpoint docs
- 2 frontend tests; full suite green (21 suites / 48 tests)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 03:52:57 +02:00
cesnimda ae3505b877 feat: deterministic email-driven status suggestions
New EmailStatusClassifier scans a message subject/body for outcome
signals (interview invite, offer, rejection) and suggests a canonical
pipeline status. Priority-ordered so a rejection that mentions the prior
interview still classifies as Rejected. Deterministic - no AI - so it is
instant, reproducible, and safe.

- GET /api/jobapplications/{id}/status-suggestion reads the job's latest
  inbound correspondence (incl. Gmail imports) and suggests a forward
  status move, suppressed when already in/past that stage
- always human-confirmed via the existing PATCH .../status
- 7 classifier unit tests + 2 endpoint integration tests; backend green (133)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 03:48:32 +02:00
cesnimda 695fbd6d21 feat(ui): time-in-stage on the dashboard + localized funnel labels
- Adds a 'Median time in stage' block to the conversion-funnel card
  showing median days and active count per stage from the enriched
  analytics-overview endpoint.
- Funnel bar labels and stage names now render through the shared
  pipeline statusLabel (localized; the funnel also now includes Waiting).
- EN/NB translations. Full frontend suite green (20 suites / 46 tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 03:43:17 +02:00
cesnimda 45cbc8b1ab feat: time-in-stage analytics + pipeline-driven funnel
- New pure StageAnalytics.TimeInStage: median days jobs have spent in
  each active pipeline stage (entry time from the last StatusChanged
  event into that stage, else applied date). Closed/success stages
  excluded since 'how long stuck' only applies to actionable stages.
- analytics-overview now derives the funnel from JobPipeline (includes
  the previously-omitted Waiting stage, normalizes legacy spellings) and
  returns TimeInStage.
- 4 unit tests; full backend suite green (124).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 03:40:41 +02:00
cesnimda 5a306f51a1 refactor(ui): drive status from a single shared pipeline module
Introduces pipeline.ts (mirrors backend JobPipeline) as the one frontend
source of truth for canonical stages, synonym normalization, tone, and
localized labels. Replaces the status list/logic previously duplicated
across KanbanBoard, JobTable, AddJobModal and EditJobDialog.

- KanbanBoard/AddJobModal/EditJobDialog render from PIPELINE_STATUSES
- JobTable uses shared statusTone + statusLabel (status chips now
  localized; NB gets proper labels, English unchanged)
- Edit dialog status dropdown is now localized too
- 5 unit tests; full frontend suite green (19 suites / 41 tests)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 03:37:14 +02:00
cesnimda bb736d1183 feat: canonical job pipeline as single source of truth
New JobPipeline: ordered canonical stages (Applied, Waiting, Interview,
Offer, Rejected, Ghosted) with category grouping and a Normalize() that
canonicalizes casing and known synonyms (Interviewing->Interview,
declined->Rejected, ...) while preserving unknown custom statuses.

- normalize status on every write path (Create/Update/PATCH status) so
  the stored value stays canonical without destroying custom values
- GET /api/jobapplications/pipeline exposes the ordered stages so the UI
  renders from one source instead of duplicated hardcoded lists
- 14 unit tests; full backend suite green (120)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 03:31:35 +02:00
cesnimda 209528c8b5 feat(ui): instant match-score panel on the Candidate Fit tab
Adds a MatchScoreCard at the top of the Candidate Fit tab that loads the
deterministic /match-score endpoint independently of the slow AI
narrative, so users see a reproducible score, matched/missing keyword
chips, and per-section coverage immediately.

- MatchScore types + cached, attachment-independent load effect
- graceful 'not enough signal' state
- EN/NB translations
- frontend panel test (matched/missing/section + degraded state)
- backend integration tests for GetMatchScore (happy path + missing CV)
- README endpoint reference

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 03:24:53 +02:00
cesnimda 3fad43a9e2 feat: deterministic CV-to-job match score endpoint
New JobCvMatchService: a pure, AI-free keyword-coverage scorer that
returns a stable, reproducible 0-100 match score plus matched/missing
keyword lists and per-CV-section coverage. Unlike candidate-fit (AI
narrative), it makes no model calls, so results are instant and
identical for identical inputs - the Jobscan-style differentiator.

- GET /api/jobapplications/{id}/match-score
- keywords = curated SkillTagger tags (high weight) + salient posting
  terms (title terms boosted); word-boundary matching avoids false hits
- section coverage shows where CV evidence is concentrated
- fix(SkillTagger): punctuation-tolerant C#/.NET patterns; the old \b
  boundaries silently missed 'C#,' and '.NET,' everywhere they are used
- 7 unit tests on the pure scorer; full backend suite green (104)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 03:16:32 +02:00
cesnimda 83e6430a24 feat: structured salary fields (min/max/currency/period)
Adds SalaryMin/SalaryMax/SalaryCurrency/SalaryPeriod alongside the
existing free-text Salary field (kept for back-compat and display).

- JobApplication model + idempotent column bridging for SQLite and MySQL
- Create/Update DTOs with NormalizeSalary (clamps negatives, swaps
  inverted min/max, uppercases currency, whitelists period)
- JobApplicationDto exposes the fields; CSV export gains 4 columns
- UI: add/edit dialogs get min/max/currency/period inputs; job table
  renders a formatted range via shared salary.ts formatter (falls back
  to free-text when structured values are absent)
- EN/NB translations; backend + full frontend suites green

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 22:15:37 +02:00
cesnimda 8f174cb767 feat: dev-only OpenAPI document at /openapi/v1.json
- AddOpenApi/MapOpenApi (anonymous, Development environment only).
- security: mark ProfileCvController.ProcessQueuedRunAsync [NonAction] -
  the controller-level [Route] exposed this background-service hook as a
  routable any-verb endpoint, which also broke OpenAPI generation.

96 endpoint paths documented.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 22:00:27 +02:00
cesnimda c41d1e8d0f ci: run the entire frontend test suite instead of a file whitelist
The whitelist silently skipped new suites; two regressions in
non-whitelisted suites reached main unnoticed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 22:00:27 +02:00
cesnimda 6150e7f19b test(ui): stabilize slow suites and repair stale trust-loop mocks
- Raise testing-library asyncUtilTimeout to 4s and jest timeout to 30s:
  heavy MUI views exceeded the 1s default on slower machines
  (profile-page, daily-control-loop double-mount).
- end-to-end-trust-loop: mock the /tailored-cv-draft endpoint the
  redesigned Tailored CV tab now loads, and assert on the structured
  draft instead of the removed legacy tailoredCvText textarea.

Full suite now green locally: 18/18 suites, 39/39 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 22:00:26 +02:00
cesnimda 999d6e05e7 feat: automated daily SQLite database backups with retention
New DatabaseBackupHostedService + SqliteDatabaseBackupRunner:
- daily VACUUM INTO snapshot to <Data:Root>/backups (safe with WAL)
- catch-up backup at startup when none exists from the last 24h
- retention pruning (Backups:RetainCount, default 14)
- warns and stays idle on MySQL/MariaDB where external backups apply

Production previously had no automated database backup on Linux
(the /api/backup endpoint is Windows-DPAPI-only).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:49:58 +02:00
cesnimda e352aaeaac fix(ui): avoid stale load closure in useViewResource reload
Keep the latest load callback in a ref so reload() always invokes the
current fetcher without changing its own identity on every render.
Reduces full-suite test failures from 5 to 3 (remaining are pre-existing).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:30:18 +02:00
cesnimda c38295d869 docs: add system overview, product research, and roadmap
Phase 1-3 deliverables: full architecture/security/tech-debt map,
2026 market research with feature matrix, and tiered execution roadmap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:24:03 +02:00
cesnimda 519c32efd7 security: untrack DataProtection keys and runtime exports; remove dead legacy controllers
- git rm --cached on committed DataProtection key XMLs (keys/, JobTrackerApi/keys/)
  and daily export JSON snapshots; extend .gitignore so runtime data
  (keys, exports, CV artifacts/exports/benchmarks) can never be committed again.
- Delete root Controller/ stubs: an early prototype compiled by no project
  (JobTrackerApi excludes them; JobTrackerBackend globs only JobTrackerApi/Controllers).
- NOTE: the removed key XMLs remain in git history; rotating DataProtection
  keys on the server is recommended.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:23:50 +02:00
cesnimda aa43ada16a chore: add Windows PowerShell variant of Ollama CV startup script
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:21:47 +02:00
cesnimda 29325a2048 chore(ui): bump nginx base image to 1.29.8-alpine
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:21:46 +02:00
cesnimda 3ef3192e6c docs: record next-session skill suggestions in handoff notes
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:21:45 +02:00
81 changed files with 4316 additions and 657 deletions
+10
View File
@@ -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=
+3 -1
View File
@@ -43,7 +43,9 @@ jobs:
- name: Test frontend
working-directory: job-tracker-ui
run: npm test -- --watchAll=false --runInBand App.test.tsx confirm.test.tsx prompt.test.tsx dialog-flow.test.tsx confirm-flow.test.tsx attachments.test.tsx job-details-generated-drafts.test.tsx admin-system-page.test.tsx profile-page.test.tsx login-page.test.tsx
# Run the WHOLE suite. Never whitelist test files here again: the previous
# whitelist silently skipped new suites and let two regressions reach main.
run: npm test -- --watchAll=false --runInBand
- name: Build frontend
working-directory: job-tracker-ui
+8
View File
@@ -46,6 +46,14 @@ todo jobtracker.txt
tmp/
/tmp/
# Runtime data that must never be committed (DataProtection keys, exports, CV artifacts)
keys/
backups/
JobTrackerApi/exports/
JobTrackerApi/CvArtifacts/
JobTrackerApi/CvExports/
JobTrackerApi/CvBenchmarks/
# Local app data
*.db
*.db-*
-23
View File
@@ -1,23 +0,0 @@
[ApiController]
[Route("api/[controller]")]
public class AttachmentsController : ControllerBase
{
private readonly IWebHostEnvironment _env;
public AttachmentsController(IWebHostEnvironment env) => _env = env;
[HttpPost]
public async Task<IActionResult> Upload([FromForm] IFormFileCollection files, [FromForm] int jobId)
{
var folder = Path.Combine(_env.ContentRootPath, "Attachments", jobId.ToString());
Directory.CreateDirectory(folder);
foreach (var file in files)
{
var path = Path.Combine(folder, file.FileName);
using var stream = new FileStream(path, FileMode.Create);
await file.CopyToAsync(stream);
}
return Ok();
}
}
-27
View File
@@ -1,27 +0,0 @@
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace JobTrackerApi.Controllers
{
[ApiController]
[Route("api/[controller]")]
public class CompaniesController : ControllerBase
{
private readonly JobTrackerContext _context;
public CompaniesController(JobTrackerContext context) => _context = context;
[HttpGet]
public async Task<IEnumerable<Company>> Get() =>
await _context.Companies.Include(c => c.Jobs).ToListAsync();
[HttpPost]
public async Task<ActionResult<Company>> Post(Company company)
{
_context.Companies.Add(company);
await _context.SaveChangesAsync();
return CreatedAtAction(nameof(Get), new { id = company.Id }, company);
}
}
}
-34
View File
@@ -1,34 +0,0 @@
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace JobTrackerApi.Controllers
{
[ApiController]
[Route("api/[controller]")]
public class CorrespondenceController : ControllerBase
{
private readonly JobTrackerContext _context;
public CorrespondenceController(JobTrackerContext context) => _context = context;
// GET all messages for a job
[HttpGet("{jobId}")]
public async Task<IEnumerable<Correspondence>> GetForJob(int jobId)
{
return await _context.Correspondences
.Where(c => c.JobApplicationId == jobId)
.OrderBy(c => c.Date)
.ToListAsync();
}
// POST new message
[HttpPost]
public async Task<ActionResult<Correspondence>> Post(Correspondence message)
{
_context.Correspondences.Add(message);
await _context.SaveChangesAsync();
return CreatedAtAction(nameof(GetForJob), new { jobId = message.JobApplicationId }, message);
}
}
}
-45
View File
@@ -1,45 +0,0 @@
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace JobTrackerApi.Controllers
{
[ApiController]
[Route("api/[controller]")]
public class JobApplicationsController : ControllerBase
{
private readonly JobTrackerContext _context;
public JobApplicationsController(JobTrackerContext context) => _context = context;
[HttpGet]
public async Task<IEnumerable<JobApplication>> Get() =>
await _context.JobApplications.Include(j => j.Company).ToListAsync();
[HttpPost]
public async Task<ActionResult<JobApplication>> Post(JobApplication job)
{
_context.JobApplications.Add(job);
await _context.SaveChangesAsync();
return CreatedAtAction(nameof(Get), new { id = job.Id }, job);
}
[HttpPut("{id}")]
public async Task<IActionResult> Put(int id, JobApplication updatedJob)
{
var job = await _context.JobApplications.FindAsync(id);
if (job == null) return NotFound();
job.JobTitle = updatedJob.JobTitle;
job.Status = updatedJob.Status;
job.ResponseReceived = updatedJob.ResponseReceived;
job.ResponseDate = updatedJob.ResponseDate;
job.Notes = updatedJob.Notes;
job.CoverLetterText = updatedJob.CoverLetterText;
job.JobUrl = updatedJob.JobUrl;
await _context.SaveChangesAsync();
return NoContent();
}
}
}
+14
View File
@@ -55,6 +55,20 @@ namespace JobTrackerApi.Data
modelBuilder.Entity<JobApplication>()
.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<JobApplication>()
.HasIndex(j => new { j.OwnerUserId, j.IsDeleted });
modelBuilder.Entity<JobApplication>()
.HasIndex(j => new { j.OwnerUserId, j.FollowUpAt });
modelBuilder.Entity<Company>()
.HasIndex(c => c.OwnerUserId);
@@ -0,0 +1,97 @@
using JobTrackerApi.Services;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
namespace JobTrackerApi.Tests;
public sealed class DatabaseBackupRunnerTests : IDisposable
{
private readonly string _root;
public DatabaseBackupRunnerTests()
{
_root = Path.Combine(Path.GetTempPath(), $"jt-backup-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(_root);
}
public void Dispose()
{
SqliteConnection.ClearAllPools();
try { Directory.Delete(_root, recursive: true); } catch (IOException) { }
}
private string CreateSourceDb(out string connectionString)
{
var dbPath = Path.Combine(_root, "source.db");
connectionString = $"Data Source={dbPath}";
using var connection = new SqliteConnection(connectionString);
connection.Open();
using var command = connection.CreateCommand();
command.CommandText = "CREATE TABLE Sample (Id INTEGER PRIMARY KEY, Name TEXT); INSERT INTO Sample (Name) VALUES ('alpha'), ('beta');";
command.ExecuteNonQuery();
return dbPath;
}
private SqliteDatabaseBackupRunner CreateRunner(string connectionString, int retainCount = 14)
=> new(connectionString, Path.Combine(_root, "backups"), retainCount, NullLogger<SqliteDatabaseBackupRunner>.Instance);
[Fact]
public async Task RunOnce_creates_a_restorable_backup_file()
{
CreateSourceDb(out var connectionString);
var runner = CreateRunner(connectionString);
var backupPath = await runner.RunOnceAsync(CancellationToken.None);
Assert.NotNull(backupPath);
Assert.True(File.Exists(backupPath));
await using var verify = new SqliteConnection($"Data Source={backupPath}");
await verify.OpenAsync();
await using var count = verify.CreateCommand();
count.CommandText = "SELECT COUNT(*) FROM Sample";
Assert.Equal(2L, (long)(await count.ExecuteScalarAsync())!);
}
[Fact]
public async Task RunOnce_prunes_backups_beyond_retention()
{
CreateSourceDb(out var connectionString);
var runner = CreateRunner(connectionString, retainCount: 2);
var backupsRoot = runner.BackupsRoot;
Directory.CreateDirectory(backupsRoot);
for (var i = 0; i < 3; i++)
{
var stale = Path.Combine(backupsRoot, $"{SqliteDatabaseBackupRunner.BackupFilePrefix}stale{i}.db");
File.WriteAllText(stale, "stale");
File.SetLastWriteTimeUtc(stale, DateTime.UtcNow.AddDays(-10 - i));
}
await runner.RunOnceAsync(CancellationToken.None);
var remaining = Directory.GetFiles(backupsRoot, $"{SqliteDatabaseBackupRunner.BackupFilePrefix}*.db");
Assert.Equal(2, remaining.Length);
Assert.Contains(remaining, f => Path.GetFileName(f).Contains("stale0"));
}
[Fact]
public void Latest_backup_timestamp_reflects_newest_file()
{
CreateSourceDb(out var connectionString);
var runner = CreateRunner(connectionString);
Assert.Null(runner.GetLatestBackupUtc());
Directory.CreateDirectory(runner.BackupsRoot);
var file = Path.Combine(runner.BackupsRoot, $"{SqliteDatabaseBackupRunner.BackupFilePrefix}x.db");
File.WriteAllText(file, "x");
var stamp = DateTime.UtcNow.AddHours(-3);
File.SetLastWriteTimeUtc(file, stamp);
var latest = runner.GetLatestBackupUtc();
Assert.NotNull(latest);
Assert.True(Math.Abs((latest!.Value - stamp).TotalSeconds) < 2);
}
}
@@ -0,0 +1,61 @@
using JobTrackerApi.Services;
using Xunit;
namespace JobTrackerApi.Tests;
public sealed class EmailStatusClassifierTests
{
[Fact]
public void Detects_rejection()
{
var s = EmailStatusClassifier.Classify("Your application", "Thank you for your time. Unfortunately, we have decided not to proceed with your application.");
Assert.NotNull(s);
Assert.Equal("Rejected", s!.SuggestedStatus);
}
[Fact]
public void Detects_offer()
{
var s = EmailStatusClassifier.Classify("Great news", "We are pleased to offer you the position of Backend Engineer.");
Assert.NotNull(s);
Assert.Equal("Offer", s!.SuggestedStatus);
}
[Fact]
public void Detects_interview_invite()
{
var s = EmailStatusClassifier.Classify("Next steps", "We would like to invite you to interview next week. What is your availability for a call?");
Assert.NotNull(s);
Assert.Equal("Interview", s!.SuggestedStatus);
}
[Fact]
public void Rejection_wins_over_interview_mention()
{
// A rejection email that references the interview the candidate had must classify as Rejected.
var s = EmailStatusClassifier.Classify(
"Update on your application",
"Thank you for taking the time to interview with us. Unfortunately, we will not be moving forward.");
Assert.NotNull(s);
Assert.Equal("Rejected", s!.SuggestedStatus);
}
[Fact]
public void Weak_interview_cue_is_low_confidence()
{
var s = EmailStatusClassifier.Classify("Coding challenge", "Please complete this take-home assessment.");
Assert.NotNull(s);
Assert.Equal("Interview", s!.SuggestedStatus);
Assert.Equal("low", s.Confidence);
}
[Fact]
public void Returns_null_for_neutral_email()
{
Assert.Null(EmailStatusClassifier.Classify("Re: question", "Thanks for the info, that answers my question about the parking."));
}
[Fact]
public void Handles_empty_input()
=> Assert.Null(EmailStatusClassifier.Classify(null, null));
}
@@ -38,6 +38,42 @@ public sealed class JobApplicationsAuthorizationTests
Assert.IsType<NotFoundResult>(result.Result);
}
[Fact]
public async Task GetMatchScore_returns_not_found_for_other_users_job()
{
var dbName = Guid.NewGuid().ToString();
await using var ownerDb = CreateDb(dbName, "owner-1");
var company = new Company { Name = "Acme", OwnerUserId = "owner-1" };
ownerDb.Companies.Add(company);
await ownerDb.SaveChangesAsync();
ownerDb.JobApplications.Add(new JobApplication { JobTitle = "Secret", CompanyId = company.Id, OwnerUserId = "owner-1", Description = "C# .NET" });
await ownerDb.SaveChangesAsync();
var jobId = await ownerDb.JobApplications.Select(j => j.Id).FirstAsync();
await using var attackerDb = CreateDb(dbName, "other-user");
var result = await CreateController(attackerDb).GetMatchScore(jobId, CancellationToken.None);
Assert.IsType<NotFoundResult>(result.Result);
}
[Fact]
public async Task GetStatusSuggestion_returns_not_found_for_other_users_job()
{
var dbName = Guid.NewGuid().ToString();
await using var ownerDb = CreateDb(dbName, "owner-1");
var company = new Company { Name = "Acme", OwnerUserId = "owner-1" };
ownerDb.Companies.Add(company);
await ownerDb.SaveChangesAsync();
ownerDb.JobApplications.Add(new JobApplication { JobTitle = "Secret", CompanyId = company.Id, OwnerUserId = "owner-1" });
await ownerDb.SaveChangesAsync();
var jobId = await ownerDb.JobApplications.Select(j => j.Id).FirstAsync();
await using var attackerDb = CreateDb(dbName, "other-user");
var result = await CreateController(attackerDb).GetStatusSuggestion(jobId, CancellationToken.None);
Assert.IsType<NotFoundResult>(result.Result);
}
private static JobTrackerContext CreateDb(string dbName, string? userId)
{
var options = new DbContextOptionsBuilder<JobTrackerContext>()
@@ -56,6 +56,231 @@ public sealed class JobApplicationsEndpointBehaviorTests
Assert.Contains("Profile page", badRequest.Value?.ToString());
}
[Fact]
public async Task Status_suggestion_from_latest_inbound_rejection()
{
await using var db = CreateDb();
var company = new Company { Name = "Acme", OwnerUserId = "user-1" };
db.Companies.Add(company);
await db.SaveChangesAsync();
var job = new JobApplication { JobTitle = "Dev", CompanyId = company.Id, OwnerUserId = "user-1", Status = "Applied" };
db.JobApplications.Add(job);
await db.SaveChangesAsync();
db.Correspondences.Add(new Correspondence
{
JobApplicationId = job.Id,
From = "Company",
Direction = "inbound",
Subject = "Update",
Content = "Unfortunately, we have decided not to proceed.",
Date = DateTime.Now,
});
await db.SaveChangesAsync();
var controller = CreateController(db, "user-1");
var result = await controller.GetStatusSuggestion(job.Id, CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result.Result);
var dto = Assert.IsType<JobApplicationsController.StatusSuggestionDto>(ok.Value);
Assert.True(dto.HasSuggestion);
Assert.Equal("Rejected", dto.SuggestedStatus);
}
[Fact]
public async Task Status_suggestion_suppressed_when_already_in_stage()
{
await using var db = CreateDb();
var company = new Company { Name = "Acme", OwnerUserId = "user-1" };
db.Companies.Add(company);
await db.SaveChangesAsync();
var job = new JobApplication { JobTitle = "Dev", CompanyId = company.Id, OwnerUserId = "user-1", Status = "Rejected" };
db.JobApplications.Add(job);
await db.SaveChangesAsync();
db.Correspondences.Add(new Correspondence
{
JobApplicationId = job.Id,
From = "Company",
Direction = "inbound",
Content = "Unfortunately, we will not be moving forward.",
Date = DateTime.Now,
});
await db.SaveChangesAsync();
var controller = CreateController(db, "user-1");
var result = await controller.GetStatusSuggestion(job.Id, CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result.Result);
var dto = Assert.IsType<JobApplicationsController.StatusSuggestionDto>(ok.Value);
Assert.False(dto.HasSuggestion);
}
[Fact]
public async Task Match_score_scores_job_against_profile_cv()
{
await using var db = CreateDb();
var company = new Company { Name = "Acme", OwnerUserId = "user-1" };
db.Companies.Add(company);
db.Users.Add(new ApplicationUser
{
Id = "user-1",
UserName = "u",
Email = "u@example.com",
ProfileCvText = "Backend engineer skilled in C#, .NET, SQL and Docker. Built REST APIs.",
});
await db.SaveChangesAsync();
var job = new JobApplication
{
JobTitle = "Senior C# Backend Developer",
CompanyId = company.Id,
OwnerUserId = "user-1",
Description = "We need strong C#, .NET, SQL, Docker and REST API experience.",
};
db.JobApplications.Add(job);
await db.SaveChangesAsync();
var controller = CreateController(db, "user-1");
var result = await controller.GetMatchScore(job.Id, CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result.Result);
var dto = Assert.IsType<JobApplicationsController.MatchScoreDto>(ok.Value);
Assert.True(dto.HasEnoughSignal);
Assert.True(dto.Score >= 75, $"expected strong score, got {dto.Score}");
Assert.Contains("C#", dto.MatchedKeywords);
}
[Fact]
public async Task Match_score_requires_profile_cv()
{
await using var db = CreateDb();
var company = new Company { Name = "Acme", OwnerUserId = "user-1" };
db.Companies.Add(company);
db.Users.Add(new ApplicationUser { Id = "user-1", UserName = "u", Email = "u@example.com" });
await db.SaveChangesAsync();
var job = new JobApplication { JobTitle = "Dev", CompanyId = company.Id, OwnerUserId = "user-1", Description = "C# .NET" };
db.JobApplications.Add(job);
await db.SaveChangesAsync();
var controller = CreateController(db, "user-1");
var result = await controller.GetMatchScore(job.Id, CancellationToken.None);
Assert.IsType<BadRequestObjectResult>(result.Result);
}
[Fact]
public async Task Create_normalizes_structured_salary()
{
await using var db = CreateDb();
var company = new Company { Name = "Acme", OwnerUserId = "user-1" };
db.Companies.Add(company);
await db.SaveChangesAsync();
var controller = CreateController(db, "user-1");
var request = new JobApplicationsController.CreateJobApplicationRequest(
JobTitle: "Backend Dev",
CompanyId: company.Id,
Status: null,
Location: null,
Salary: "60-70k",
SalaryMin: 70000m, // min > max on purpose: normalization swaps them
SalaryMax: 60000m,
SalaryCurrency: " nok ",
SalaryPeriod: "YEAR",
NextAction: null,
FollowUpAt: null,
Notes: null,
Description: null,
TranslatedDescription: null,
DescriptionLanguage: null,
Tags: null,
Deadline: null,
CoverLetterText: null,
JobUrl: null,
DateApplied: null,
FeedbackRequestedAt: null,
HasResume: null,
HasCoverLetter: null,
HasPortfolio: null,
HasOtherAttachment: null);
var result = await controller.Create(request, CancellationToken.None);
Assert.NotNull(result);
var saved = await db.JobApplications.FirstAsync();
Assert.Equal(60000m, saved.SalaryMin);
Assert.Equal(70000m, saved.SalaryMax);
Assert.Equal("NOK", saved.SalaryCurrency);
Assert.Equal("year", saved.SalaryPeriod);
Assert.Equal("60-70k", saved.Salary);
}
[Fact]
public async Task Update_drops_invalid_salary_period_and_negative_values()
{
await using var db = CreateDb();
var company = new Company { Name = "Acme", OwnerUserId = "user-1" };
db.Companies.Add(company);
await db.SaveChangesAsync();
var job = new JobApplication
{
JobTitle = "Backend Dev",
CompanyId = company.Id,
OwnerUserId = "user-1",
SalaryMin = 50000m,
SalaryMax = 60000m,
SalaryCurrency = "NOK",
SalaryPeriod = "year",
};
db.JobApplications.Add(job);
await db.SaveChangesAsync();
var controller = CreateController(db, "user-1");
var request = new JobApplicationsController.UpdateJobApplicationRequest(
JobTitle: "Backend Dev",
CompanyId: company.Id,
Status: "Applied",
ResponseReceived: false,
ResponseDate: null,
Location: null,
Salary: null,
SalaryMin: -5m,
SalaryMax: null,
SalaryCurrency: "",
SalaryPeriod: "fortnight",
NextAction: null,
FollowUpAt: null,
HasResume: null,
HasCoverLetter: null,
HasPortfolio: null,
HasOtherAttachment: null,
Notes: null,
Description: null,
TranslatedDescription: null,
DescriptionLanguage: null,
Tags: null,
Deadline: null,
CoverLetterText: null,
JobUrl: null,
DateApplied: null,
FeedbackRequestedAt: null,
StatusChangedAt: null);
var result = await controller.Update(job.Id, request, CancellationToken.None);
Assert.IsType<NoContentResult>(result);
var saved = await db.JobApplications.FirstAsync();
Assert.Null(saved.SalaryMin);
Assert.Null(saved.SalaryMax);
Assert.Null(saved.SalaryCurrency);
Assert.Null(saved.SalaryPeriod);
}
private static JobApplicationsController CreateController(JobTrackerContext db, string userId)
{
var summarizer = new Mock<ISummarizerService>();
@@ -0,0 +1,105 @@
using JobTrackerApi.Services;
using Xunit;
namespace JobTrackerApi.Tests;
public sealed class JobCvMatchServiceTests
{
private readonly JobCvMatchService _service = new();
private static Dictionary<string, string> Sections(params (string Name, string Text)[] items)
=> items.ToDictionary(i => i.Name, i => i.Text, StringComparer.OrdinalIgnoreCase);
[Fact]
public void Strong_overlap_scores_high_and_lists_matched_keywords()
{
var result = _service.Evaluate(
jobTitle: "Senior C# Backend Developer",
jobText: "We need a backend engineer with strong C#, .NET, SQL and Docker experience building REST APIs.",
cvSections: Sections(
("Skills", "C# .NET SQL Docker Kubernetes"),
("Experience", "Built REST APIs in C# and .NET with SQL Server and Docker.")));
Assert.True(result.Score >= 75, $"expected strong score, got {result.Score}");
Assert.Equal("Strong", result.Band);
Assert.Contains("C#", result.MatchedKeywords);
Assert.Contains(".NET", result.MatchedKeywords);
Assert.True(result.HasEnoughSignal);
}
[Fact]
public void No_overlap_scores_low_and_surfaces_missing_keywords()
{
var result = _service.Evaluate(
jobTitle: "Kubernetes Platform Engineer",
jobText: "Deep Kubernetes, AWS, and Docker platform experience required. Terraform and CI/CD pipelines.",
cvSections: Sections(
("Skills", "Graphic design, Adobe Photoshop, Illustrator, copywriting"),
("Experience", "Ran marketing campaigns and brand design work.")));
Assert.True(result.Score < 50, $"expected low score, got {result.Score}");
Assert.Equal("Low", result.Band);
Assert.Contains("Kubernetes", result.MissingKeywords);
Assert.Contains("AWS", result.MissingKeywords);
}
[Fact]
public void Is_deterministic_for_identical_inputs()
{
var a = _service.Evaluate("Data Engineer", "Python, SQL, Spark, ETL pipelines and AWS.", Sections(("Skills", "Python SQL AWS")));
var b = _service.Evaluate("Data Engineer", "Python, SQL, Spark, ETL pipelines and AWS.", Sections(("Skills", "Python SQL AWS")));
Assert.Equal(a.Score, b.Score);
Assert.Equal(a.MatchedKeywords, b.MatchedKeywords);
Assert.Equal(a.MissingKeywords, b.MissingKeywords);
}
[Fact]
public void Word_boundary_prevents_false_substring_matches()
{
// "go" (the language) must not match inside "goals"/"ago".
var result = _service.Evaluate(
jobTitle: "Go Developer",
jobText: "Go programming language, goroutines, concurrency.",
cvSections: Sections(("Experience", "Achieved company goals two years ago in a great environment.")));
Assert.DoesNotContain("go", result.MatchedKeywords, StringComparer.OrdinalIgnoreCase);
}
[Fact]
public void Section_coverage_reports_where_matches_are_concentrated()
{
var result = _service.Evaluate(
jobTitle: "React Frontend Engineer",
jobText: "Build UIs with React, TypeScript and JavaScript. Strong testing culture.",
cvSections: Sections(
("Skills", "React TypeScript JavaScript"),
("Experience", "Wrote documentation and managed budgets.")));
var skills = Assert.Single(result.SectionCoverage, s => s.Section == "Skills");
var experience = Assert.Single(result.SectionCoverage, s => s.Section == "Experience");
Assert.True(skills.Matched > experience.Matched);
}
[Fact]
public void Empty_cv_reports_no_signal()
{
var result = _service.Evaluate("Anything", "Some role text with several words here.", Sections());
Assert.False(result.HasEnoughSignal);
Assert.Equal("Unknown", result.Band);
Assert.Equal(0, result.MatchedCount);
}
[Fact]
public void Title_keywords_are_weighted_and_missing_ones_rank_first()
{
// The title term "kubernetes" is absent from the CV; it should lead the missing list
// because title terms carry the title bonus weight.
var result = _service.Evaluate(
jobTitle: "Kubernetes Specialist",
jobText: "Kubernetes orchestration. Some familiarity with logging and monitoring dashboards.",
cvSections: Sections(("Skills", "logging monitoring dashboards")));
Assert.Equal("Kubernetes", result.MissingKeywords.First());
}
}
+54
View File
@@ -0,0 +1,54 @@
using JobTrackerApi.Services;
using Xunit;
namespace JobTrackerApi.Tests;
public sealed class JobPipelineTests
{
[Theory]
[InlineData("applied", "Applied")]
[InlineData("APPLIED", "Applied")]
[InlineData(" Offer ", "Offer")]
[InlineData("Interviewing", "Interview")]
[InlineData("interviews", "Interview")]
[InlineData("declined", "Rejected")]
[InlineData("no response", "Ghosted")]
public void Normalize_canonicalizes_casing_and_synonyms(string input, string expected)
=> Assert.Equal(expected, JobPipeline.Normalize(input));
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData(null)]
public void Normalize_empty_becomes_default(string? input)
=> Assert.Equal("Applied", JobPipeline.Normalize(input));
[Fact]
public void Normalize_preserves_unknown_custom_status()
=> Assert.Equal("Take-home assignment", JobPipeline.Normalize(" Take-home assignment "));
[Fact]
public void Stages_are_ordered_and_unique()
{
var orders = JobPipeline.Stages.Select(s => s.Order).ToList();
Assert.Equal(orders.OrderBy(x => x), orders);
Assert.Equal(orders.Count, orders.Distinct().Count());
}
[Fact]
public void OrderOf_sorts_canonical_before_custom()
{
Assert.True(JobPipeline.OrderOf("Applied") < JobPipeline.OrderOf("Offer"));
Assert.True(JobPipeline.OrderOf("Offer") < JobPipeline.OrderOf("Custom stage"));
Assert.Equal(JobPipeline.OrderOf("Interview"), JobPipeline.OrderOf("Interviewing"));
}
[Fact]
public void IsCanonical_only_true_for_known_stages()
{
Assert.True(JobPipeline.IsCanonical("Offer"));
Assert.True(JobPipeline.IsCanonical("offer"));
Assert.False(JobPipeline.IsCanonical("Interviewing")); // synonym, not canonical
Assert.False(JobPipeline.IsCanonical("Whatever"));
}
}
@@ -0,0 +1,62 @@
using JobTrackerApi.Services;
using Xunit;
namespace JobTrackerApi.Tests;
public sealed class StageAnalyticsTests
{
private static readonly DateTime Now = new(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc);
[Fact]
public void Computes_median_days_per_active_stage()
{
var jobs = new[]
{
new StageOccupancy("Applied", Now.AddDays(-10)),
new StageOccupancy("Applied", Now.AddDays(-20)),
new StageOccupancy("Applied", Now.AddDays(-30)),
new StageOccupancy("Interview", Now.AddDays(-4)),
};
var result = StageAnalytics.TimeInStage(jobs, Now);
var applied = Assert.Single(result, p => p.Stage == "Applied");
Assert.Equal(20, applied.MedianDays);
Assert.Equal(3, applied.Count);
var interview = Assert.Single(result, p => p.Stage == "Interview");
Assert.Equal(4, interview.MedianDays);
}
[Fact]
public void Excludes_closed_and_success_stages()
{
var jobs = new[]
{
new StageOccupancy("Offer", Now.AddDays(-5)),
new StageOccupancy("Rejected", Now.AddDays(-5)),
new StageOccupancy("Ghosted", Now.AddDays(-5)),
};
Assert.Empty(StageAnalytics.TimeInStage(jobs, Now));
}
[Fact]
public void Normalizes_legacy_status_and_orders_by_pipeline()
{
var jobs = new[]
{
new StageOccupancy("Interviewing", Now.AddDays(-3)),
new StageOccupancy("Applied", Now.AddDays(-1)),
new StageOccupancy("Waiting", Now.AddDays(-2)),
};
var result = StageAnalytics.TimeInStage(jobs, Now);
Assert.Equal(new[] { "Applied", "Waiting", "Interview" }, result.Select(p => p.Stage).ToArray());
}
[Fact]
public void Empty_input_returns_empty()
=> Assert.Empty(StageAnalytics.TimeInStage(Array.Empty<StageOccupancy>(), Now));
}
@@ -58,6 +58,10 @@ namespace JobTrackerApi.Controllers
"DateApplied",
"Location",
"Salary",
"SalaryMin",
"SalaryMax",
"SalaryCurrency",
"SalaryPeriod",
"NextAction",
"FollowUpAt",
"JobUrl",
@@ -76,6 +80,10 @@ namespace JobTrackerApi.Controllers
Esc(j.DateApplied.ToString("o")),
Esc(j.Location),
Esc(j.Salary),
Esc(j.SalaryMin?.ToString(System.Globalization.CultureInfo.InvariantCulture)),
Esc(j.SalaryMax?.ToString(System.Globalization.CultureInfo.InvariantCulture)),
Esc(j.SalaryCurrency),
Esc(j.SalaryPeriod),
Esc(j.NextAction),
Esc(j.FollowUpAt?.ToString("o")),
Esc(j.JobUrl),
@@ -23,8 +23,10 @@ namespace JobTrackerApi.Controllers
private readonly ILogger<JobApplicationsController> _logger;
private readonly ICvTemplateRenderer _cvTemplateRenderer;
private readonly ICvPdfExporter _cvPdfExporter;
private readonly AnalyticsService _analytics;
private readonly IJobCvMatchService _matchService;
public JobApplicationsController(JobTrackerContext db, ISummarizerService summarizer, IAppEmailSender email, UserManager<ApplicationUser> users, ILogger<JobApplicationsController> logger, ICvTemplateRenderer? cvTemplateRenderer = null, ICvPdfExporter? cvPdfExporter = null)
public JobApplicationsController(JobTrackerContext db, ISummarizerService summarizer, IAppEmailSender email, UserManager<ApplicationUser> users, ILogger<JobApplicationsController> logger, ICvTemplateRenderer? cvTemplateRenderer = null, ICvPdfExporter? cvPdfExporter = null, AnalyticsService? analytics = null, IJobCvMatchService? matchService = null)
{
_db = db;
_summarizer = summarizer;
@@ -33,6 +35,8 @@ namespace JobTrackerApi.Controllers
_logger = logger;
_cvTemplateRenderer = cvTemplateRenderer ?? new CvTemplateRenderer();
_cvPdfExporter = cvPdfExporter ?? new ThrowingCvPdfExporter();
_analytics = analytics ?? new AnalyticsService(db);
_matchService = matchService ?? new JobCvMatchService();
}
private sealed class ThrowingCvPdfExporter : ICvPdfExporter
@@ -749,6 +753,10 @@ Canonical profile:
Deadline: job.Deadline,
Location: job.Location,
Salary: job.Salary,
SalaryMin: job.SalaryMin,
SalaryMax: job.SalaryMax,
SalaryCurrency: job.SalaryCurrency,
SalaryPeriod: job.SalaryPeriod,
NextAction: job.NextAction,
FollowUpAt: job.FollowUpAt,
FeedbackRequestedAt: job.FeedbackRequestedAt,
@@ -1081,6 +1089,10 @@ Canonical profile:
DateTime? Deadline,
string? Location,
string? Salary,
decimal? SalaryMin,
decimal? SalaryMax,
string? SalaryCurrency,
string? SalaryPeriod,
string? NextAction,
DateTime? FollowUpAt,
DateTime? FeedbackRequestedAt,
@@ -1349,6 +1361,10 @@ Canonical profile:
string? Status,
string? Location,
string? Salary,
decimal? SalaryMin,
decimal? SalaryMax,
string? SalaryCurrency,
string? SalaryPeriod,
string? NextAction,
DateTime? FollowUpAt,
string? Notes,
@@ -1367,6 +1383,22 @@ Canonical profile:
bool? HasOtherAttachment
);
private static (decimal? Min, decimal? Max, string? Currency, string? Period) NormalizeSalary(
decimal? min, decimal? max, string? currency, string? period)
{
if (min is < 0) min = null;
if (max is < 0) max = null;
if (min.HasValue && max.HasValue && min > max) (min, max) = (max, min);
var cur = (currency ?? "").Trim().ToUpperInvariant();
if (cur.Length > 8) cur = cur[..8];
var per = (period ?? "").Trim().ToLowerInvariant();
if (per is not ("year" or "month" or "hour")) per = "";
return (min, max, cur.Length == 0 ? null : cur, per.Length == 0 ? null : per);
}
[HttpPost]
public async Task<ActionResult<JobApplication>> Create([FromBody] CreateJobApplicationRequest request, CancellationToken cancellationToken)
{
@@ -1375,9 +1407,7 @@ Canonical profile:
if (title.Length == 0) return BadRequest("Job title is required.");
if (request.CompanyId <= 0) return BadRequest("Valid companyId is required.");
var companyOk = await _db.Companies.AnyAsync(c => c.Id == request.CompanyId, cancellationToken);
if (!companyOk) return BadRequest("companyId does not exist.");
// Scoped by the Company query filter, so this also rejects another user's companyId.
var companyExists = await _db.Companies.AnyAsync(c => c.Id == request.CompanyId, cancellationToken);
if (!companyExists) return BadRequest("companyId does not exist.");
@@ -1386,7 +1416,7 @@ Canonical profile:
OwnerUserId = string.IsNullOrWhiteSpace(userId) ? null : userId,
JobTitle = title,
CompanyId = request.CompanyId,
Status = string.IsNullOrWhiteSpace(request.Status) ? "Applied" : request.Status.Trim(),
Status = JobPipeline.Normalize(request.Status),
Location = string.IsNullOrWhiteSpace(request.Location) ? null : request.Location.Trim(),
Salary = string.IsNullOrWhiteSpace(request.Salary) ? null : request.Salary.Trim(),
NextAction = string.IsNullOrWhiteSpace(request.NextAction) ? null : request.NextAction.Trim(),
@@ -1409,6 +1439,9 @@ Canonical profile:
ResponseDate = null,
};
(job.SalaryMin, job.SalaryMax, job.SalaryCurrency, job.SalaryPeriod) =
NormalizeSalary(request.SalaryMin, request.SalaryMax, request.SalaryCurrency, request.SalaryPeriod);
// Generate and persist a short summary at creation time to avoid repeated model calls.
try
{
@@ -1447,6 +1480,10 @@ Canonical profile:
DateTime? ResponseDate,
string? Location,
string? Salary,
decimal? SalaryMin,
decimal? SalaryMax,
string? SalaryCurrency,
string? SalaryPeriod,
string? NextAction,
DateTime? FollowUpAt,
bool? HasResume,
@@ -1482,11 +1519,13 @@ Canonical profile:
job.JobTitle = title;
job.CompanyId = request.CompanyId;
job.Status = string.IsNullOrWhiteSpace(request.Status) ? job.Status : request.Status.Trim();
job.Status = string.IsNullOrWhiteSpace(request.Status) ? job.Status : JobPipeline.Normalize(request.Status);
job.ResponseReceived = request.ResponseReceived;
job.ResponseDate = request.ResponseDate;
job.Location = string.IsNullOrWhiteSpace(request.Location) ? null : request.Location.Trim();
job.Salary = string.IsNullOrWhiteSpace(request.Salary) ? null : request.Salary.Trim();
(job.SalaryMin, job.SalaryMax, job.SalaryCurrency, job.SalaryPeriod) =
NormalizeSalary(request.SalaryMin, request.SalaryMax, request.SalaryCurrency, request.SalaryPeriod);
job.NextAction = string.IsNullOrWhiteSpace(request.NextAction) ? null : request.NextAction.Trim();
job.FollowUpAt = request.FollowUpAt;
job.FeedbackRequestedAt = request.FeedbackRequestedAt;
@@ -1533,6 +1572,13 @@ Canonical profile:
public sealed record UpdateStatusRequest(string Status);
public sealed record PipelineStageDto(string Key, int Order, string Category);
/// <summary>Canonical ordered pipeline stages so the UI renders one source of truth.</summary>
[HttpGet("pipeline")]
public ActionResult<IEnumerable<PipelineStageDto>> GetPipeline()
=> Ok(JobPipeline.Stages.Select(s => new PipelineStageDto(s.Key, s.Order, s.Category.ToString())));
[HttpPatch("{id:int}/status")]
public async Task<IActionResult> UpdateStatus([FromRoute] int id, [FromBody] UpdateStatusRequest request, CancellationToken cancellationToken)
{
@@ -1541,7 +1587,7 @@ Canonical profile:
if (string.IsNullOrWhiteSpace(request.Status)) return BadRequest("Status is required.");
var old = job.Status;
job.Status = request.Status.Trim();
job.Status = JobPipeline.Normalize(request.Status);
if (!string.Equals(old, job.Status, StringComparison.OrdinalIgnoreCase))
{
_db.JobEvents.Add(new JobEvent
@@ -1558,6 +1604,57 @@ Canonical profile:
return NoContent();
}
public sealed record StatusSuggestionDto(
bool HasSuggestion,
string? SuggestedStatus,
string? CurrentStatus,
string? Signal,
string? Confidence,
DateTime? MessageDate,
string? MessageSubject);
/// <summary>
/// Suggests a pipeline status from the job's most recent inbound message (e.g. an interview
/// invite or rejection). Deterministic and always human-confirmed via PATCH .../status.
/// </summary>
[HttpGet("{id:int}/status-suggestion")]
public async Task<ActionResult<StatusSuggestionDto>> GetStatusSuggestion([FromRoute] int id, CancellationToken cancellationToken)
{
var job = await _db.JobApplications.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
if (job is null) return NotFound();
var none = new StatusSuggestionDto(false, null, job.Status, null, null, null, null);
var latestInbound = await _db.Correspondences
.AsNoTracking()
.Where(c => c.JobApplicationId == id
&& c.Direction != "outbound"
&& c.From != "Me")
.OrderByDescending(c => c.Date)
.FirstOrDefaultAsync(cancellationToken);
if (latestInbound is null) return Ok(none);
var suggestion = EmailStatusClassifier.Classify(latestInbound.Subject, latestInbound.Content);
if (suggestion is null) return Ok(none);
// Don't nag when the job is already in (or past) the suggested stage.
var currentOrder = JobPipeline.OrderOf(job.Status);
var suggestedOrder = JobPipeline.OrderOf(suggestion.SuggestedStatus);
if (JobPipeline.Normalize(job.Status) == suggestion.SuggestedStatus || currentOrder >= suggestedOrder)
{
return Ok(none);
}
return Ok(new StatusSuggestionDto(
HasSuggestion: true,
SuggestedStatus: suggestion.SuggestedStatus,
CurrentStatus: job.Status,
Signal: suggestion.Signal,
Confidence: suggestion.Confidence,
MessageDate: latestInbound.Date,
MessageSubject: latestInbound.Subject));
}
[HttpPost("{id:int}/refresh-ai")]
public async Task<ActionResult<JobApplicationDto>> RefreshAi([FromRoute] int id, CancellationToken cancellationToken)
@@ -1736,46 +1833,9 @@ Canonical profile:
return Ok(all);
}
public sealed record JobStats(
int Total,
int Active,
int Deleted,
Dictionary<string, int> ByStatus,
int AppliedLast30Days,
double AverageDaysSinceApplied
);
[HttpGet("stats")]
public async Task<ActionResult<JobStats>> GetStats(CancellationToken cancellationToken)
{
var now = DateTime.Now;
var all = await _db.JobApplications
.AsNoTracking()
.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")]
@@ -1972,19 +2032,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<int> Counts);
public sealed record TagTrendPoint(string Month, List<int> Counts);
public sealed record AnalyticsOverviewDto(
List<FunnelStagePoint> Funnel,
List<ResponseRatePoint> ResponseRateBySource,
List<CompanyActivityPoint> 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<DuplicateCandidateDto> Matches);
public sealed record FollowUpDraftDto(string Subject, string Body, string Reason, DateTime SuggestedSendOn, string ContextSummary, List<string> ContextSignals, string? ThreadSubject, string? LastCorrespondenceFrom, DateTime? LastCorrespondenceAt);
@@ -2070,6 +2119,89 @@ Canonical profile:
};
}
public sealed record MatchScoreDto(
int Score,
string Band,
int MatchedCount,
int TotalKeywords,
List<string> MatchedKeywords,
List<string> MissingKeywords,
List<MatchSectionCoverageDto> SectionCoverage,
bool HasEnoughSignal);
public sealed record MatchSectionCoverageDto(string Section, int Matched, int Total);
// Builds CV text grouped by section so match coverage can show *where* the evidence sits.
private static Dictionary<string, string> BuildCvSections(ApplicationUser? user)
{
var structured = StructuredCvProfileJson.Deserialize(user?.ProfileCvStructureJson);
var sections = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
void Add(string name, IEnumerable<string?> values)
{
var text = string.Join("\n", values.Where(v => !string.IsNullOrWhiteSpace(v)));
if (!string.IsNullOrWhiteSpace(text)) sections[name] = text;
}
Add("Summary", new[] { structured.Contact.Headline }.Concat(structured.Summary));
Add("Skills", structured.Skills);
Add("Experience", structured.Jobs.SelectMany(job =>
new[] { job.Title, job.Company }.Concat(job.Bullets).Concat(job.Skills)));
Add("Education", structured.Education.SelectMany(ed =>
new[] { ed.Qualification, ed.Institution }.Concat(ed.Details)));
// Always include raw profile text (covers users who only pasted plain CV text, and
// catches keywords the structured sections missed).
if (!string.IsNullOrWhiteSpace(user?.ProfileCvText))
{
sections["Profile"] = user!.ProfileCvText!;
}
return sections;
}
/// <summary>
/// Fast, deterministic CV↔job keyword coverage score. Unlike candidate-fit (AI narrative),
/// this makes no model calls, so it returns instantly and reproducibly.
/// </summary>
[HttpGet("{id:int}/match-score")]
public async Task<ActionResult<MatchScoreDto>> GetMatchScore([FromRoute] int id, CancellationToken cancellationToken)
{
var job = await _db.JobApplications
.Include(j => j.Company)
.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
if (job is null) return NotFound();
var userId = CurrentUserId;
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
var user = await _db.Users.FirstOrDefaultAsync(x => x.Id == userId, cancellationToken);
var cvSections = BuildCvSections(user);
if (cvSections.Count == 0)
{
return BadRequest("Add your profile CV on the Profile page before running the match score.");
}
var jobText = string.Join("\n\n", new[] { job.Description, job.TranslatedDescription, job.Notes }
.Where(x => !string.IsNullOrWhiteSpace(x)));
if (string.IsNullOrWhiteSpace(jobText))
{
return BadRequest("This job does not have enough description or notes to compare against your CV.");
}
var result = _matchService.Evaluate(job.JobTitle, jobText, cvSections);
return Ok(new MatchScoreDto(
Score: result.Score,
Band: result.Band,
MatchedCount: result.MatchedCount,
TotalKeywords: result.TotalKeywords,
MatchedKeywords: result.MatchedKeywords.ToList(),
MissingKeywords: result.MissingKeywords.ToList(),
SectionCoverage: result.SectionCoverage.Select(s => new MatchSectionCoverageDto(s.Section, s.Matched, s.Total)).ToList(),
HasEnoughSignal: result.HasEnoughSignal));
}
[HttpGet("{id:int}/candidate-fit")]
public async Task<ActionResult<CandidateFitDto>> GetCandidateFit([FromRoute] int id, [FromQuery] string? attachmentIds, CancellationToken cancellationToken)
{
@@ -2667,75 +2799,7 @@ Candidate master CV:
[HttpGet("analytics-overview")]
public async Task<ActionResult<AnalyticsOverviewDto>> GetAnalyticsOverview(CancellationToken cancellationToken)
{
var activeJobs = await _db.JobApplications
.AsNoTracking()
.Include(j => j.Company)
.Where(j => !j.IsDeleted)
.ToListAsync(cancellationToken);
var funnelMap = new Dictionary<string, int>
{
["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.Company?.Source) ? "Unknown source" : j.Company!.Source!.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.Company.Name })
.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<ActionResult<TagTrendResponse>> GetTagTrends(
@@ -883,6 +883,10 @@ public sealed class ProfileCvController : ControllerBase
return run;
}
// Invoked by CvProcessingHostedService (this controller is also registered as a
// transient service). NonAction keeps it off the HTTP surface: without it the
// controller-level [Route] exposes it as an any-verb endpoint.
[NonAction]
public async Task ProcessQueuedRunAsync(int runId, CancellationToken cancellationToken)
{
var run = await _db.CvExtractionRuns.FirstOrDefaultAsync(x => x.Id == runId, cancellationToken);
+1
View File
@@ -11,6 +11,7 @@
<Compile Remove="Controllers\**\*.cs" />
<Compile Remove="Services\**\*.cs" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.14" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.14" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" />
</ItemGroup>
+15
View File
@@ -112,6 +112,7 @@ builder.Services.AddCors(options =>
// Add controllers
builder.Services.AddControllers();
builder.Services.AddOpenApi();
var dataRoot = (builder.Configuration["Data:Root"] ?? "").Trim();
if (string.IsNullOrWhiteSpace(dataRoot))
{
@@ -128,6 +129,8 @@ Directory.CreateDirectory(dataProtectionKeysPath);
builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(dataProtectionKeysPath))
.SetApplicationName("JobTracker");
builder.Services.AddSingleton<IDatabaseBackupRunner, SqliteDatabaseBackupRunner>();
builder.Services.AddHostedService<DatabaseBackupHostedService>();
builder.Services.AddHostedService<RulesHostedService>();
builder.Services.AddHostedService<FollowUpReminderHostedService>();
builder.Services.AddHostedService<DailyExportHostedService>();
@@ -153,7 +156,9 @@ builder.Services.AddHttpClient("ai-service", client =>
});
builder.Services.AddMemoryCache();
builder.Services.AddScoped<AnalyticsService>();
builder.Services.AddSingleton<ISummarizerService, SummarizerService>();
builder.Services.AddSingleton<IJobCvMatchService, JobCvMatchService>();
builder.Services.AddSingleton<ICvAiClassifier, CvAiClassifier>();
builder.Services.AddSingleton<ICvAiNormalizer, CvAiNormalizer>();
builder.Services.AddSingleton<IGoogleTokenValidator, GoogleTokenValidator>();
@@ -161,6 +166,10 @@ builder.Services.AddScoped<IGmailOAuthService, GmailOAuthService>();
builder.Services.AddSingleton<IGmailJobMatchingService, GmailJobMatchingService>();
builder.Services.AddSingleton<IGmailCorrespondenceEnrichmentService, NoOpGmailCorrespondenceEnrichmentService>();
// Provider-neutral email seam (multi-provider: Gmail today; Microsoft Graph / IMAP / manual next).
builder.Services.AddScoped<JobTrackerApi.Services.EmailProviders.IEmailProvider, JobTrackerApi.Services.EmailProviders.GmailProvider>();
builder.Services.AddScoped<JobTrackerApi.Services.EmailProviders.IEmailProviderRegistry, JobTrackerApi.Services.EmailProviders.EmailProviderRegistry>();
builder.Services.AddIdentityCore<ApplicationUser>(options =>
{
options.User.RequireUniqueEmail = true;
@@ -439,4 +448,10 @@ app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
// API schema for tooling/docs. Dev-only: not exposed in production deployments.
if (app.Environment.IsDevelopment())
{
app.MapOpenApi().AllowAnonymous();
}
app.Run();
+172
View File
@@ -0,0 +1,172 @@
using Microsoft.EntityFrameworkCore;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
namespace JobTrackerApi.Services
{
/// <summary>
/// Read-only analytics/statistics aggregation extracted from JobApplicationsController.
/// Uses the tenant-scoped <see cref="JobTrackerContext"/>, so the global OwnerUserId
/// query filters apply automatically. Behaviour is identical to the former inline
/// controller methods (GetStats / GetAnalyticsOverview).
/// </summary>
public sealed class AnalyticsService
{
private readonly JobTrackerContext _db;
public AnalyticsService(JobTrackerContext db)
{
_db = db;
}
public async Task<JobStats> 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<AnalyticsOverviewDto> 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.Id,
j.Status,
j.ResponseReceived,
j.ResponseDate,
j.DateApplied,
j.CompanyId,
CompanyName = j.Company.Name,
CompanySource = j.Company.Source
})
.ToListAsync(cancellationToken);
// Funnel = distribution across canonical stages, driven by the pipeline (one source
// of truth, so it includes every stage and normalizes legacy spellings).
var normalizedByStage = activeJobs
.GroupBy(j => JobPipeline.Normalize(j.Status))
.ToDictionary(g => g.Key, g => g.Count());
var funnel = JobPipeline.Stages
.Select(stage => new FunnelStagePoint(stage.Key, normalizedByStage.TryGetValue(stage.Key, out var c) ? c : 0))
.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);
}
// Time-in-stage: for each active job, when did it enter its current stage? Use the most
// recent StatusChanged event into that stage, else its applied date.
var activeIds = activeJobs.Select(j => j.Id).ToList();
var statusChanges = await _db.JobEvents
.AsNoTracking()
.Where(e => e.Type == "StatusChanged" && activeIds.Contains(e.JobApplicationId))
.Select(e => new { e.JobApplicationId, e.NewValue, e.At })
.ToListAsync(cancellationToken);
var lastEntryByJob = statusChanges
.GroupBy(e => e.JobApplicationId)
.ToDictionary(g => g.Key, g => g.ToList());
var occupancy = activeJobs.Select(job =>
{
var current = JobPipeline.Normalize(job.Status);
DateTime enteredAt = job.DateApplied;
if (lastEntryByJob.TryGetValue(job.Id, out var changes))
{
var lastIntoCurrent = changes
.Where(e => JobPipeline.Normalize(e.NewValue) == current)
.OrderByDescending(e => e.At)
.FirstOrDefault();
if (lastIntoCurrent is not null) enteredAt = lastIntoCurrent.At;
}
return new StageOccupancy(current, enteredAt.ToUniversalTime());
});
var timeInStage = StageAnalytics.TimeInStage(occupancy, DateTime.UtcNow)
.Select(p => new StageDurationDto(p.Stage, p.MedianDays, p.Count))
.ToList();
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,
TimeInStage: timeInStage
);
}
}
}
@@ -0,0 +1,84 @@
namespace JobTrackerApi.Services
{
public sealed class DatabaseBackupHostedService : BackgroundService
{
private readonly IDatabaseBackupRunner _runner;
private readonly ILogger<DatabaseBackupHostedService> _logger;
private readonly IConfiguration _cfg;
private readonly IStartupReadiness _startupReadiness;
public DatabaseBackupHostedService(
IDatabaseBackupRunner runner,
ILogger<DatabaseBackupHostedService> logger,
IConfiguration cfg,
IStartupReadiness startupReadiness)
{
_runner = runner;
_logger = logger;
_cfg = cfg;
_startupReadiness = startupReadiness;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await _startupReadiness.WaitUntilReadyAsync(stoppingToken);
if (!_cfg.GetValue("Backups:Enabled", true))
{
_logger.LogInformation("Automated database backups disabled (Backups:Enabled=false).");
return;
}
if (!_runner.IsSupported)
{
_logger.LogWarning("Automated database backups are unavailable for the configured provider. Configure external backups for MySQL/MariaDB.");
return;
}
var hour = _cfg.GetValue("Backups:HourLocal", 3);
if (hour < 0 || hour > 23) hour = 3;
// Catch-up: guarantee at least one recent backup exists even if the
// process never stays up long enough to reach the scheduled hour.
var latest = _runner.GetLatestBackupUtc();
if (latest is null || latest < DateTime.UtcNow.AddHours(-24))
{
await TryBackupAsync(stoppingToken);
}
while (!stoppingToken.IsCancellationRequested)
{
var now = DateTime.Now;
var next = new DateTime(now.Year, now.Month, now.Day, hour, 0, 0);
if (next <= now) next = next.AddDays(1);
_logger.LogInformation("Next database backup scheduled at {Next}.", next);
try
{
await Task.Delay(next - now, stoppingToken);
}
catch (TaskCanceledException)
{
break;
}
await TryBackupAsync(stoppingToken);
}
}
private async Task TryBackupAsync(CancellationToken ct)
{
try
{
await _runner.RunOnceAsync(ct);
}
catch (OperationCanceledException)
{
}
catch (Exception ex)
{
_logger.LogError(ex, "Database backup failed.");
}
}
}
}
@@ -0,0 +1,110 @@
using Microsoft.Data.Sqlite;
namespace JobTrackerApi.Services
{
public interface IDatabaseBackupRunner
{
string BackupsRoot { get; }
bool IsSupported { get; }
/// <summary>Creates one backup file and prunes old ones. Returns the backup path, or null when unsupported.</summary>
Task<string?> RunOnceAsync(CancellationToken ct);
DateTime? GetLatestBackupUtc();
}
public sealed class SqliteDatabaseBackupRunner : IDatabaseBackupRunner
{
public const string BackupFilePrefix = "jobtracker_backup_";
private readonly ILogger<SqliteDatabaseBackupRunner> _logger;
private readonly string _connectionString;
private readonly int _retainCount;
public string BackupsRoot { get; }
public bool IsSupported { get; }
public SqliteDatabaseBackupRunner(IConfiguration cfg, AppPaths paths, ILogger<SqliteDatabaseBackupRunner> logger)
{
_logger = logger;
var provider = (cfg["Database:Provider"] ?? "sqlite").Trim().ToLowerInvariant();
var cs = cfg.GetConnectionString("JobTracker");
if (string.IsNullOrWhiteSpace(cs))
{
cs = $"Data Source={paths.GetDbPath()}";
provider = "sqlite";
}
_connectionString = cs;
IsSupported = provider == "sqlite";
BackupsRoot = Path.Combine(paths.DataRoot, "backups");
_retainCount = Math.Clamp(cfg.GetValue("Backups:RetainCount", 14), 1, 365);
}
// Test-friendly constructor.
public SqliteDatabaseBackupRunner(string connectionString, string backupsRoot, int retainCount, ILogger<SqliteDatabaseBackupRunner> logger)
{
_logger = logger;
_connectionString = connectionString;
IsSupported = true;
BackupsRoot = backupsRoot;
_retainCount = Math.Clamp(retainCount, 1, 365);
}
public async Task<string?> RunOnceAsync(CancellationToken ct)
{
if (!IsSupported)
{
_logger.LogWarning("Automated backups only support the SQLite provider. Configure external backups for MySQL/MariaDB.");
return null;
}
Directory.CreateDirectory(BackupsRoot);
var target = Path.Combine(BackupsRoot, $"{BackupFilePrefix}{DateTime.UtcNow:yyyyMMdd_HHmmss}.db");
if (File.Exists(target)) File.Delete(target);
await using (var connection = new SqliteConnection(_connectionString))
{
await connection.OpenAsync(ct);
await using var command = connection.CreateCommand();
// VACUUM INTO produces a consistent, compacted snapshot without blocking writers (WAL).
command.CommandText = $"VACUUM INTO '{target.Replace("'", "''")}'";
await command.ExecuteNonQueryAsync(ct);
}
_logger.LogInformation("Database backup written: {File}.", target);
PruneOldBackups();
return target;
}
public DateTime? GetLatestBackupUtc()
{
if (!Directory.Exists(BackupsRoot)) return null;
var latest = ListBackups().FirstOrDefault();
return latest?.LastWriteTimeUtc;
}
private void PruneOldBackups()
{
foreach (var stale in ListBackups().Skip(_retainCount))
{
try
{
stale.Delete();
_logger.LogInformation("Pruned old database backup: {File}.", stale.Name);
}
catch (IOException ex)
{
_logger.LogWarning(ex, "Could not prune old database backup {File}.", stale.Name);
}
}
}
private IOrderedEnumerable<FileInfo> ListBackups()
=> new DirectoryInfo(BackupsRoot)
.EnumerateFiles($"{BackupFilePrefix}*.db")
.OrderByDescending(f => f.LastWriteTimeUtc);
}
}
@@ -0,0 +1,63 @@
using JobTrackerApi.Services;
namespace JobTrackerApi.Services.EmailProviders
{
/// <summary>
/// Gmail implementation of <see cref="IEmailProvider"/>. Adapts the existing
/// <see cref="IGmailOAuthService"/> (Gmail REST client) to the provider-neutral contract,
/// mapping Gmail DTOs to the neutral shapes.
/// </summary>
public sealed class GmailProvider : IEmailProvider
{
private readonly IGmailOAuthService _gmail;
public GmailProvider(IGmailOAuthService gmail)
{
_gmail = gmail;
}
public string ProviderKey => "gmail";
public async Task<EmailConnectionInfo?> 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<IReadOnlyList<EmailMessageSummary>> 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<IReadOnlyList<EmailMessageSummary>> ListThreadMessagesAsync(string ownerUserId, string threadId, CancellationToken cancellationToken)
{
var messages = await _gmail.ListThreadMessagesAsync(ownerUserId, threadId, cancellationToken);
return messages.Select(ToSummary).ToList();
}
public async Task<EmailMessageDetail> 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);
}
}
@@ -0,0 +1,69 @@
namespace JobTrackerApi.Services.EmailProviders
{
/// <summary>
/// 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 (<see cref="GmailProvider"/>); the controller migration and additional
/// providers land in follow-up slices.
/// </summary>
public interface IEmailProvider
{
/// <summary>Stable key: "gmail" | "microsoft" | "imap" | "manual".</summary>
string ProviderKey { get; }
/// <summary>The user's active connection for this provider, or null if not connected.</summary>
Task<EmailConnectionInfo?> GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken);
/// <summary>Search the user's mailbox. <paramref name="query"/> is provider-specific syntax.</summary>
Task<IReadOnlyList<EmailMessageSummary>> SearchAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken);
/// <summary>All messages in a thread/conversation.</summary>
Task<IReadOnlyList<EmailMessageSummary>> ListThreadMessagesAsync(string ownerUserId, string threadId, CancellationToken cancellationToken);
/// <summary>Full message content (body + attachments metadata).</summary>
Task<EmailMessageDetail> 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<string> Labels,
IReadOnlyList<EmailAttachmentRef> Attachments);
/// <summary>Resolves a registered <see cref="IEmailProvider"/> by its key.</summary>
public interface IEmailProviderRegistry
{
IReadOnlyList<IEmailProvider> All { get; }
IEmailProvider? Get(string? providerKey);
}
public sealed class EmailProviderRegistry : IEmailProviderRegistry
{
private readonly Dictionary<string, IEmailProvider> _byKey;
public EmailProviderRegistry(IEnumerable<IEmailProvider> providers)
{
All = providers.ToList();
_byKey = All.ToDictionary(p => p.ProviderKey, StringComparer.OrdinalIgnoreCase);
}
public IReadOnlyList<IEmailProvider> All { get; }
public IEmailProvider? Get(string? providerKey)
=> !string.IsNullOrWhiteSpace(providerKey) && _byKey.TryGetValue(providerKey, out var p) ? p : null;
}
}
@@ -0,0 +1,61 @@
namespace JobTrackerApi.Services
{
public sealed record EmailStatusSuggestion(string SuggestedStatus, string Signal, string Confidence);
/// <summary>
/// Deterministic email → pipeline-status classifier. Scans subject/body for outcome signals and
/// suggests a canonical status. No AI: instant, reproducible, and safe (the user always confirms).
/// Priority matters — a rejection email often still mentions "interview", so rejection wins.
/// </summary>
public static class EmailStatusClassifier
{
// Ordered highest-priority first. Each stage lists lowercase phrases to look for.
private static readonly (string Status, string Confidence, string[] Phrases)[] Rules =
{
("Rejected", "high", new[]
{
"regret to inform", "we regret", "unfortunately, we", "not moving forward",
"not be moving forward", "decided not to proceed", "will not be proceeding",
"not to proceed", "not been selected", "will not be progressing",
"unable to offer", "position has been filled", "no longer being considered",
"decided to move forward with other", "pursue other candidates",
"not to move forward", "were not successful", "was not successful",
}),
("Offer", "high", new[]
{
"pleased to offer", "delighted to offer", "happy to offer", "offer of employment",
"job offer", "we would like to offer", "formal offer", "extend an offer",
"offer letter", "excited to offer",
}),
("Interview", "medium", new[]
{
"invite you to interview", "invite you to an interview", "schedule an interview",
"would like to invite you", "phone screen", "phone interview", "video interview",
"technical interview", "next steps in the", "your availability for a call",
"availability for an interview", "set up a call", "set up an interview",
"meet the team", "book a time", "invitation to interview", "interview invitation",
"like to speak with you", "move to the interview",
}),
};
// Weaker single-word cues only fire when no strong phrase matched (kept low-confidence).
private static readonly string[] InterviewWeakCues = { "interview", "assessment", "coding challenge", "take-home" };
public static EmailStatusSuggestion? Classify(string? subject, string? body)
{
var text = $"{subject}\n{body}".ToLowerInvariant();
if (string.IsNullOrWhiteSpace(text)) return null;
foreach (var (status, confidence, phrases) in Rules)
{
var hit = phrases.FirstOrDefault(p => text.Contains(p, StringComparison.Ordinal));
if (hit is not null) return new EmailStatusSuggestion(status, hit, confidence);
}
var weak = InterviewWeakCues.FirstOrDefault(c => text.Contains(c, StringComparison.Ordinal));
if (weak is not null) return new EmailStatusSuggestion("Interview", weak, "low");
return null;
}
}
}
+208
View File
@@ -0,0 +1,208 @@
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
using JobTrackerApi.Services.JobImport;
namespace JobTrackerApi.Services
{
/// <summary>One keyword drawn from the job posting and whether the CV covers it.</summary>
public sealed record MatchKeyword(string Keyword, int Weight, bool InTitle, bool Matched);
/// <summary>How many of the matched keywords appear in a given CV section.</summary>
public sealed record MatchSectionCoverage(string Section, int Matched, int Total);
public sealed record JobCvMatchResult(
int Score,
string Band,
int MatchedCount,
int TotalKeywords,
IReadOnlyList<string> MatchedKeywords,
IReadOnlyList<string> MissingKeywords,
IReadOnlyList<MatchSectionCoverage> SectionCoverage,
bool HasEnoughSignal);
public interface IJobCvMatchService
{
JobCvMatchResult Evaluate(string jobTitle, string jobText, IReadOnlyDictionary<string, string> cvSections);
}
/// <summary>
/// Deterministic CV↔job keyword coverage score. No AI: the same inputs always produce the
/// same number so users get a stable, reproducible signal (the Jobscan-style differentiator).
/// The AI narrative lives separately in the candidate-fit endpoint.
/// </summary>
public sealed class JobCvMatchService : IJobCvMatchService
{
// Weights: curated skill tags are high-signal; salient posting terms are the long tail.
private const int CuratedTagWeight = 3;
private const int TermWeight = 1;
private const int TitleBonus = 2;
private const int MaxKeywords = 28;
private static readonly Regex TokenPattern = new(@"[a-z0-9][a-z0-9+.#-]*", RegexOptions.Compiled);
private static readonly HashSet<string> StopWords = new(StringComparer.OrdinalIgnoreCase)
{
"the", "and", "for", "with", "you", "your", "our", "are", "will", "have", "has", "that",
"this", "from", "not", "but", "all", "can", "who", "how", "why", "what", "when", "who",
"job", "role", "work", "working", "team", "teams", "company", "years", "year", "experience",
"experienced", "skills", "skill", "ability", "able", "strong", "good", "great", "excellent",
"including", "include", "includes", "well", "using", "use", "used", "within", "across",
"into", "onto", "their", "them", "they", "were", "was", "would", "should", "could", "must",
"new", "also", "per", "via", "etc", "such", "any", "one", "two", "three", "day", "days",
"week", "weeks", "month", "months", "time", "full", "part", "based", "join", "looking",
"seeking", "candidate", "candidates", "applicant", "position", "positions", "opportunity",
"responsibilities", "requirements", "required", "preferred", "plus", "nice", "want", "need",
"needs", "help", "make", "made", "get", "got", "more", "most", "many", "much", "each",
"other", "others", "some", "than", "then", "there", "here", "about", "over", "under", "out",
"off", "its", "his", "her", "she", "him", "may", "might", "high", "low", "level", "levels",
"environment", "environments", "world", "people", "person", "customer", "customers", "client",
"clients", "product", "products", "service", "services", "business", "solution", "solutions",
"project", "projects", "process", "processes", "development", "develop", "developer",
// Seniority / role-title words: noise for CV keyword matching (the hard skills are what count).
"senior", "junior", "lead", "principal", "mid", "staff", "engineer", "engineers",
"engineering", "manager", "specialist", "analyst", "consultant", "administrator",
"coordinator", "associate", "intern", "officer", "director", "professional",
};
public JobCvMatchResult Evaluate(string jobTitle, string jobText, IReadOnlyDictionary<string, string> cvSections)
{
jobTitle ??= string.Empty;
jobText ??= string.Empty;
cvSections ??= new Dictionary<string, string>();
var titleTokens = Tokenize(jobTitle).ToHashSet(StringComparer.OrdinalIgnoreCase);
var keywords = BuildKeywords(jobTitle, jobText, titleTokens);
// Combine all CV sections into one searchable corpus, plus keep per-section text for coverage.
var sectionCorpora = cvSections
.Where(kvp => !string.IsNullOrWhiteSpace(kvp.Value))
.ToDictionary(kvp => kvp.Key, kvp => Normalize(kvp.Value), StringComparer.OrdinalIgnoreCase);
var fullCorpus = string.Join(" \n ", sectionCorpora.Values);
var evaluated = keywords
.Select(k => k with { Matched = CorpusContains(fullCorpus, k.Keyword) })
.ToList();
var totalWeight = evaluated.Sum(k => k.Weight);
var matchedWeight = evaluated.Where(k => k.Matched).Sum(k => k.Weight);
var hasEnoughSignal = evaluated.Count >= 3 && sectionCorpora.Count > 0;
var score = totalWeight == 0 ? 0 : (int)Math.Round(100.0 * matchedWeight / totalWeight, MidpointRounding.AwayFromZero);
score = Math.Clamp(score, 0, 100);
var band = !hasEnoughSignal ? "Unknown" : score >= 75 ? "Strong" : score >= 50 ? "Partial" : "Low";
var matchedKeywords = evaluated.Where(k => k.Matched)
.OrderByDescending(k => k.Weight).ThenBy(k => k.Keyword, StringComparer.OrdinalIgnoreCase)
.Select(k => k.Keyword).ToList();
var missingKeywords = evaluated.Where(k => !k.Matched)
.OrderByDescending(k => k.Weight).ThenBy(k => k.Keyword, StringComparer.OrdinalIgnoreCase)
.Select(k => k.Keyword).ToList();
var sectionCoverage = sectionCorpora
.Select(section => new MatchSectionCoverage(
section.Key,
evaluated.Count(k => CorpusContains(section.Value, k.Keyword)),
evaluated.Count))
.Where(sc => sc.Total > 0)
.OrderByDescending(sc => sc.Matched)
.ToList();
return new JobCvMatchResult(
Score: score,
Band: band,
MatchedCount: matchedKeywords.Count,
TotalKeywords: evaluated.Count,
MatchedKeywords: matchedKeywords,
MissingKeywords: missingKeywords,
SectionCoverage: sectionCoverage,
HasEnoughSignal: hasEnoughSignal);
}
private static List<MatchKeyword> BuildKeywords(string jobTitle, string jobText, HashSet<string> titleTokens)
{
var combined = $"{jobTitle}\n{jobText}";
var byKey = new Dictionary<string, MatchKeyword>(StringComparer.OrdinalIgnoreCase);
// 1) Curated skill tags: high-signal, canonical spelling.
foreach (var tag in SkillTagger.Detect(combined))
{
var inTitle = TitleContains(jobTitle, tag);
byKey[tag] = new MatchKeyword(tag, CuratedTagWeight + (inTitle ? TitleBonus : 0), inTitle, false);
}
// 2) Salient posting terms: frequency-ranked content words from the description.
var frequencies = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
foreach (var token in Tokenize(jobText))
{
if (token.Length < 3 || StopWords.Contains(token) || IsNumeric(token)) continue;
frequencies[token] = frequencies.TryGetValue(token, out var c) ? c + 1 : 1;
}
var rankedTerms = frequencies
.Where(kvp => kvp.Value >= 1)
.OrderByDescending(kvp => titleTokens.Contains(kvp.Key) ? 1 : 0)
.ThenByDescending(kvp => kvp.Value)
.ThenBy(kvp => kvp.Key, StringComparer.OrdinalIgnoreCase)
.Select(kvp => kvp.Key);
foreach (var term in rankedTerms)
{
if (byKey.Count >= MaxKeywords) break;
if (byKey.ContainsKey(term)) continue;
var inTitle = titleTokens.Contains(term);
byKey[term] = new MatchKeyword(term, TermWeight + (inTitle ? TitleBonus : 0), inTitle, false);
}
return byKey.Values
.OrderByDescending(k => k.Weight)
.ThenBy(k => k.Keyword, StringComparer.OrdinalIgnoreCase)
.Take(MaxKeywords)
.ToList();
}
private static bool TitleContains(string title, string phrase)
=> Normalize(title).Contains(Normalize(phrase), StringComparison.Ordinal);
private static bool CorpusContains(string normalizedCorpus, string keyword)
{
var needle = Normalize(keyword);
if (needle.Length == 0) return false;
// Word-boundary-ish match to avoid "go" matching "goal".
var idx = normalizedCorpus.IndexOf(needle, StringComparison.Ordinal);
while (idx >= 0)
{
var beforeOk = idx == 0 || !char.IsLetterOrDigit(normalizedCorpus[idx - 1]);
var afterPos = idx + needle.Length;
var afterOk = afterPos >= normalizedCorpus.Length || !char.IsLetterOrDigit(normalizedCorpus[afterPos]);
if (beforeOk && afterOk) return true;
idx = normalizedCorpus.IndexOf(needle, idx + 1, StringComparison.Ordinal);
}
return false;
}
private static IEnumerable<string> Tokenize(string text)
{
if (string.IsNullOrWhiteSpace(text)) yield break;
foreach (Match m in TokenPattern.Matches(text.ToLowerInvariant()))
{
yield return m.Value.Trim('-', '.', '+', '#');
}
}
private static bool IsNumeric(string token)
=> token.All(c => char.IsDigit(c) || c is '.' or '-' or '+');
private static string Normalize(string text)
{
if (string.IsNullOrWhiteSpace(text)) return string.Empty;
var sb = new StringBuilder(text.Length);
foreach (var ch in text.ToLowerInvariant())
{
sb.Append(char.IsWhiteSpace(ch) ? ' ' : ch);
}
return sb.ToString();
}
}
}
@@ -9,8 +9,10 @@ public static class SkillTagger
{
private static readonly (string Tag, Regex Pattern, int Weight)[] Patterns =
{
("C#", new Regex(@"\bC#\b|\bcsharp\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 6),
(".NET", new Regex(@"\b\.NET\b|\bASP\.NET\b|\bDOTNET\b|\bEntity Framework\b|\bEF Core\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 6),
// Symbol skills need punctuation-tolerant boundaries: \b fails next to '#'/'.'
// (both non-word chars), which previously left "C#," and ".NET," undetected.
("C#", new Regex(@"(?<![A-Za-z0-9#])C#|\bc[-\s]?sharp\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 6),
(".NET", new Regex(@"(?<![A-Za-z0-9.])\.NET\b|\bASP\.NET\b|\bDOTNET\b|\bEntity Framework\b|\bEF Core\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 6),
("Python", new Regex(@"\bPython\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 6),
("Java", new Regex(@"\bJava\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 5),
("JavaScript", new Regex(@"\bJavaScript\b|\bJS\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 5),
+75
View File
@@ -0,0 +1,75 @@
namespace JobTrackerApi.Services
{
public enum PipelineCategory
{
Active,
Success,
Closed,
}
public sealed record PipelineStage(string Key, int Order, PipelineCategory Category);
/// <summary>
/// Canonical job-application pipeline: the single source of truth for the ordered set of
/// statuses, their grouping, and how free-text/legacy values normalize onto them.
/// Status remains a free-text column so custom values are never destroyed; this only
/// canonicalizes casing and known synonyms.
/// </summary>
public static class JobPipeline
{
public const string DefaultStatus = "Applied";
public static readonly IReadOnlyList<PipelineStage> Stages = new List<PipelineStage>
{
new("Applied", 1, PipelineCategory.Active),
new("Waiting", 2, PipelineCategory.Active),
new("Interview", 3, PipelineCategory.Active),
new("Offer", 4, PipelineCategory.Success),
new("Rejected", 5, PipelineCategory.Closed),
new("Ghosted", 6, PipelineCategory.Closed),
};
private static readonly Dictionary<string, string> Canonical =
Stages.ToDictionary(s => s.Key, s => s.Key, StringComparer.OrdinalIgnoreCase);
// Legacy/synonym spellings that should collapse onto a canonical stage.
private static readonly Dictionary<string, string> Aliases = new(StringComparer.OrdinalIgnoreCase)
{
["interviewing"] = "Interview",
["interviews"] = "Interview",
["interviewed"] = "Interview",
["in interview"] = "Interview",
["awaiting response"] = "Waiting",
["awaiting"] = "Waiting",
["in progress"] = "Waiting",
["pending"] = "Waiting",
["no response"] = "Ghosted",
["no reply"] = "Ghosted",
["declined"] = "Rejected",
};
/// <summary>
/// Returns the canonical status for a raw value: trims, matches a stage case-insensitively,
/// or maps a known synonym. Unknown non-empty values are preserved (trimmed) so custom
/// statuses survive. Empty/whitespace becomes the default stage.
/// </summary>
public static string Normalize(string? status)
{
var trimmed = (status ?? string.Empty).Trim();
if (trimmed.Length == 0) return DefaultStatus;
if (Canonical.TryGetValue(trimmed, out var canonical)) return canonical;
if (Aliases.TryGetValue(trimmed, out var alias)) return alias;
return trimmed;
}
public static bool IsCanonical(string? status)
=> !string.IsNullOrWhiteSpace(status) && Canonical.ContainsKey(status.Trim());
public static int OrderOf(string? status)
{
var normalized = Normalize(status);
var stage = Stages.FirstOrDefault(s => string.Equals(s.Key, normalized, StringComparison.OrdinalIgnoreCase));
return stage?.Order ?? int.MaxValue; // custom statuses sort last
}
}
}
+45
View File
@@ -0,0 +1,45 @@
namespace JobTrackerApi.Services
{
public sealed record StageDurationPoint(string Stage, int Order, double MedianDays, int Count);
/// <summary>One job's position: its canonical stage and when it entered that stage.</summary>
public sealed record StageOccupancy(string Status, DateTime EnteredStageAtUtc);
/// <summary>
/// Pure time-in-stage analytics: for each active pipeline stage, the median number of days
/// the jobs currently sitting there have been waiting. Closed stages (Rejected/Ghosted) and
/// the terminal success stage (Offer) are excluded — "how long has this been stuck" only
/// makes sense for stages you still act on.
/// </summary>
public static class StageAnalytics
{
public static List<StageDurationPoint> TimeInStage(IEnumerable<StageOccupancy> jobs, DateTime nowUtc)
{
var byStage = jobs
.Select(j => (Stage: JobPipeline.Normalize(j.Status), Days: Math.Max(0, (nowUtc - j.EnteredStageAtUtc).TotalDays)))
.Where(x => JobPipeline.Stages.Any(s => s.Key == x.Stage && s.Category == PipelineCategory.Active))
.GroupBy(x => x.Stage);
var points = new List<StageDurationPoint>();
foreach (var group in byStage)
{
var days = group.Select(x => x.Days).OrderBy(x => x).ToList();
points.Add(new StageDurationPoint(
Stage: group.Key,
Order: JobPipeline.OrderOf(group.Key),
MedianDays: Median(days),
Count: days.Count));
}
return points.OrderBy(p => p.Order).ToList();
}
private static double Median(IReadOnlyList<double> sorted)
{
if (sorted.Count == 0) return 0;
var mid = sorted.Count / 2;
var median = sorted.Count % 2 == 0 ? (sorted[mid - 1] + sorted[mid]) / 2d : sorted[mid];
return Math.Round(median, 1);
}
}
}
@@ -484,6 +484,12 @@ public static class StartupInitializationExtensions
EnsureColumn(conn, "JobApplications", "LastReminderEmailSentAt", "ALTER TABLE JobApplications ADD COLUMN LastReminderEmailSentAt TEXT NULL;");
EnsureColumn(conn, "JobApplications", "RecruiterMessageDraft", "ALTER TABLE JobApplications ADD COLUMN RecruiterMessageDraft TEXT NULL;");
// Structured salary fields (EF maps decimal to TEXT on SQLite).
EnsureColumn(conn, "JobApplications", "SalaryMin", "ALTER TABLE JobApplications ADD COLUMN SalaryMin TEXT NULL;");
EnsureColumn(conn, "JobApplications", "SalaryMax", "ALTER TABLE JobApplications ADD COLUMN SalaryMax TEXT NULL;");
EnsureColumn(conn, "JobApplications", "SalaryCurrency", "ALTER TABLE JobApplications ADD COLUMN SalaryCurrency TEXT NULL;");
EnsureColumn(conn, "JobApplications", "SalaryPeriod", "ALTER TABLE JobApplications ADD COLUMN SalaryPeriod TEXT NULL;");
// Ensure ownership columns exist even on non-legacy DBs.
EnsureColumn(conn, "Companies", "OwnerUserId", "ALTER TABLE Companies ADD COLUMN OwnerUserId TEXT NULL;");
EnsureColumn(conn, "JobApplications", "OwnerUserId", "ALTER TABLE JobApplications ADD COLUMN OwnerUserId TEXT NULL;");
@@ -499,6 +505,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);
}
@@ -607,6 +623,10 @@ public static class StartupInitializationExtensions
EnsureMySqlColumn(conn, "JobApplications", "DeletedAt", "ALTER TABLE `JobApplications` ADD COLUMN `DeletedAt` datetime NULL;");
EnsureMySqlColumn(conn, "JobApplications", "Location", "ALTER TABLE `JobApplications` ADD COLUMN `Location` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "Salary", "ALTER TABLE `JobApplications` ADD COLUMN `Salary` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "SalaryMin", "ALTER TABLE `JobApplications` ADD COLUMN `SalaryMin` decimal(18,2) NULL;");
EnsureMySqlColumn(conn, "JobApplications", "SalaryMax", "ALTER TABLE `JobApplications` ADD COLUMN `SalaryMax` decimal(18,2) NULL;");
EnsureMySqlColumn(conn, "JobApplications", "SalaryCurrency", "ALTER TABLE `JobApplications` ADD COLUMN `SalaryCurrency` varchar(8) NULL;");
EnsureMySqlColumn(conn, "JobApplications", "SalaryPeriod", "ALTER TABLE `JobApplications` ADD COLUMN `SalaryPeriod` varchar(16) NULL;");
EnsureMySqlColumn(conn, "JobApplications", "NextAction", "ALTER TABLE `JobApplications` ADD COLUMN `NextAction` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "FollowUpAt", "ALTER TABLE `JobApplications` ADD COLUMN `FollowUpAt` datetime NULL;");
EnsureMySqlColumn(conn, "JobApplications", "FeedbackRequestedAt", "ALTER TABLE `JobApplications` ADD COLUMN `FeedbackRequestedAt` datetime NULL;");
@@ -819,6 +839,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();
@@ -1,99 +0,0 @@
{
"Version": "dailyexport.v1",
"CreatedAt": "2026-03-25T02:00:00.0368687+01:00",
"Companies": [
{
"Id": 1,
"OwnerUserId": "23dc196b-f227-4499-93fe-403d8801e21c",
"Name": "Acme Browser QA",
"Location": null,
"Source": null,
"RecruiterName": "Maria Recruiter",
"RecruiterEmail": "maria@acme.test",
"RecruiterLinkedIn": null,
"LastContactedAt": "2026-03-24T11:15:21.4772436",
"NextContactAt": "2026-03-24T00:00:00",
"PipelineStage": null
}
],
"JobApplications": [
{
"Id": 1,
"OwnerUserId": "23dc196b-f227-4499-93fe-403d8801e21c",
"JobTitle": "Backend Developer",
"CompanyId": 1,
"Company": null,
"Status": "Waiting",
"DateApplied": "2026-03-01T13:00:00+01:00",
"Location": null,
"Salary": null,
"NextAction": null,
"FollowUpAt": "2026-03-24T00:00:00",
"FeedbackRequestedAt": null,
"RecruiterMessageDraft": "Saved browser recruiter message",
"HasResume": true,
"HasCoverLetter": true,
"HasPortfolio": false,
"HasOtherAttachment": false,
"IsDeleted": false,
"DeletedAt": null,
"ResponseReceived": true,
"ResponseDate": null,
"Notes": "Browser-seeded notes\n\n\u003C\u003C\u003CAPPLICATION_ANSWER_DRAFT\u003E\u003E\u003E\nSaved browser application answer\n\u003C\u003C\u003CEND_APPLICATION_ANSWER_DRAFT\u003E\u003E\u003E",
"CoverLetterText": "Saved browser cover letter",
"JobUrl": "https://example.test/backend-developer",
"Description": "Need .NET APIs and strong stakeholder communication.",
"TranslatedDescription": null,
"DescriptionLanguage": null,
"Tags": "[\u0022.NET\u0022, \u0022APIs\u0022, \u0022Communication\u0022]",
"Deadline": null,
"ShortSummary": "Strong overlap in backend API delivery.",
"TailoredCvText": "Saved browser tailored CV",
"TailoredCvUpdatedAt": "2026-03-24T10:58:13.226164+01:00",
"LastReminderEmailSentAt": null,
"Messages": [],
"Attachments": [],
"Events": [],
"DaysSince": 23
}
],
"Correspondence": [
{
"Id": 1,
"JobApplicationId": 1,
"From": "Company",
"Subject": "Backend Developer application update",
"Channel": "Email",
"ExternalMessageId": "browser-msg-1",
"ExternalThreadId": "browser-thread-1",
"ExternalFrom": "Maria Recruiter \u003Cmaria@acme.test\u003E",
"ExternalTo": "admin@example.com",
"Content": "We are aligning interview slots and need someone who can own the API layer.",
"Date": "2026-03-10T10:00:00+01:00"
},
{
"Id": 2,
"JobApplicationId": 1,
"From": "Me",
"Subject": "Re: Backend Developer application update",
"Channel": "Email",
"ExternalMessageId": null,
"ExternalThreadId": null,
"ExternalFrom": null,
"ExternalTo": null,
"Content": "Hi Maria,\n\nEdited browser follow-up.\n\nThanks,\nadmin@example.com",
"Date": "2026-03-24T11:15:21.4521755"
}
],
"Attachments": [],
"Events": [],
"Rules": {
"Id": 1,
"AppliedFollowUpDays": 14,
"AppliedGhostDays": 30,
"OfferFollowUpDays": 7,
"OfferGhostDays": 14,
"FeedbackFollowUpDays": 7,
"FeedbackGhostDays": 14
}
}
@@ -1,99 +0,0 @@
{
"Version": "dailyexport.v1",
"CreatedAt": "2026-03-26T02:00:00.005823+01:00",
"Companies": [
{
"Id": 1,
"OwnerUserId": "23dc196b-f227-4499-93fe-403d8801e21c",
"Name": "Acme Browser QA",
"Location": null,
"Source": null,
"RecruiterName": "Maria Recruiter",
"RecruiterEmail": "maria@acme.test",
"RecruiterLinkedIn": null,
"LastContactedAt": "2026-03-24T11:15:21.4772436",
"NextContactAt": "2026-03-24T00:00:00",
"PipelineStage": null
}
],
"JobApplications": [
{
"Id": 1,
"OwnerUserId": "23dc196b-f227-4499-93fe-403d8801e21c",
"JobTitle": "Backend Developer",
"CompanyId": 1,
"Company": null,
"Status": "Waiting",
"DateApplied": "2026-03-01T13:00:00+01:00",
"Location": null,
"Salary": null,
"NextAction": null,
"FollowUpAt": "2026-03-24T00:00:00",
"FeedbackRequestedAt": null,
"RecruiterMessageDraft": "Saved browser recruiter message",
"HasResume": true,
"HasCoverLetter": true,
"HasPortfolio": false,
"HasOtherAttachment": false,
"IsDeleted": false,
"DeletedAt": null,
"ResponseReceived": true,
"ResponseDate": null,
"Notes": "Browser-seeded notes\n\n\u003C\u003C\u003CAPPLICATION_ANSWER_DRAFT\u003E\u003E\u003E\nSaved browser application answer\n\u003C\u003C\u003CEND_APPLICATION_ANSWER_DRAFT\u003E\u003E\u003E",
"CoverLetterText": "Saved browser cover letter",
"JobUrl": "https://example.test/backend-developer",
"Description": "Need .NET APIs and strong stakeholder communication.",
"TranslatedDescription": null,
"DescriptionLanguage": null,
"Tags": "[\u0022.NET\u0022, \u0022APIs\u0022, \u0022Communication\u0022]",
"Deadline": null,
"ShortSummary": "Strong overlap in backend API delivery.",
"TailoredCvText": "Saved browser tailored CV",
"TailoredCvUpdatedAt": "2026-03-24T10:58:13.226164+01:00",
"LastReminderEmailSentAt": null,
"Messages": [],
"Attachments": [],
"Events": [],
"DaysSince": 24
}
],
"Correspondence": [
{
"Id": 1,
"JobApplicationId": 1,
"From": "Company",
"Subject": "Backend Developer application update",
"Channel": "Email",
"ExternalMessageId": "browser-msg-1",
"ExternalThreadId": "browser-thread-1",
"ExternalFrom": "Maria Recruiter \u003Cmaria@acme.test\u003E",
"ExternalTo": "admin@example.com",
"Content": "We are aligning interview slots and need someone who can own the API layer.",
"Date": "2026-03-10T10:00:00+01:00"
},
{
"Id": 2,
"JobApplicationId": 1,
"From": "Me",
"Subject": "Re: Backend Developer application update",
"Channel": "Email",
"ExternalMessageId": null,
"ExternalThreadId": null,
"ExternalFrom": null,
"ExternalTo": null,
"Content": "Hi Maria,\n\nEdited browser follow-up.\n\nThanks,\nadmin@example.com",
"Date": "2026-03-24T11:15:21.4521755"
}
],
"Attachments": [],
"Events": [],
"Rules": {
"Id": 1,
"AppliedFollowUpDays": 14,
"AppliedGhostDays": 30,
"OfferFollowUpDays": 7,
"OfferGhostDays": 14,
"FeedbackFollowUpDays": 7,
"FeedbackGhostDays": 14
}
}
@@ -1,16 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<key id="9a89a42c-d2bd-4770-83fb-5930685432db" version="1">
<creationDate>2026-03-24T09:54:28.8487759Z</creationDate>
<activationDate>2026-03-24T09:54:28.8487759Z</activationDate>
<expirationDate>2026-06-22T09:54:28.8487759Z</expirationDate>
<descriptor deserializerType="Microsoft.AspNetCore.DataProtection.AuthenticatedEncryption.ConfigurationModel.AuthenticatedEncryptorDescriptorDeserializer, Microsoft.AspNetCore.DataProtection, Version=9.0.0.0, Culture=neutral, PublicKeyToken=adb9793829ddae60">
<descriptor>
<encryption algorithm="AES_256_CBC" />
<validation algorithm="HMACSHA256" />
<masterKey p4:requiresEncryption="true" xmlns:p4="http://schemas.asp.net/2015/03/dataProtection">
<!-- Warning: the key below is in an unencrypted form. -->
<value>LXbXqbpiEXn0OM6fr/TuXDBcZd83DvOInTI09PGZRr1Z20LQCD/PUKF1oo9UwC4O1VgK3wA//yxH9PPCIPzEaw==</value>
</masterKey>
</descriptor>
</descriptor>
</key>
+33
View File
@@ -0,0 +1,33 @@
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<string, int> 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 StageDurationDto(string Stage, double MedianDays, int Count);
public sealed record AnalyticsOverviewDto(
List<FunnelStagePoint> Funnel,
List<ResponseRatePoint> ResponseRateBySource,
List<CompanyActivityPoint> TopCompanies,
double? MedianDaysToFirstResponse,
int TotalResponses,
int TotalActive,
List<StageDurationDto> TimeInStage
);
}
+6
View File
@@ -13,6 +13,12 @@ public class JobApplication
public DateTime DateApplied { get; set; } = DateTime.UtcNow;
public string? Location { get; set; }
public string? Salary { get; set; }
// Structured salary; the free-text Salary field is kept for display/back-compat.
public decimal? SalaryMin { get; set; }
public decimal? SalaryMax { get; set; }
public string? SalaryCurrency { get; set; } // e.g. "NOK", "GBP", "EUR"
public string? SalaryPeriod { get; set; } // "year" | "month" | "hour"
public string? NextAction { get; set; }
public DateTime? FollowUpAt { get; set; }
public DateTime? FeedbackRequestedAt { get; set; }
+13 -1
View File
@@ -12,6 +12,8 @@ Job Tracker is a simple, self-hosted app for tracking job applications with a Re
- History/event trail per application (created, status changes, follow-up set, delete/restore)
- Export jobs to JSON/CSV + daily scheduled JSON export
- Optional “job import” preview from supported job sites (plugins) + optional translation to English
- Quick-capture bookmarklet (Settings) + installable PWA with a mobile share-target: both open `/?add=<page url>` to pre-fill Add Job from any posting
- Note: no offline service-worker cache is bundled by design (the app is deployed frequently; an aggressive cache would risk serving stale builds). The manifest provides installability and share-to-capture without it.
- Optional local AI service for short/full descriptions
- Optional Google sign-in (Google ID tokens) to protect the API
@@ -133,6 +135,10 @@ Common keys:
- `Exports:DailyEnabled`: enable/disable daily export background job
- `Exports:DailyFolder`: export destination (relative to `Data:Root` if not absolute)
- `Exports:DailyHourLocal`: local hour (023) when the daily export runs
- `Backups:Enabled`: enable/disable the automated SQLite backup job (default `true`)
- `Backups:HourLocal`: local hour (023) when the daily database backup runs (default `3`)
- `Backups:RetainCount`: how many backup files to keep in `<Data:Root>/backups` (default `14`)
- Backups use SQLite `VACUUM INTO` (consistent snapshot, safe with WAL). A catch-up backup runs at startup when none exists from the last 24 h. For MySQL/MariaDB configure external backups instead (see `deploy/MARIADB.md`).
- `Auth:GoogleClientId`: if set, enables JWT bearer validation for Google ID tokens
- `Auth:JwtKey`: secret used to sign local JWTs for username/password login (set via env var `Auth__JwtKey`)
- `Auth:JwtIssuer`: JWT issuer (default `JobTrackerApi`)
@@ -187,7 +193,9 @@ Authentication:
- Updates an application; records a `StatusChanged` event if the status changed.
- `PATCH /api/jobapplications/{id}/status`
- Body: `{ "status": "..." }`
- Updates only status; records `StatusChanged` if it changed.
- Updates only status; records `StatusChanged` if it changed. The status is normalized against the canonical pipeline (casing + known synonyms like `Interviewing``Interview`); unrecognized values are preserved as custom statuses.
- `GET /api/jobapplications/pipeline`
- Returns the canonical ordered pipeline stages (`Applied, Waiting, Interview, Offer, Rejected, Ghosted`) with display order and category (`Active`/`Success`/`Closed`). The UI renders board columns and status dropdowns from this single source of truth.
- `PATCH /api/jobapplications/{id}/followup`
- Body: `{ "followUpAt": "2026-03-13T12:00:00Z" }` (or `null`)
- Sets/clears follow-up date; records a `FollowUpSet` event.
@@ -197,6 +205,10 @@ Authentication:
- Returns a unified timeline combining job events, correspondence, and attachments.
- `GET /api/jobapplications/stats`
- Returns totals, counts by status, applied-last-30-days, and average days since applied.
- `GET /api/jobapplications/{id}/match-score`
- Deterministic CV↔job keyword-coverage score (0100) with matched/missing keywords and per-CV-section coverage. No AI calls: results are instant and reproducible. Requires profile CV text/structure and a job description. (The AI narrative equivalent is `GET /api/jobapplications/{id}/candidate-fit`.)
- `GET /api/jobapplications/{id}/status-suggestion`
- Deterministic status suggestion derived from the job's most recent inbound message (interview invite / offer / rejection). Returns a forward-only suggestion (`hasSuggestion`, `suggestedStatus`, `signal`, …) or `hasSuggestion: false`. Applying it is a normal `PATCH .../status` — always user-confirmed.
- `DELETE /api/jobapplications/{id}`
- Soft-deletes an application (`IsDeleted=true`); records a `Deleted` event.
- `POST /api/jobapplications/{id}/restore`
+3 -1
View File
@@ -61,7 +61,9 @@ fi
# Force recreation so updated port mappings, env vars, and container config always apply on deploy.
compose up -d --force-recreate --remove-orphans backend frontend
if [ "$DEPLOY_BUILD_AI_SERVICE" = "true" ]; then
compose up -d --force-recreate ai-service ollama
# Ollama is opt-in (compose "bundled-ollama" profile). Deploys reuse an
# existing/shared Ollama via OLLAMA_BASE_URL instead of starting a duplicate.
compose up -d --force-recreate ai-service
fi
if [ -n "${OLLAMA_MODEL:-}" ]; then
+18 -2
View File
@@ -54,6 +54,9 @@ services:
frontend:
build:
context: ./job-tracker-ui
# fork-ts-checker (CRA's build type-checker) needs more than Docker's default
# 64MB /dev/shm; too little causes a SIGSEGV during `npm run build`.
shm_size: '1gb'
args:
- REACT_APP_GOOGLE_CLIENT_ID=${AUTH_GOOGLE_CLIENT_ID}
# Optional override; default in production is `/api`
@@ -72,12 +75,21 @@ services:
context: ./tools/summarizer
dockerfile: Dockerfile
environment:
# Point at an existing/shared Ollama by setting OLLAMA_BASE_URL in .env
# (e.g. http://<host-ip>:11435). The in-compose ollama service below is
# opt-in via the "bundled-ollama" profile, so it is NOT started by default
# and no duplicate Ollama container is created.
- 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:
- ollama
networks:
- default
- shared_services
@@ -88,7 +100,11 @@ services:
timeout: 10s
retries: 3
# Opt-in only: start with `docker compose --profile bundled-ollama up`.
# Left out of the default set so deploys reuse an existing/shared Ollama
# (configured via OLLAMA_BASE_URL) instead of spinning up a duplicate.
ollama:
profiles: ["bundled-ollama"]
image: ollama/ollama:latest
ports:
- "11434:11434"
+78
View File
@@ -0,0 +1,78 @@
# Merge Request — Wave 0 quick wins + Tier-1/Tier-2 features
**Branch:** `chore/wave0-quick-wins``main`
**Scope:** 24 commits · 62 files · +3,165 / 489
**Status:** all tests green (backend 135, frontend 23 suites / 54 tests), production build compiles.
> Prepared for human review. Do **not** auto-merge. One operator action is required after merge
> (DataProtection key rotation — see *Known limitations*).
---
## Summary
Delivers the first two roadmap tiers plus the engineering-health groundwork, developed as small
conventional commits. Two design principles run through it:
1. **Deterministic over "AI-guessy."** Match scoring, status suggestions, and pipeline logic are
pure/deterministic — instant, reproducible, and safe (the user confirms every state change). This
directly answers the market's most common complaint (hallucinated/generic AI output).
2. **One pathway, not two.** The bookmarklet and the PWA share-target feed a single `/?add=` capture
flow rather than parallel implementations.
## What's included
**Engineering health (Wave 0)**
- `security:` untracked committed DataProtection keys + daily exports; removed dead legacy controllers.
- `feat:` automated daily SQLite backups (`VACUUM INTO`, retention, startup catch-up) — prod previously
had **no** automated backup on Linux.
- `ci:` run the **entire** frontend suite (the old whitelist was hiding 3 broken suites, now fixed).
- `feat:` dev-only OpenAPI at `/openapi/v1.json`; `feat:` structured salary fields.
**Tier-1 features**
- **Match score** (`GET /jobapplications/{id}/match-score`) — deterministic CV↔job keyword coverage
(0100) + matched/missing keywords + section coverage. Instant panel on the Candidate Fit tab.
- **Canonical pipeline** — `JobPipeline` single source of truth; status normalized on write (custom
values preserved); UI deduped across 5 files; `GET .../pipeline`.
- **Analytics v2** — time-in-stage medians (from `StatusChanged` history) + funnel driven by the
pipeline (fixes a bug that omitted the Waiting stage).
- **Status suggestions** — deterministic email→status classifier surfaced as a human-confirmed banner.
**Tier-2 features**
- **Bookmarklet** quick-capture (Settings) reusing `jobimport/preview`.
- **Installable PWA** with a mobile share-target into the same capture flow.
**Quality**
- Phase-6 security review (`docs/SECURITY_REPORT.md`): tenant isolation on new endpoints verified +
regression-tested; no injection/ReDoS; dev-only OpenAPI.
- Bug fixes: `SkillTagger` C#/.NET regex (silently missed those skills everywhere), a React
stale-closure, a duplicated DB query, and 3 pre-existing hidden test failures.
## Test coverage added
New pure/unit-tested services: `JobCvMatchService` (7), `JobPipeline` (14), `StageAnalytics` (4),
`EmailStatusClassifier` (7). New endpoint integration + authorization tests (match-score,
status-suggestion). New frontend tests: match-score panel, status-suggestion banner, pipeline,
quick-capture, capture-url resolution.
## Docs
New: `docs/SYSTEM_OVERVIEW.md`, `docs/PRODUCT_RESEARCH.md`, `docs/ROADMAP.md`,
`docs/SECURITY_REPORT.md`. README updated with the new endpoints, backup/pipeline config, and
quick-capture/PWA notes.
## Known limitations / follow-ups
- **ACTION REQUIRED (security):** the removed DataProtection key XMLs remain in git **history**.
Rotate them on the production host after merge (see `SECURITY_REPORT.md` §6).
- **Per-user custom pipeline stages** were deliberately deferred (unproven demand; large surface).
- **No offline service worker** by design — the app deploys frequently and an aggressive cache would
risk serving stale builds. The PWA is installable and share-capable without it.
- Not yet done (future branches): interview hub (M3), contacts CRM (M4), god-controller decomposition,
performance pass, Vite migration.
## Reviewer notes
- Repo quirk: controllers/services compile via the `JobTrackerBackend` library, **not** the
`JobTrackerApi` host project (see `docs/SYSTEM_OVERVIEW.md` §2).
- All AI-adjacent features are deterministic and make no model calls.
+121
View File
@@ -0,0 +1,121 @@
# PRODUCT_RESEARCH.md — Job Application Tracking Market (2026)
> Phase 2 deliverable. Research conducted 2026-07-02 via web sources (linked throughout).
> Purpose: position Jobbjakt against the market and rank the features worth building next.
---
## 1. Market landscape
The market splits into five clusters:
| Cluster | Representatives | Model |
|---|---|---|
| **Tracker-first + AI resume** | [Teal](https://www.tealhq.com/), [Huntr](https://huntr.co/pricing), JibberJobber | Freemium SaaS; premium $2940/mo |
| **Autofill / volume** | [Simplify](https://simplify.jobs/job-application-tracker) (autofill), [LazyApply](https://lazyapply.com/) ($99999/yr), LoopCV (auto-apply) | Extension-centric |
| **Matching + copilot** | [Jobright](https://jobright.ai/blog/teal-review-2026-walkthrough-alternatives-and-faqs/) | AI job matching, resume tailoring, autofill |
| **Resume/ATS optimization** | [Jobscan](https://www.jobscan.co/) ($49.95/mo!), Resume Worded, Rezi | Match-score per job description |
| **Self-hosted / privacy** | [JobSync](https://github.com/Gsync/jobsync), [CareerSync](https://github.com/Tomiwajin/CareerSync), [career-ops](https://career-ops.org/), various [GitHub projects](https://github.com/topics/job-application-tracker) | OSS, local-first, often Ollama-based |
| **Email auto-tracking** | [Trackr](https://www.trackrjobs.com/), [G-Track](https://jobtrack-ai.com/gmail-job-tracker), Gmail [Chrome extensions](https://chromewebstore.google.com/detail/gmail-job-application-tra/lkpjngmdfncejiomkofogfdoppgifmkh) | Inbox scanning → status updates |
### Competitor snapshots
**Teal** — market leader for tracker+resume. Free: unlimited tracking, Chrome extension (50+ job boards), kanban (Saved/Applied/Interview/Offer/Rejected), 10 ATS templates, contact manager, ATS score (15 checks). Premium ($9/wk, $29/mo, [$79/qtr](https://www.tealhq.com/pricing)): keyword match scoring, AI bullets/cover letters, analytics. Cons reported: [billing-after-cancellation complaints, generic/hallucinating AI content, ATS failures on two-column templates](https://resumehog.com/blog/posts/teal-hq-review-april-2026-is-the-job-tracker-worth-your-time.html), [high-maintenance workflow, overwhelming UI, poor support](https://resumejudge.com/blog/tealhq-review/), no automation.
**Huntr** — best visual kanban + CRM layer. Free: 100 tracked jobs cap, unlimited base resumes, basic scoring. [Pro $40/mo](https://huntr.co/pricing): AI tailored resumes, unlimited cover letters, advanced matching/insights. 4.9★ extension (clip from any site + autofill). Cons: [must rebuild resume inside their builder, plain templates, free plan stops being useful fast](https://resumejudge.com/blog/huntr-review/), online-only.
**Simplify** — free autofill extension for 100+ ATS portals (Workday, Greenhouse, iCIMS), real-time keyword flagging, pipeline tracking. Execution-focused, light on CRM depth.
**Jobscan** — per-job resume match score (1100, 30+ checks, "aim ≥75%"), cover-letter optimization report. Expensive ($49.95/mo). This single feature is the most-cited reason people pay for job-search tools.
**Email auto-trackers** (Trackr, G-Track, extensions) — scan Gmail, AI-classify (Applied/Next step/Rejected/Offer), auto-update statuses, apply labels. This is rapidly becoming table stakes; users love "zero manual data entry".
**Self-hosted OSS** (JobSync, CareerSync, career-ops) — privacy pitch ("no cloud, no telemetry, no account"), Ollama/local-LLM parsing, but all are far less complete than Jobbjakt: mostly CRUD + basic AI, no CV pipeline, no correspondence CRM, no rules engine.
### Standard vs premium features across the market
- **Table stakes (free everywhere):** kanban board, status stages, notes, basic contact tracking, browser clipper, export.
- **Premium (what people pay for):** per-job resume↔JD **match scoring with keyword gaps**, AI tailored resumes/cover letters, analytics (response rate, funnel conversion, time-in-stage), email/interview follow-up automation, autofill at scale.
- **Emerging differentiators:** inbox auto-tracking, interview prep hubs (question banks, scheduling, calendar sync — cf. [interview scheduling tools](https://www.selectsoftwarereviews.com/buyer-guide/interview-scheduling-software)), job-match scoring against a profile, salary/offer comparison.
### Recurring user frustrations (opportunities)
1. **Privacy/data anxiety** — sensitive career data on VC-funded SaaS; [breach/misuse concerns](https://www.saashub.com/compare-job-tracker-by-teal-vs-huntr). Jobbjakt's core moat.
2. **Paywall fatigue** — free tiers cap exactly at the point of seriousness (Huntr's 100 jobs, Teal's AI credits, Jobscan's 5 scans/mo).
3. **AI slop** — hallucinated skills, misspelled names, generic bullets; users want AI grounded in *their* real CV (Jobbjakt's structured-CV grounding is the right architecture).
4. **Manual data entry** — retyping jobs and statuses; solved by clippers + inbox scanning.
5. **Vendor lock-in** — resumes trapped in proprietary builders (Huntr), hard exports.
6. **Tool sprawl** — tracker + Jobscan + resume builder + calendar = 4 subscriptions; users want one hub.
---
## 2. Feature matrix — Jobbjakt vs market
✅ has it · 🟡 partial · ❌ missing
| Feature | Teal | Huntr | Simplify | OSS self-hosted | **Jobbjakt today** |
|---|---|---|---|---|---|
| Kanban pipeline | ✅ | ✅ | ✅ | 🟡 | 🟡 board view exists; status is free-text, no drag-drop canonical pipeline |
| Job capture from URL | ✅ ext | ✅ ext | ✅ ext | 🟡 | 🟡 server-side parse (Finn/NAV/LinkedIn/Jobbnorge + JSON-LD); no extension/bookmarklet |
| Inbox auto-tracking | ❌ | ❌ | 🟡 | 🟡 | ✅ **Gmail OAuth import + human review queue** (ahead of paid SaaS) |
| Contacts/recruiter CRM | ✅ | ✅ | ❌ | ❌ | 🟡 company-level only, no people entities |
| Resume/CV builder | ✅ | ✅ | 🟡 | ❌ | ✅ structured CV parse + templates + PDF export |
| Per-job tailored resume (AI) | 💰 | 💰 | 💰 | ❌ | ✅ **local-AI tailored drafts** (privacy-unique) |
| Resume↔JD match score + keyword gaps | 💰 | 💰 | 🟡 | ❌ | ❌ (handoff doc lists "missing-keyword analysis" as planned) |
| AI cover letters / messages | 💰 | 💰 | 💰 | ❌ | ✅ free, local |
| Follow-up reminders | ✅ | ✅ | 🟡 | ❌ | ✅ + rules engine (auto-ghost) — richer than most |
| Analytics dashboard (funnel, response rate, time-in-stage) | 💰 | 💰 | 🟡 | 🟡 | 🟡 basic stats endpoint only |
| Interview management (schedule, prep notes, calendar) | 🟡 | 🟡 | ❌ | ❌ | ❌ (only generic follow-up dates) |
| Calendar integration (ICS/Google) | 🟡 | 🟡 | ❌ | ❌ | ❌ |
| Salary/offer tracking & comparison | 🟡 | 🟡 | ❌ | ❌ | 🟡 salary text field only |
| Autofill applications | ❌ | ✅ | ✅ | ❌ | ❌ (out of scope — needs extension) |
| Multi-language (EN/NB) + translation | ❌ | ❌ | ❌ | ❌ | ✅ unique for Nordic market |
| Self-hosted / data ownership | ❌ | ❌ | ❌ | ✅ | ✅ |
| Mobile experience | ✅ apps | ✅ | ✅ | ❌ | 🟡 responsive-ish desktop web; no PWA |
| Export/portability | 🟡 | 🟡 | 🟡 | ✅ | ✅ JSON/CSV + daily export |
**Position:** Jobbjakt is already **ahead of every OSS competitor** and matches or beats paid SaaS on AI drafting, Gmail import, and data ownership. Its gaps versus paid SaaS are: match scoring, canonical pipeline/kanban UX, interview & calendar layer, analytics depth, capture friction (no extension), and contact-level CRM.
---
## 3. Market gap — what would make Jobbjakt significantly better than existing solutions
> **"The private, self-hosted career hub: everything Teal+Huntr+Jobscan charge $7090/mo for, powered by your own local AI, with your data never leaving your server."**
No product today combines: serious tracker UX + inbox auto-tracking + local-LLM tailoring + match scoring + interview hub, self-hosted. Jobbjakt is uniquely ~60% of the way there.
---
## 4. Ranked feature ideas (value × effort)
Effort: S (<1 day) · M (13 days) · L (12 wk) · XL (>2 wk). Grounded in the Phase 1 codebase map.
| # | Feature | User impact | Effort | Notes |
|---|---|---|---|---|
| 1 | **CV↔job match score + keyword gap analysis** (per job: score, missing keywords, section coverage; reuse structured CV JSON + existing Ollama path) | ★★★★★ — the #1 paid feature in the market, free & local here | ML | Backend has all inputs already; add endpoint + UI panel in job workspace |
| 2 | **Canonical pipeline + drag-drop kanban** (status enum/ordering, custom stages per user, drive board/badges from it) | ★★★★★ — core daily UX; free-text status blocks analytics too | ML | Already on README wish list; needs migration for status normalization |
| 3 | **Analytics dashboard v2** (funnel conversion, response rate, time-in-stage, weekly activity, source effectiveness) | ★★★★ — retention feature; needs #2 for clean stages | M | Data all exists in `JobEvent` history |
| 4 | **Interview hub** (interview entity: rounds, type, scheduled time, prep notes, outcome; ICS feed/export + reminders) | ★★★★ — biggest functional gap vs SaaS | L | New entity + timeline integration; ICS is cheap, Google Calendar sync later |
| 5 | **Bookmarklet / minimal browser capture** (one-click "save to Jobbjakt" using existing `jobimport/preview`) | ★★★★ — kills the biggest friction (manual entry); full extension can wait | SM | Server parsing already exists; a bookmarklet or share-target PWA is days not weeks |
| 6 | **Contacts (people) CRM** (recruiter/hiring-manager entities linked to companies/jobs/correspondence) | ★★★ | M | Natural extension of company recruiter fields |
| 7 | **PWA pass** (installable, mobile nav polish, share-target for job URLs) | ★★★ — mobile is where users check status | M | CRA supports PWA manifest; pairs with #5 |
| 8 | **Salary/offer tracker** (structured salary min/max/currency, offer comparison view) | ★★ | SM | Currently a free-text field |
| 9 | **Smarter inbox** (extend existing Gmail review with AI status suggestions: "this looks like a rejection → move to Rejected?") | ★★★★ — compounds an existing unique strength | M | Classification via existing Ollama service |
| 10 | **Web push / digest notifications** (beyond SMTP) | ★★ | M | Needs service worker (pairs with #7) |
Deliberately **not** recommended: auto-apply bots (ToS/ethics/quality problems, LazyApply-style tools are poorly reviewed), building a full Chrome-store extension now (high maintenance; bookmarklet first), multi-provider cloud AI (undermines the privacy moat — keep local-first with optional cloud later).
## 5. Recommended implementation order (input to Phase 3 roadmap)
1. **Match score + keyword gaps** (#1) — flagship differentiator, builds on freshest code (structured CV).
2. **Canonical pipeline + kanban** (#2) — unblocks analytics, fixes daily UX.
3. **Analytics v2** (#3) — quick follow-on.
4. **Bookmarklet capture** (#5) + **PWA** (#7) — friction killers.
5. **Interview hub** (#4) — biggest new surface, schedule after the above land.
6. Then #9, #6, #8, #10 by appetite.
Engineering-health work (CI test whitelist, prod DB backups, god-controller decomposition) is tracked separately in `docs/SYSTEM_OVERVIEW.md` §1517 and should interleave with feature work in Phase 3.
---
Sources: [Prentus tracker roundup](https://prentus.com/blog/we-found-the-5-best-job-tracker-tools-on-the-market) · [ApplyArc comparison](https://applyarc.com/compare/best-job-application-trackers) · [Teal pricing](https://www.tealhq.com/pricing) · [Teal reviews (ResumeHog)](https://resumehog.com/blog/posts/teal-hq-review-april-2026-is-the-job-tracker-worth-your-time.html) · [Teal cons (ResumeJudge)](https://resumejudge.com/blog/tealhq-review/) · [Huntr pricing](https://huntr.co/pricing) · [Huntr cons (ResumeJudge)](https://resumejudge.com/blog/huntr-review/) · [Huntr vs Teal](https://huntr.co/blog/huntr-vs-teal) · [Simplify tracker](https://simplify.jobs/job-application-tracker) · [Jobright review of Teal](https://jobright.ai/blog/teal-review-2026-walkthrough-alternatives-and-faqs/) · [LazyApply](https://lazyapply.com/) · [Auto-apply tools compared](https://blog.fastapply.co/auto-apply-jobs-tools-compared-2026) · [Jobscan](https://www.jobscan.co/) · [Jobscan pricing](https://onlineatschecker.com/blog/jobscan-pricing-2026-free-plan-worth-it) · [JobSync (OSS)](https://github.com/Gsync/jobsync) · [CareerSync (OSS)](https://github.com/Tomiwajin/CareerSync) · [career-ops](https://career-ops.org/) · [Trackr](https://www.trackrjobs.com/) · [G-Track](https://jobtrack-ai.com/gmail-job-tracker) · [Gmail tracker extension](https://chromewebstore.google.com/detail/gmail-job-application-tra/lkpjngmdfncejiomkofogfdoppgifmkh) · [Interview scheduling software guide](https://www.selectsoftwarereviews.com/buyer-guide/interview-scheduling-software) · [SaaSHub Teal vs Huntr](https://www.saashub.com/compare-job-tracker-by-teal-vs-huntr)
+75
View File
@@ -0,0 +1,75 @@
# ROADMAP.md — Jobbjakt Product & Engineering Roadmap
> Phase 3 deliverable (2026-07-02). Sources: `docs/SYSTEM_OVERVIEW.md` (Phase 1) and `docs/PRODUCT_RESEARCH.md` (Phase 2).
> Scoring: Value/Complexity/Risk on ▲ high / ● medium / ▽ low. Effort: S <1 day · M 13 days · L 12 wk · XL >2 wk.
**North star:** the private, self-hosted career hub — the tracker UX of Huntr, the tailoring/scoring of Teal+Jobscan, powered by local AI, with data that never leaves your server.
---
## Tier 0 — Quick Wins (do first; days, low risk, compounding payoff)
| # | Item | Type | Value | Effort | Risk | Rationale |
|---|---|---|---|---|---|---|
| Q1 | **CI: run the full frontend test suite** (replace the hand-maintained 10-file whitelist with the whole suite; fix/quarantine any flaky test explicitly) | eng | ▲ | S | ▽ | New tests currently silently skipped in CI; already caused a gap once |
| Q2 | **Automated production DB backup** (scheduled SQLite `VACUUM INTO`/copy to `exports/` with retention; document restore) | eng | ▲ | SM | ▽ | Prod currently has *no working automated backup* (backup endpoint is Windows-DPAPI-only, prod is Linux) |
| Q3 | **Repo hygiene** (delete dead root `Controller/`; remove `temp_job.json`, `temp_post_job.py`; gitignore `JobTrackerApi/CvArtifacts/`, `bin_build/`, stray artifacts; commit pending WIP fixes on a branch) | eng | ● | S | ▽ | Removes footguns before refactors; working tree currently dirty |
| Q4 | **Swagger/OpenAPI** (Swashbuckle or built-in OpenAPI, dev-only exposure) | eng | ● | S | ▽ | README endpoint list already drifts; prerequisite for a generated TS client later |
| Q5 | **Structured salary fields** (min/max/currency/period alongside the free-text field, backfill-friendly) | product | ● | SM | ▽ | Cheap now, prerequisite for offer comparison + analytics later |
## Tier 1 — High Value (the differentiators; next 24 weeks of feature work)
| # | Item | Value | Effort | Risk | Notes |
|---|---|---|---|---|---|
| H1 | **CV↔Job match score + keyword gap analysis** — per-job score, missing keywords, section coverage; reuse `ProfileCvStructureJson` + existing Ollama path; panel in job workspace | ▲▲ | ML | ● | The market's #1 paid feature (Jobscan $50/mo), free & local here. Flagship differentiator |
| H2 | **Canonical pipeline + drag-drop kanban** — status enum + ordering + per-user custom stages; migration normalizing existing free-text statuses; board becomes drag-drop | ▲▲ | ML | ● | Fixes daily UX; unblocks H3; the riskiest part is the status migration (needs careful mapping + tests) |
| H3 | **Analytics dashboard v2** — funnel conversion, response rate, time-in-stage, weekly activity, source effectiveness (data already in `JobEvent`) | ▲ | M | ▽ | Depends on H2 for clean stages |
| H4 | **Gmail AI status suggestions** — extend the existing review queue: classify incoming mail (rejection/interview/offer) via local AI and suggest status moves, human-confirmed | ▲ | M | ● | Compounds an existing unique strength; keep human-in-the-loop |
## Tier 2 — Medium Value (after Tier 1)
| # | Item | Value | Effort | Risk |
|---|---|---|---|---|
| M1 | **Bookmarklet / PWA share-target capture** — one-click save-to-Jobbjakt reusing `jobimport/preview` | ▲ | SM | ▽ |
| M2 | **PWA pass** — manifest, installability, mobile nav polish | ● | M | ▽ |
| M3 | **Interview hub** — interview entity (round, type, time, prep notes, outcome), timeline integration, ICS export + reminders | ▲ | L | ● |
| M4 | **Contacts (people) CRM** — recruiter/hiring-manager entities linked to companies/jobs/correspondence | ● | M | ▽ |
| M5 | **Durable CV processing queue** — DB-backed queue replacing in-memory (jobs survive restart) | ● | M | ● |
| M6 | **ProblemDetails + validation consistency** across API | ● | M | ▽ |
## Tier 3 — Long-Term Improvements (structural; interleave carefully)
| # | Item | Value | Effort | Risk |
|---|---|---|---|---|
| L1 | **Decompose god controllers** (`JobApplicationsController` 151 KB, `ProfileCvController` 117 KB, `GmailController` 60 KB) into feature services; extract AI prompt construction behind interfaces. Strictly behavior-preserving, test-first, one slice per PR | ▲ (maintainability) | XL | ▲ |
| L2 | **Finish the project-layout migration** — physically move linked `Models/`/`Data/`/controller/service files into real projects, retire glob-include `JobTrackerBackend` | ● | L | ● |
| L3 | **Vite migration** (CRA/react-scripts is EOL; 4 GB-heap builds) | ● | L | ● |
| L4 | **OpenAPI-generated TypeScript client** replacing hand-written `api.ts` surface | ● | ML | ● |
| L5 | **Staging environment / deploy gate** (compose profile or second host; smoke test before prod) | ▲ (ops) | L | ● |
## Tier 4 — Future Ideas (not scheduled)
- Full browser extension (Chrome/Firefox store) with autofill.
- Web push notifications + weekly digest.
- Company research assistant (local AI summarizing company info).
- Offer comparison & salary analytics dashboards.
- Job feed matching from saved searches (Finn/NAV polling).
- Native mobile wrappers; CalDAV/Google Calendar two-way sync.
- Multi-instance/scale-out readiness (distributed cache/queue).
---
## Recommended execution sequence (Phase 4+)
Interleaving product and engineering so debt never blocks features:
1. **Wave 0 (hygiene):** Q3 → Q1 → Q2 → Q4 → Q5 (each a small conventional commit on a feature branch; Q1/Q2 are the two items with real operational risk today)
2. **Wave 1 (flagship):** H1 match scoring (design doc → backend endpoint → UI panel → tests)
3. **Wave 2 (core UX):** H2 canonical pipeline/kanban, then H3 analytics
4. **Wave 3:** H4 Gmail suggestions, M1 bookmarklet, M2 PWA
5. **Wave 4:** M3 interview hub, M4 contacts, M5 durable queue
6. **Continuous:** L1 controller decomposition proceeds opportunistically — whenever a wave touches a god-controller area, extract that slice first (M6 rides along); L2L5 scheduled after Wave 3 checkpoint.
Phases 510 of the mission (bug hunt, security audit, performance, refactoring, testing, docs) run after or between waves as checkpoints; Phase 11 rules apply throughout (feature branches, conventional commits, full test suite before commit, no auto-merge to main).
**Explicitly deprioritized:** auto-apply automation (quality/ToS problems), cloud AI providers (undermines privacy moat), Chrome-store extension before the bookmarklet proves demand.
+122
View File
@@ -0,0 +1,122 @@
# SECURITY_REPORT.md — Session Change Review
> Phase 6 deliverable. Scope: security review of the changes made in this work session
> (Wave 0 + roadmap H1H4), plus confirmation that the tenant-isolation model still holds.
> Date: 2026-07-03. Complements the prior standalone assessments in
> `docs/security-assessments/` (M013 adversarial, M014 remediation, M015 authorization replay).
This is **not** a full re-audit of the whole application — those live in `docs/security-assessments/`.
It is a focused review of the new/changed surface so nothing shipped this session introduces a regression.
---
## 1. Summary
No new vulnerabilities were introduced. Tenant isolation on the new endpoints is carried by the
existing `JobTrackerContext` global query filters and is now covered by regression tests. One latent
correctness issue (a routable background-service method) was closed, and leaked runtime secrets were
removed from version control (rotation recommended — see §6).
| Severity | Count | Items |
|---|---|---|
| Critical | 0 | — |
| High | 0 | — |
| Medium | 1 (mitigated) | DataProtection keys present in git history (untracked this session; rotation recommended) |
| Low / hardening | 3 | see §5 |
---
## 2. New/changed attack surface reviewed
| Change | Surface | Verdict |
|---|---|---|
| `GET /jobapplications/{id}/match-score` | route int id; reads own CV + job | Safe — tenant-scoped |
| `GET /jobapplications/{id}/status-suggestion` | route int id; reads own correspondence | Safe — tenant-scoped |
| `GET /jobapplications/pipeline` | none (static metadata) | Safe |
| `PATCH .../status`, Create/Update (status normalization) | user string → `JobPipeline.Normalize` | Safe — no injection, values stored parameterized |
| Structured salary fields | numeric + short strings, `NormalizeSalary` | Safe — clamps negatives, whitelists period |
| Automated DB backup (`VACUUM INTO`) | server-controlled path | Safe — see §4 |
| Dev OpenAPI (`/openapi/v1.json`) | schema | Safe — `Development` environment only |
| `EmailStatusClassifier` | reads stored correspondence text | Safe — deterministic, no eval/injection |
---
## 3. OWASP-oriented checklist for the new code
- **A01 Broken Access Control** — The two new data endpoints load the job via
`_db.JobApplications.FirstOrDefaultAsync(j => j.Id == id)`, which is filtered by the global
query filter `CurrentUserId != null && OwnerUserId == CurrentUserId` (deny-on-null, hardened in
M013-2). A cross-user id returns `NotFound`, not another tenant's data. The correspondence lookup
in `status-suggestion` and the `JobEvent` lookup in analytics are likewise filtered through their
parent's owner. **Verified by `JobApplicationsAuthorizationTests` (match-score + status-suggestion).**
- **A03 Injection** — All new persistence goes through EF Core parameterized queries. The only raw
SQL added is `VACUUM INTO '<path>'` with a fully server-derived path (see §4). No string
concatenation of user input into queries.
- **A03 ReDoS** — New regexes (`JobCvMatchService.TokenPattern`, the revised `SkillTagger` C#/.NET
patterns with fixed-width look-behinds) are linear with no catastrophic backtracking.
- **A04 Insecure Design** — Status suggestions and match scoring are deterministic and
**human-confirmed** (a status only changes when the user clicks). No automated outbound actions.
- **A05 Security Misconfiguration** — OpenAPI is exposed only under `IsDevelopment()`; production
deployments (`ASPNETCORE_ENVIRONMENT=Production`) do not serve it.
- **A08 Data Integrity** — `JobPipeline.Normalize` canonicalizes status on write but preserves
unknown custom values (no silent data loss).
- **A09 Logging** — No secrets or PII added to logs by the new code.
---
## 4. Database backup — path handling
`SqliteDatabaseBackupRunner` runs `VACUUM INTO '<target>'`. The target is
`<Data:Root>/backups/jobtracker_backup_<UTC-timestamp>.db` — no user input reaches it — and single
quotes are escaped defensively. Backups contain the full database (sensitive) and are written to the
same data volume as the live DB, i.e. the same trust boundary; they are git-ignored. For defense in
depth, operators should ship backups off-host with transport encryption and restrict volume
permissions. **Recommendation (low):** document an off-host, encrypted backup rotation in the
deployment guide.
---
## 5. Low / hardening findings
1. **match-score input size (low).** `GetMatchScore` does not cap job-description length before
tokenizing. Descriptions are bounded in practice (imported/typed), and the algorithm is linear, so
this is not a DoS, but a defensive cap (e.g. 50 KB) would be prudent.
2. **New read endpoints are not rate-limited (low).** `match-score`/`status-suggestion` are cheap and
deterministic (no AI, one indexed query), and auth-gated in production, so abuse potential is low.
Consider a general authenticated-read limiter if the API is exposed publicly.
3. **status-suggestion is conservative for custom statuses (informational).** A job in a non-canonical
custom status (pipeline order = max) never receives a suggestion. This is safe (fails closed) but
slightly under-surfaces; acceptable given custom statuses are rare.
---
## 6. Secrets hygiene (actioned this session)
- Committed ASP.NET **DataProtection key XML files** (`keys/`, `JobTrackerApi/keys/`) and daily export
JSON were removed from tracking and added to `.gitignore`
(commit `security: untrack DataProtection keys and runtime exports…`).
- **These key files remain in git history.** DataProtection keys sign auth/session artifacts, so
**rotating them on the production host is recommended** (generate fresh keys; the app regenerates the
key ring in the persisted `keys/` directory on next start). Until rotated, anyone with history access
could read the old key material.
- Local `.env` remains git-ignored; `appsettings.Development.json` contains only `CHANGE_ME_*`
placeholders. No live secrets are tracked.
---
## 7. Confirmed intact from prior assessments
Spot-checked that the M013M015 remediations are still in force after this session's changes:
- Owner query filters still deny on null `CurrentUserId` (`Data/JobTrackerContext.cs`).
- Local JWT still requires a concrete subject claim (`LocalAuthIdentity`, `Program.cs`).
- Job-import SSRF guard (DNS resolution + private-range rejection, redirects disabled) untouched.
- CSRF double-submit middleware and CORS allowlist untouched.
---
## 8. Retest
All backend tests pass (135), including the two new tenant-isolation tests for the new endpoints.
No fix in this report required code changes beyond what already landed; the residual **action for the
operator is DataProtection key rotation** (§6).
+293
View File
@@ -0,0 +1,293 @@
# Jobbjakt (Job Tracker) — System Overview
> Phase 1 deliverable: full-system map produced before any code changes.
> Last updated: 2026-07-02. Verified against commit `eea327e1` plus local working-tree changes.
---
## 1. What the product is
Jobbjakt is a self-hosted, multi-user job application tracking platform with heavy AI assistance:
- Track job applications end-to-end (status pipeline, follow-ups, deadlines, salary, tags, notes).
- Company/recruiter CRM (pipeline stage, contact dates, recruiter details).
- Correspondence log per application, including **Gmail OAuth import with review workflow**.
- Attachments per application with purpose metadata and AI-inclusion toggles.
- **CV platform**: upload → OCR/text extraction → structured CV parsing (Ollama-assisted block classification) → per-job tailored CV drafts → templated PDF export via Playwright.
- AI drafts: cover letters, recruiter messages, follow-up drafts, job description summaries, translation (LibreTranslate optional).
- Rules engine (auto-ghosting, follow-up "needs attention"), reminder emails, daily JSON export, history/event trail, encrypted backup (Windows/DPAPI).
- Admin surface: user management, audit log, system readiness page.
- Deployed to production at `https://jobs.cesnimda.uk` via Gitea Actions → SSH → Docker Compose.
---
## 2. Architecture overview
```mermaid
flowchart LR
subgraph Client
UI[React 19 SPA<br/>MUI 7, react-router 6<br/>CRA/react-scripts]
end
subgraph Frontend container
NGINX[nginx 1.29-alpine<br/>serves build + proxies /api]
end
subgraph Backend container
API[ASP.NET Core net9.0 API<br/>JobTrackerApi host]
BG[Hosted services:<br/>Rules, FollowUpReminder,<br/>DailyExport, JobEnrichment,<br/>SummarizerProbe, CvProcessing]
DB[(SQLite default<br/>or MariaDB/MySQL)]
FS[/Data root:<br/>Attachments, CvArtifacts,<br/>exports, DP keys/]
end
subgraph AI stack
AISVC[FastAPI ai-service :8001<br/>distilbart summarizer,<br/>OCR pytesseract/PyMuPDF,<br/>docx/pdf extraction]
OLLAMA[Ollama :11434<br/>qwen2.5:7b<br/>CV classification + rewrite]
end
EXT1[Google OAuth / Gmail API]
EXT2[Job sites: Finn, NAV,<br/>LinkedIn, Jobbnorge]
EXT3[SMTP - Gmail app password]
EXT4[LibreTranslate optional]
UI --> NGINX --> API
API --> DB
API --> FS
API --> AISVC --> OLLAMA
API --> EXT1
API --> EXT2
API --> EXT3
API --> EXT4
BG --> DB
```
### Solution layout (unusual — read this first)
| Project | Role |
|---|---|
| `JobTrackerApi/` | Web **host** only: `Program.cs`, appsettings, migrations, Dockerfile. Its csproj **excludes** `Controllers/**` and `Services/**` from its own compilation. |
| `JobTrackerBackend/` | "Transitional shared-backend" **library** that compiles, via `<Compile Include>` links, the files physically located in `../Data`, `../Models`, `../JobTrackerApi/Controllers`, `../JobTrackerApi/Services`. Exists so tests can reference controllers/services without the web-entry project. |
| `JobTrackerApi.Tests/` | xUnit test project (~20 test classes incl. authorization/hostile-fixture tests). |
| `Models/`, `Data/` (repo root) | The *real* EF models and `JobTrackerContext`, compiled into JobTrackerBackend. |
| `Controller/` (repo root) | **Legacy stub controllers (~1 KB each) — dead code**, not referenced by any csproj. |
| `job-tracker-ui/` | React SPA. |
| `tools/summarizer/` | FastAPI AI service (own Dockerfile, pytest tests). |
| `deploy/`, `.gitea/workflows/` | Prod deploy script + CI/CD pipeline. |
| `docs/` | Session handoffs, security assessments (M013M015), UAT notes. |
---
## 3. Technology stack
**Backend**: ASP.NET Core net9.0, EF Core 9 (SQLite default; Pomelo MySQL/MariaDB switchable via `Database:Provider`), ASP.NET Identity Core (users/roles), JWT bearer auth (local + Google policy scheme), built-in RateLimiter, DataProtection (file-system keys), Playwright (CV PDF export).
**Frontend**: React 19, TypeScript 4.9, MUI 7 (+ x-data-grid, x-date-pickers, lab), axios, react-router-dom 6, @tanstack/react-table, CRA `react-scripts` 5 (build needs `--max-old-space-size=4096`), i18n EN + NB (custom provider), Jest/RTL tests.
**AI**: FastAPI + transformers (`sshleifer/distilbart-cnn-12-6`) for summaries; pytesseract/PyMuPDF/pypdf/python-docx for extraction/OCR; Ollama (`qwen2.5:7b`) for CV block classification and rewrite paths; TTL cache.
**Infra**: Docker Compose (4 services: backend, frontend/nginx, ai-service, ollama w/ GPU), Gitea Actions CI (build + backend tests + selected frontend tests + frontend build) → SSH deploy → `deploy/deploy.sh` on the prod host, external `jobtracker_shared` network.
---
## 4. Authentication & authorization
- **Smart policy scheme**: inspects the bearer token issuer — Google-issued ID tokens (`accounts.google.com`) route to the `google` JWT handler (validated against `Auth:GoogleClientId`); everything else routes to `local` JWT (symmetric key `Auth:JwtKey`, issuer/audience validated, 2-min clock skew).
- **Cookie session support**: local handler also reads the session cookie (`AuthSessionOptions.SessionCookieName`); **CSRF double-submit** middleware enforces cookie+header match for all mutating requests when a session cookie is present (login/register/reset/csrf endpoints exempt).
- `Auth:Require=true` sets a fallback authorize-all policy (prod compose sets it). Dev without a JWT key generates an ephemeral key + warning; **fails closed** if auth required but no key.
- Local tokens **must** carry a subject claim (`LocalAuthIdentity`), enforced in `OnTokenValidated` — hardened after finding M013-2.
- **Multi-tenancy**: every tenant entity carries `OwnerUserId`; `JobTrackerContext` applies global query filters `CurrentUserId != null && OwnerUserId == CurrentUserId` (deny-on-null). Correspondence/JobEvents/CV entities filter through their parent's owner.
- Roles via ASP.NET Identity: admin-only controllers (`UsersController`, `AdminAuditController`, `AdminSystemController`).
- Password policy: min 8, digit + lowercase required. Password reset via emailed token (SMTP required). Registration disabled by default.
- Rate limiting: `auth-login` (10/5 min/IP) and `auth-email` (5/15 min/IP) fixed-window policies.
---
## 5. Database schema (EF Core, 8 migrations)
```mermaid
erDiagram
ApplicationUser ||--o{ Company : owns
ApplicationUser ||--o{ JobApplication : owns
ApplicationUser ||--o| UserRuleSettings : has
ApplicationUser ||--o{ GmailConnection : has
ApplicationUser ||--o{ CvUploadArtifact : owns
ApplicationUser ||--o{ CvExtractionRun : owns
Company ||--o{ JobApplication : "has jobs"
JobApplication ||--o{ Correspondence : messages
JobApplication ||--o{ Attachment : attachments
JobApplication ||--o{ JobEvent : events
JobApplication ||--o| TailoredCvDraft : "1:1 draft"
CvUploadArtifact ||--o{ CvExtractionRun : "source of"
ApplicationUser ||--o{ GmailReviewDecision : decides
```
Key notes:
- `ApplicationUser` (IdentityUser) also stores profile CV text, **structured CV JSON** (`ProfileCvStructureJson`), avatar data-URL, Google link info, current CV artifact/run pointers.
- `JobApplication`: status string (default "Applied"), soft delete (`IsDeleted`/`DeletedAt`), tags as JSON string, imported description + translation, persisted `ShortSummary`, tailored CV text, reminder bookkeeping. Cascade deletes to messages/attachments/events/draft.
- `RuleSettings` (global, seeded Id=1) + per-user `UserRuleSettings`.
- `SystemEmailSettings`: DB-stored SMTP override (resolved by `EmailSettingsResolver`).
- Indexes: `OwnerUserId` on Company/JobApplication/GmailConnection; composite `(OwnerUserId, UploadedAtUtc)`, `(OwnerUserId, StartedAtUtc)`, unique `(OwnerUserId, JobApplicationId)` on draft, unique `(OwnerUserId, GmailAddress)`.
- SQLite file lives at `DataRoot/jobtracker.db` (WAL mode); migrations applied automatically at startup (`StartupInitializationExtensions`, 62 KB — also seeds admin, creates Identity tables where `dotnet ef` unavailable, ignores `PendingModelChangesWarning`).
---
## 6. API surface (all under `/api`, ~15 controllers)
| Controller | Highlights |
|---|---|
| `JobApplicationsController` (**151 KB!**) | CRUD, paging/filtering/sorting, board, reminders, stats, history, unified timeline, status/follow-up PATCH, soft delete/restore, **plus** AI surface: application package material, follow-up drafts, cover-letter/recruiter drafts ("Maria" drafts), workflow signals. |
| `ProfileCvController` (**117 KB**) | CV upload artifacts, extraction runs, structure parsing, rebuild/improve, tailored CV generation via Ollama rewrite, template rendering + Playwright PDF preview/export, benchmark corpus harness. |
| `GmailController` (**60 KB**) | OAuth connect/callback, sync, message review queue, import decisions, job matching. |
| `AuthController` (22 KB) | login/register/me/config, Google exchange, password reset request/reset, session cookie + CSRF endpoints. |
| `CompaniesController` | CRUD, idempotent create by name, recruiter/pipeline fields. |
| `CorrespondenceController` | per-job messages CRUD. |
| `AttachmentsController` | multipart upload to disk, download, rename, delete, purpose/AI-inclusion metadata. |
| `RulesController` | global + per-user rule settings, clamped. |
| `ExportController` | JSON/CSV export. |
| `BackupController` | DPAPI-encrypted backup (Windows only). |
| `JobImportController` | URL preview via plugin parsers (SSRF-hardened). |
| `UsersController`, `AdminAuditController`, `AdminSystemController` | admin: user/role management, audit trail, system readiness (DB/Gmail/AI). |
| `ClientErrorsController` | frontend error intake → logs. |
No OpenAPI/Swagger is wired up; the README is the de-facto API doc (already drifting).
---
## 7. Background services (6 hosted services)
| Service | Function |
|---|---|
| `RulesHostedService``RulesEngine` | periodic auto-transitions (e.g., → Ghosted) from rule settings |
| `FollowUpReminderHostedService` | reminder emails for due/upcoming follow-ups (dedup via `LastReminderEmailSentAt`) |
| `DailyExportHostedService` | daily JSON export at configured local hour |
| `JobEnrichmentHostedService` | backfills summaries/enrichment for jobs |
| `SummarizerProbeHostedService` | probes AI service readiness |
| `CvProcessingHostedService` + `CvProcessingQueue` | in-memory queue for CV extraction/processing jobs |
All state is in-process (`IMemoryCache`, in-memory queue) — single-instance assumption; no distributed locks; queue contents lost on restart.
---
## 8. AI pipeline (data flow)
1. **Job import**: URL → plugin parse (Finn/NAV/LinkedIn/Jobbnorge or universal JSON-LD parser) → optional LibreTranslate → language detect + skill tagging → preview → user accepts → stored on `JobApplication`.
2. **Summaries**: API → `SummarizerService` (31 KB) → FastAPI `/summarize` (distilbart, TTL-cached, GPU-if-available) → persisted `ShortSummary`.
3. **CV ingest**: upload (PDF/DOCX/image ≤ 8 MB) → FastAPI extract/OCR → block classification (Ollama-assisted, `CvAiClassifier`/`CvAiNormalizer`) → `ProfileCvStructureJson` on user.
4. **Tailoring**: job description + structured CV sections → Ollama rewrite path (recent commits: clamped lengths, hardened diagnostics) → `TailoredCvDraft` (JSON blocks) → `CvTemplateRenderer` (25 KB, template carousel) → Playwright → PDF.
5. **Drafts**: cover letter / recruiter message / follow-up drafts generated per job with attachment-aware context selection.
Degradation: if AI service or Ollama is down, core tracking still works (probe service + "AI is not a deploy gate" in CI).
---
## 9. Email
- `SmtpEmailSender` with `EmailSettingsResolver`: config from env/appsettings **or** DB-stored `SystemEmailSettings` (admin-editable).
- Uses Gmail SMTP + app password in prod. Flows: password reset, follow-up reminders. `App:PublicBaseUrl` builds links.
---
## 10. Configuration & secrets
- `.env` (git-ignored) → docker-compose env → ASP.NET config. `.env.example` documents the shape. Real secrets currently present in local `.env` (JWT key, admin password, SMTP app password, Google client secret).
- `appsettings.Development.json` contains only `CHANGE_ME_*` placeholders (good).
- Key knobs: `Database:Provider`, `ConnectionStrings:JobTracker`, `Data:Root`, `Cors:Origins`, `Ai:BaseUrl`, `Auth:*`, `Email:*`, `Exports:*`, `Translation:*`, `App:PublicBaseUrl`, `HttpsRedirection:*` (TLS terminated at reverse proxy; HSTS/redirect off in-container).
- `ProductionConfigTests.cs` exists to guard prod config shape.
---
## 11. Build, CI/CD, deployment
- **CI** (`.gitea/workflows/ci-deploy.yml`): on PR + push-to-main → build backend (Release), run backend tests, `npm ci`, run an **explicit whitelist of 10 frontend test files** (not the whole suite), build frontend.
- **Deploy** (push to main only): SSH to prod host → `git reset --hard <sha>` in `/opt/job-tracker/app``deploy/deploy.sh` (docker compose build/up with retry/cache-prune fallbacks) → verify containers; AI service health is non-blocking.
- Frontend Dockerfile: node build stage → nginx 1.29-alpine (working-tree bump from 1.27 pending commit); nginx proxies `/api` to backend.
- No staging environment; deploys go straight to prod after CI.
---
## 12. Testing strategy
- **Backend**: xUnit integration-style tests via `TestHostFactory`; notable coverage: authorization (`JobApplicationsAuthorizationTests`, `OwnershipGuardTests`, hostile fixture DB project), auth/system, Gmail, CV corpus harness, summarizer, SQLite migration helper, production config.
- **Frontend**: ~20 Jest/RTL test files (workspace flows, Gmail review, login, admin, attachments, drafts, trust-loop e2e-ish component tests). CI runs only the whitelisted subset.
- **AI service**: pytest (`tools/summarizer/tests/test_app.py`).
- No true end-to-end browser tests; no load/perf tests.
---
## 13. Logging & error handling
- Console/debug logging; custom middleware logs every request (method, path, status, ms, traceId, sub claim). Unhandled exceptions logged then rethrown (500).
- Client errors POSTed to `/api/client-errors` and logged server-side; React `ErrorBoundary` + route error page in UI.
- No structured sink (Seq/OTLP), no log rotation policy in-app (container stdout), no correlation to frontend errorIds beyond log text, no ProblemDetails standardization.
---
## 14. Security posture (current)
Strong points (much already hardened via M013M015 adversarial assessments in `docs/security-assessments/`):
- SSRF on job import **fixed & retested** (DNS resolution check, private/loopback/link-local rejection, redirects disabled).
- Subjectless-JWT / owner-filter bypass **fixed & retested** (fail-closed identity, deny-on-null query filters).
- Cross-user job history leak fixed (`81196374`); authorization replay findings recorded (M015).
- CSRF double-submit for cookie sessions; CORS allowlist; rate-limited login/email endpoints; ephemeral JWT key refused when auth required; Identity password hashing (PBKDF2); DataProtection keys persisted outside repo runtime path.
Open questions / watch areas (to verify in Phase 6):
- `AllowCredentials()` combined with configurable `Cors:Origins="*"` wildcard mode (SetIsOriginAllowed(true) + credentials) — dangerous if ever enabled.
- Attachment upload: file-type/size limits, path handling, content-type on download need re-audit.
- Avatar stored as data-URL on user record (size/XSS considerations).
- Gmail OAuth token storage encryption at rest; scopes; audit of `GmailController` (60 KB).
- Global rate limiting only on 2 auth policies — AI/expensive endpoints unthrottled.
- Backup endpoint Windows-only DPAPI — silently unavailable on Linux prod.
- Dependency freshness (axios, react-scripts 5/CRA is deprecated upstream; transformers/torch pinning).
- Secrets present in local `.env` (expected, git-ignored) — confirm no history leaks.
---
## 15. Technical debt report
1. **God controllers**: `JobApplicationsController` (151 KB), `ProfileCvController` (117 KB), `GmailController` (60 KB), `StartupInitializationExtensions` (62 KB). Massive single files mixing HTTP, business logic, AI prompt construction, and persistence. Highest-leverage refactor target — but high risk, needs test cover first.
2. **Transitional project layout**: `JobTrackerBackend` compiles files it doesn't own via glob includes; root `Models/`/`Data/` folders; **dead** root `Controller/` folder; `JobTrackerBackend/bin`+`obj` artifacts and `JobTrackerApi/jobtracker.db` + `bin_build/`, `CvArtifacts/`, `exports/`, `keys/` polluting the repo/working tree. `.gitignore` needs review.
3. **CI runs a hand-maintained subset** of frontend tests — new test files silently not run (already bit them once; `profile-page.test.tsx` had to be added manually).
4. **CRA/react-scripts 5** is EOL-ish, slow builds (needs 4 GB heap), TS 4.9. Vite migration is the obvious path (medium effort).
5. **Naming drift**: `Summarizer*` vs `AiService*`; "Jobbjakt" vs "Job Tracker" branding split; EN/NB translation consistency flagged in handoff doc.
6. No OpenAPI; README endpoint list already drifts from code (e.g., Gmail/profile/admin endpoints missing there).
7. In-memory queue/cache single-instance coupling undocumented.
8. Root-level clutter: `temp_job.json`, `temp_post_job.py`, `todo jobtracker.txt`, `test/`, `tmp/`, `vendor/`, `.venv/`.
9. `DaysSince` compares `DateTime.UtcNow` with `.Days` truncation — timezone/UX edge cases; status is a free string, no canonical pipeline enum (README itself lists this as a wanted improvement).
10. Windows-only backup path.
---
## 16. Areas of concern
- **Single point of data**: SQLite in a Docker volume; backups are manual/Windows-only; no automated off-host backup.
- **Deploy risk**: `git reset --hard` + straight-to-prod with no staging and non-exhaustive CI test coverage.
- **AI coupling**: prompt logic buried in controllers makes model/provider changes and testing hard.
- **Restart data loss**: queued CV processing jobs are lost on restart (in-memory queue).
- **Uncommitted working tree**: 3 modified files (Dockerfile nginx bump, `useViewResource` stale-closure fix, handoff doc) + untracked `scripts/start-ollama-cv.ps1` and a stray `JobTrackerApi/CvArtifacts/` data folder.
---
## 17. Opportunities for improvement (input to Phase 2/3)
Product (initial hypotheses, to be validated by market research):
- Canonical pipeline model + customizable Kanban stages (already on README wish list).
- Interview scheduling/prep hub (calendar integration, prep notes, question banks).
- Salary/offer comparison and analytics dashboards (funnel conversion, response rates, time-in-stage).
- Browser extension / bookmarklet for one-click job capture (plugins already exist server-side).
- Saved searches/views, full-text search, date-range and tag filters.
- Notifications beyond email (web push, digest).
- Contact-level recruiter CRM (people, not just companies).
- Mobile-friendly PWA pass.
Engineering:
- Swagger/OpenAPI + generated TS client; ProblemDetails everywhere.
- Split god controllers into feature services; move AI prompting behind interfaces.
- Run full frontend test suite in CI (`npm test -- --watchAll=false` without whitelist) once flaky tests are addressed; add `dotnet format`/eslint gates.
- Vite migration; dependency refresh.
- Durable job queue (DB-backed) for CV processing; automated DB backup job.
- Repo hygiene: delete dead `Controller/`, ignore build artifacts, remove committed DB files.
+8
View File
@@ -79,6 +79,14 @@ Mitigation has been added in deploy script, but if it happens again check:
3. Final UX polish pass on profile/job details/attachments
4. Dashboard + system polish
## Useful skills to apply next time
- `accessibility`
- use for the final UI polish/a11y pass across dialogs, forms, focus states, contrast, keyboard support, and screen-reader naming
- `agent-browser`
- use for live verification of local or deployed Jobbjakt flows, screenshots, route checks, admin/system checks, and browser-based a11y smoke testing
- `code-optimizer`
- use for a targeted performance/code-quality audit after the current feature/polish work stabilizes
## Files most relevant next time
- `JobTrackerApi/Controllers/JobApplicationsController.cs`
- `JobTrackerApi/Controllers/ProfileCvController.cs`
+83
View File
@@ -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 12 — 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.
@@ -0,0 +1,72 @@
# 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 |
|---|---|---|---|
| **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 |
### 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.
## 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. 1520s) 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.
+67
View File
@@ -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).
+1 -1
View File
@@ -14,7 +14,7 @@ RUN npm ci
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
FROM nginx:1.29.8-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/build /usr/share/nginx/html
+38 -23
View File
@@ -1,25 +1,40 @@
{
"short_name": "JobTrack",
"name": "JobTrack — Job Application Tracker",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#0b1224",
"background_color": "#0b1224"
"short_name": "Jobbjakt",
"name": "Jobbjakt — Job Application Tracker",
"description": "Track and manage your job applications, tailor CVs, and stay on top of follow-ups.",
"id": "/",
"scope": "/",
"start_url": ".",
"display": "standalone",
"orientation": "portrait-primary",
"categories": ["productivity", "business"],
"theme_color": "#15803d",
"background_color": "#0b1224",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192",
"purpose": "any maskable"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512",
"purpose": "any maskable"
}
],
"share_target": {
"action": "/",
"method": "GET",
"params": {
"url": "add",
"text": "addtext"
}
}
}
+18 -1
View File
@@ -28,10 +28,12 @@ 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";
import { api } from "./api";
import { resolveCaptureUrl } from "./captureUrl";
import { clearAuthClientState, setAuthUserKey } from "./auth";
import AppShell, { NavItem } from "./layout/AppShell";
import { clearAccentColor, getAccentColor, getThemeModePref, setAccentColor, setThemeModePref, ThemeModePref } from "./themePrefs";
@@ -109,6 +111,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
const compactHeaderActions = useMediaQuery("(max-width:767.95px)");
const [addOpen, setAddOpen] = useState(false);
const [captureUrl, setCaptureUrl] = useState<string | undefined>(undefined);
const [quickOpen, setQuickOpen] = useState(false);
const [refreshToken, setRefreshToken] = useState(0);
const [requireAuth, setRequireAuth] = useState<boolean | null>(null);
@@ -124,6 +127,19 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
useEffect(() => {
api.get<AuthConfig>("/auth/config").then((r) => setRequireAuth(Boolean(r.data?.requireAuth))).catch(() => setRequireAuth(false));
}, []);
// Quick-capture target: bookmarklet (/?add=<url>) or PWA share (url in `add`, or a link
// embedded in shared `addtext`). Opens Add Job pre-filled and strips the params.
useEffect(() => {
const url = resolveCaptureUrl(location.search);
if (!url) return;
setCaptureUrl(url);
setAddOpen(true);
const params = new URLSearchParams(location.search);
params.delete("add");
params.delete("addtext");
navigate({ pathname: location.pathname, search: params.toString() }, { replace: true });
}, [location.search, location.pathname, navigate]);
useEffect(() => {
let active = true;
api.get<MeResponse>("/auth/me")
@@ -288,7 +304,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
</AppShell>
<Suspense fallback={null}>
<AddJobModal open={addOpen} onClose={() => setAddOpen(false)} onCreated={() => { setRefreshToken((t) => t + 1); }} />
<AddJobModal open={addOpen} initialUrl={captureUrl} onClose={() => { setAddOpen(false); setCaptureUrl(undefined); }} onCreated={() => { setRefreshToken((t) => t + 1); }} />
<QuickCommandDialog open={quickOpen} onClose={() => setQuickOpen(false)} onNavigate={(to) => navigate(to)} onOpenAddJob={() => setAddOpen(true)} />
</Suspense>
</>
@@ -329,6 +345,7 @@ export default function App() {
});
const router = useMemo(() => createBrowserRouter([
{ path: "/", element: <LandingPage />, errorElement: <RouteErrorPage /> },
{ path: "/login", element: <LoginPage />, errorElement: <RouteErrorPage /> },
{ path: "/forgot-password", element: <ForgotPasswordPage />, errorElement: <RouteErrorPage /> },
{ path: "/reset-password", element: <ResetPasswordPage />, errorElement: <RouteErrorPage /> },
+7 -1
View File
@@ -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 {
+22
View File
@@ -0,0 +1,22 @@
import { resolveCaptureUrl } from './captureUrl';
describe('resolveCaptureUrl', () => {
test('reads the bookmarklet add param', () => {
expect(resolveCaptureUrl('?add=https%3A%2F%2Fexample.com%2Fjob')).toBe('https://example.com/job');
});
test('extracts a url embedded in shared text', () => {
expect(resolveCaptureUrl('?addtext=' + encodeURIComponent('Cool role here https://example.com/job/42 apply now')))
.toBe('https://example.com/job/42');
});
test('prefers add over addtext', () => {
expect(resolveCaptureUrl('?add=https%3A%2F%2Fa.com&addtext=' + encodeURIComponent('https://b.com')))
.toBe('https://a.com');
});
test('returns null when there is no url', () => {
expect(resolveCaptureUrl('')).toBeNull();
expect(resolveCaptureUrl('?addtext=' + encodeURIComponent('just some text, no link'))).toBeNull();
});
});
+10
View File
@@ -0,0 +1,10 @@
// Resolves the quick-capture URL from query params produced by the bookmarklet (`add`)
// or the PWA share-target (a link in `add`, or embedded in shared `addtext`).
export function resolveCaptureUrl(search: string): string | null {
const params = new URLSearchParams(search);
const add = params.get("add");
if (add) return add;
const addText = params.get("addtext");
if (addText) return addText.match(/https?:\/\/\S+/)?.[0] ?? null;
return null;
}
+43 -21
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from "react";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
@@ -30,12 +30,14 @@ import { Company, JobImportResult } from "../types";
import { invalidateCompaniesCache, useCompanies } from "../hooks/useCompanies";
import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
import { PIPELINE_STATUSES, statusLabel as pipelineStatusLabel } from "../pipeline";
import TagsInput from "./TagsInput";
interface Props {
open: boolean;
onClose: () => void;
onCreated: () => void;
initialUrl?: string;
}
type DuplicateCandidate = {
@@ -60,7 +62,6 @@ type CreatedJobResponse = {
type AttachmentBucketKey = "resume" | "coverLetter" | "portfolio" | "other";
type AttachmentBuckets = Record<AttachmentBucketKey, File[]>;
const STATUS_OPTIONS = ["Applied", "Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const;
const ACCEPTED_DOCUMENT_TYPES = ".pdf,.doc,.docx,.txt,.md,image/*,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,text/plain,text/markdown";
const FIELD_SX = { "& .MuiInputBase-root": { minHeight: 56 } };
const PICKER_TEXT_FIELD_PROPS = { fullWidth: true, sx: FIELD_SX };
@@ -97,7 +98,7 @@ function normalizeLanguage(value?: string | null) {
return raw;
}
export default function AddJobModal({ open, onClose, onCreated }: Props) {
export default function AddJobModal({ open, onClose, onCreated, initialUrl }: Props) {
const { toast } = useToast();
const { t, language } = useI18n();
@@ -115,9 +116,13 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
const [dateApplied, setDateApplied] = useState(() => getTodayIso());
const [jobTitle, setJobTitle] = useState("");
const [status, setStatus] = useState<(typeof STATUS_OPTIONS)[number]>("Applied");
const [status, setStatus] = useState<(typeof PIPELINE_STATUSES)[number]>("Applied");
const [location, setLocation] = useState("");
const [salary, setSalary] = useState("");
const [salaryMin, setSalaryMin] = useState("");
const [salaryMax, setSalaryMax] = useState("");
const [salaryCurrency, setSalaryCurrency] = useState("");
const [salaryPeriod, setSalaryPeriod] = useState("");
const [jobUrl, setJobUrl] = useState("");
const [deadline, setDeadline] = useState("");
@@ -133,6 +138,21 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
setCompanies(cachedCompanies);
}, [cachedCompanies]);
// Quick-capture: when opened with a URL (from the bookmarklet), prefill and auto-import once.
const autoImportedUrlRef = useRef<string | null>(null);
useEffect(() => {
if (!open) {
autoImportedUrlRef.current = null;
return;
}
const url = initialUrl?.trim();
if (!url || autoImportedUrlRef.current === url) return;
autoImportedUrlRef.current = url;
setJobUrl(url);
void importFromUrl(url);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, initialUrl]);
const resetForm = () => {
setCompany(null);
setCompanyInput("");
@@ -219,16 +239,17 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
}
};
const importFromUrl = async () => {
const importFromUrl = async (urlArg?: string) => {
if (importing) return;
if (!jobUrl.trim()) {
const url = (urlArg ?? jobUrl).trim();
if (!url) {
toast(t("addJobModalPasteUrlFirst"), "warning");
return;
}
setImporting(true);
try {
const res = await api.post<JobImportResult>("/jobimport/preview", { url: jobUrl.trim() });
const res = await api.post<JobImportResult>("/jobimport/preview", { url });
const r = res.data;
if (!r?.success) throw new Error(r?.error || t("addJobModalImportFailed"));
@@ -291,6 +312,10 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
status,
location,
salary,
salaryMin: salaryMin.trim() ? Number(salaryMin) : null,
salaryMax: salaryMax.trim() ? Number(salaryMax) : null,
salaryCurrency: salaryCurrency.trim() || null,
salaryPeriod: salaryPeriod || null,
nextAction: null,
followUpAt: null,
jobUrl,
@@ -342,18 +367,6 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
}));
};
const statusLabel = (value: typeof STATUS_OPTIONS[number]) => {
const map = {
Applied: t("statusApplied"),
Waiting: t("statusWaiting"),
Interview: t("statusInterview"),
Offer: t("statusOffer"),
Rejected: t("statusRejected"),
Ghosted: t("statusGhosted"),
} as const;
return map[value];
};
const filesLabel = (files: File[]) => {
if (files.length === 0) return t("addJobModalNoFilesSelected");
if (files.length === 1) return files[0].name;
@@ -471,9 +484,9 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
/>
<TextField select label={t("addJobModalStatus")} value={status} onChange={(e) => setStatus(e.target.value as any)} sx={FIELD_SX}>
{STATUS_OPTIONS.map((s) => (
{PIPELINE_STATUSES.map((s) => (
<MenuItem key={s} value={s}>
{statusLabel(s)}
{pipelineStatusLabel(t, s)}
</MenuItem>
))}
</TextField>
@@ -482,6 +495,15 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
<TextField label={t("location")} value={location} onChange={(e) => setLocation(e.target.value)} sx={FIELD_SX} />
<TextField label={t("addJobModalSalary")} value={salary} onChange={(e) => setSalary(e.target.value)} sx={FIELD_SX} />
<TextField label={t("salaryMinLabel")} type="number" value={salaryMin} onChange={(e) => setSalaryMin(e.target.value)} sx={FIELD_SX} />
<TextField label={t("salaryMaxLabel")} type="number" value={salaryMax} onChange={(e) => setSalaryMax(e.target.value)} sx={FIELD_SX} />
<TextField label={t("salaryCurrencyLabel")} value={salaryCurrency} onChange={(e) => setSalaryCurrency(e.target.value)} sx={FIELD_SX} inputProps={{ maxLength: 8 }} />
<TextField select SelectProps={{ native: true }} label={t("salaryPeriodLabel")} value={salaryPeriod} onChange={(e) => setSalaryPeriod(e.target.value)} sx={FIELD_SX} InputLabelProps={{ shrink: true }}>
<option value=""></option>
<option value="year">{t("salaryPeriodYear")}</option>
<option value="month">{t("salaryPeriodMonth")}</option>
<option value="hour">{t("salaryPeriodHour")}</option>
</TextField>
<DatePicker
label={t("addJobModalDeadline")}
value={parsePickerDate(deadline)}
@@ -25,6 +25,7 @@ import { api } from "../api";
import ViewStateNotice from "./ViewStateNotice";
import { getUserKeyFromToken } from "../themePrefs";
import { useI18n } from "../i18n/I18nProvider";
import { statusLabel } from "../pipeline";
import { buildWorkflowPath, getWorkflowAction } from "../jobWorkflowSignals";
import { JobApplication } from "../types";
import { useViewResource } from "../hooks/useViewResource";
@@ -49,6 +50,7 @@ type OverviewAnalytics = {
medianDaysToFirstResponse?: number | null;
totalResponses: number;
totalActive: number;
timeInStage?: { stage: string; medianDays: number; count: number }[];
};
type TagTrendResponse = { months: string[]; series: { tag: string; counts: number[] }[] };
@@ -453,7 +455,7 @@ export default function DashboardView() {
return (
<Box key={item.label}>
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 0.5, gap: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 700 }}>{item.label}</Typography>
<Typography variant="body2" sx={{ fontWeight: 700 }}>{statusLabel(t, item.label)}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{item.count}</Typography>
</Box>
<LinearProgress
@@ -474,6 +476,22 @@ export default function DashboardView() {
})}
</Stack>
{overview?.timeInStage?.length ? (
<Box sx={{ mt: 2.25 }}>
<Typography variant="body2" sx={{ fontWeight: 800, mb: 1 }}>{t("dashboardTimeInStageTitle")}</Typography>
<Stack spacing={0.75}>
{overview.timeInStage.map((item) => (
<Box key={item.stage} sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 700 }}>{statusLabel(t, item.stage)}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
{t("dashboardTimeInStageValue", { days: item.medianDays, count: item.count })}
</Typography>
</Box>
))}
</Stack>
</Box>
) : null}
<Box sx={{ mt: 2.25, p: 1.5, borderRadius: 3, backgroundColor: alpha(theme.palette.primary.main, 0.05) }}>
<Typography variant="body2" sx={{ fontWeight: 800 }}>{summaryView.topSource?.label ?? t("dashboardResponseSources")}</Typography>
<Typography variant="h5" sx={{ fontWeight: 950, mt: 0.5 }}>{summaryView.topSource ? `${summaryView.topSource.rate}%` : "—"}</Typography>
@@ -24,6 +24,7 @@ import { useToast } from "../toast";
import { useCompanies } from "../hooks/useCompanies";
import TagsInput from "./TagsInput";
import { useI18n } from "../i18n/I18nProvider";
import { PIPELINE_STATUSES, statusLabel } from "../pipeline";
interface Props {
open: boolean;
@@ -32,7 +33,6 @@ interface Props {
onSaved: () => void;
}
const STATUS_OPTIONS = ["Applied", "Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const;
const FIELD_SX = { "& .MuiInputBase-root": { minHeight: 56 } };
const PICKER_TEXT_FIELD_PROPS = { fullWidth: true, sx: FIELD_SX };
@@ -80,6 +80,10 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
const [dateApplied, setDateApplied] = useState(() => new Date().toISOString().slice(0, 10));
const [location, setLocation] = useState("");
const [salary, setSalary] = useState("");
const [salaryMin, setSalaryMin] = useState("");
const [salaryMax, setSalaryMax] = useState("");
const [salaryCurrency, setSalaryCurrency] = useState("");
const [salaryPeriod, setSalaryPeriod] = useState("");
const [nextAction, setNextAction] = useState("");
const [followUpAt, setFollowUpAt] = useState<string>("");
const [jobUrl, setJobUrl] = useState("");
@@ -110,6 +114,10 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
setDateApplied(toDateInputValue(j.dateApplied));
setLocation(j.location ?? "");
setSalary(j.salary ?? "");
setSalaryMin(j.salaryMin != null ? String(j.salaryMin) : "");
setSalaryMax(j.salaryMax != null ? String(j.salaryMax) : "");
setSalaryCurrency(j.salaryCurrency ?? "");
setSalaryPeriod(j.salaryPeriod ?? "");
setNextAction((j as any).nextAction ?? "");
setFollowUpAt((j as any).followUpAt ? toDateInputValue((j as any).followUpAt) : "");
setJobUrl(j.jobUrl ?? "");
@@ -144,6 +152,10 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
responseDate: responseReceived && responseDate ? responseDate : null,
location: location.trim() || null,
salary: salary.trim() || null,
salaryMin: salaryMin.trim() ? Number(salaryMin) : null,
salaryMax: salaryMax.trim() ? Number(salaryMax) : null,
salaryCurrency: salaryCurrency.trim() || null,
salaryPeriod: salaryPeriod || null,
nextAction: nextAction.trim() || null,
followUpAt: followUpAt || null,
hasResume,
@@ -195,7 +207,7 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
<Typography variant="overline" sx={{ color: "text.secondary" }}>{t("editJobStatusUpdate")}</Typography>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr 1fr" }, gap: 2, mt: 1 }}>
<TextField select label={t("editJobCurrentStatus")} value={status} onChange={(e) => setStatus(e.target.value)} sx={FIELD_SX}>
{STATUS_OPTIONS.map((s) => <MenuItem key={s} value={s}>{s}</MenuItem>)}
{PIPELINE_STATUSES.map((s) => <MenuItem key={s} value={s}>{statusLabel(t, s)}</MenuItem>)}
</TextField>
<DatePicker label={t("editJobStatusChangedOn")} value={parsePickerDate(statusChangedAt)} onChange={(value) => setStatusChangedAt(toPickerIso(value))} slotProps={{ textField: { ...PICKER_TEXT_FIELD_PROPS, helperText: status === initialStatus ? t("editJobStatusChangedHelpIdle") : t("editJobStatusChangedHelpActive") } }} />
<Box sx={{ display: "flex", alignItems: "center" }}><FormControlLabel control={<Checkbox checked={responseReceived} onChange={(e) => setResponseReceived(e.target.checked)} />} label={t("editJobReplyReceived")} /></Box>
@@ -210,6 +222,15 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2, mt: 1 }}>
<TextField label={t("location")} value={location} onChange={(e) => setLocation(e.target.value)} sx={FIELD_SX} />
<TextField label={t("addJobModalSalary")} value={salary} onChange={(e) => setSalary(e.target.value)} sx={FIELD_SX} />
<TextField label={t("salaryMinLabel")} type="number" value={salaryMin} onChange={(e) => setSalaryMin(e.target.value)} sx={FIELD_SX} />
<TextField label={t("salaryMaxLabel")} type="number" value={salaryMax} onChange={(e) => setSalaryMax(e.target.value)} sx={FIELD_SX} />
<TextField label={t("salaryCurrencyLabel")} value={salaryCurrency} onChange={(e) => setSalaryCurrency(e.target.value)} sx={FIELD_SX} inputProps={{ maxLength: 8 }} />
<TextField select SelectProps={{ native: true }} label={t("salaryPeriodLabel")} value={salaryPeriod} onChange={(e) => setSalaryPeriod(e.target.value)} sx={FIELD_SX} InputLabelProps={{ shrink: true }}>
<option value=""></option>
<option value="year">{t("salaryPeriodYear")}</option>
<option value="month">{t("salaryPeriodMonth")}</option>
<option value="hour">{t("salaryPeriodHour")}</option>
</TextField>
<DatePicker label={t("editJobDeadline")} value={parsePickerDate(deadline)} onChange={(value) => setDeadline(toPickerIso(value))} slotProps={{ textField: PICKER_TEXT_FIELD_PROPS }} />
<TextField label={t("editJobDescriptionLanguage")} value={descriptionLanguage} onChange={(e) => setDescriptionLanguage(e.target.value)} sx={FIELD_SX} />
<Box sx={{ gridColumn: "1 / -1" }}><TagsInput value={tags} onChange={setTags} /></Box>
@@ -10,6 +10,7 @@ import {
DialogTitle,
FormControl,
InputLabel,
LinearProgress,
MenuItem,
Select,
Tab,
@@ -17,9 +18,11 @@ import {
TextField,
Typography,
} from "@mui/material";
import { alpha } from "@mui/material/styles";
import { api, getApiErrorMessage } from "../api";
import { ApplicationPackageResponse, CandidateFit, FocusPlanResponse, FollowUpDraft, InterviewPrepResponse, JobApplication, ReadinessResponse, TailoredCvDraft } from "../types";
import { ApplicationPackageResponse, CandidateFit, FocusPlanResponse, FollowUpDraft, InterviewPrepResponse, JobApplication, MatchScore, ReadinessResponse, StatusSuggestion, TailoredCvDraft } from "../types";
import { statusLabel } from "../pipeline";
import { useToast } from "../toast";
import { useDialogActions } from "../dialogs";
import { emptyTailoredCvDraft, joinLines, normalizeTailoredCvDraft, splitLines } from "../tailoredCvDraft";
@@ -130,6 +133,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
const { confirmAction } = useDialogActions();
const followUpCache = useWorkspaceTabCache<FollowUpDraft | null>();
const candidateFitCache = useWorkspaceTabCache<CandidateFit | null>();
const matchScoreCache = useWorkspaceTabCache<MatchScore | null>();
const focusPlanCache = useWorkspaceTabCache<FocusPlanResponse | null>();
const interviewPrepCache = useWorkspaceTabCache<InterviewPrepResponse | null>();
const readinessCache = useWorkspaceTabCache<ReadinessResponse | null>();
@@ -168,6 +172,10 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
const [sendingDraft, setSendingDraft] = useState(false);
const [refreshingAi, setRefreshingAi] = useState(false);
const [candidateFit, setCandidateFit] = useState<CandidateFit | null>(null);
const [matchScore, setMatchScore] = useState<MatchScore | null>(null);
const [loadingMatchScore, setLoadingMatchScore] = useState(false);
const [statusSuggestion, setStatusSuggestion] = useState<StatusSuggestion | null>(null);
const [applyingStatusSuggestion, setApplyingStatusSuggestion] = useState(false);
const [focusPlan, setFocusPlan] = useState<FocusPlanResponse | null>(null);
const [loadingCandidateFit, setLoadingCandidateFit] = useState(false);
const [loadingFocusPlan, setLoadingFocusPlan] = useState(false);
@@ -200,6 +208,8 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
if (!open || !jobId) return;
setFollowUpDraft(null);
setCandidateFit(null);
setMatchScore(null);
setStatusSuggestion(null);
setFocusPlan(null);
setInterviewPrep(null);
setReadiness(null);
@@ -280,6 +290,49 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
}).catch(() => setCandidateFit(null)).finally(() => setLoadingCandidateFit(false));
}, [open, jobId, tab, candidateFit, selectedAttachmentCsv, candidateFitCache]);
// Match score is deterministic and cheap: load it on the Candidate Fit tab
// independently of the slow AI narrative so users see the number instantly.
useEffect(() => {
if (!open || !jobId || tab !== 5 || matchScore) return;
const cacheKey = `${jobId}:match-score`;
const cached = matchScoreCache.getCached(cacheKey);
if (cached) {
setMatchScore(cached);
return;
}
setLoadingMatchScore(true);
api.get<MatchScore>(`/jobapplications/${jobId}/match-score`).then((r) => {
matchScoreCache.setCached(cacheKey, r.data);
setMatchScore(r.data);
}).catch(() => setMatchScore(null)).finally(() => setLoadingMatchScore(false));
}, [open, jobId, tab, matchScore, matchScoreCache]);
// Suggest a status move from the latest inbound email when the workspace opens.
useEffect(() => {
if (!open || !jobId) return;
let cancelled = false;
api.get<StatusSuggestion>(`/jobapplications/${jobId}/status-suggestion`)
.then((r) => { if (!cancelled) setStatusSuggestion(r.data?.hasSuggestion ? r.data : null); })
.catch(() => { if (!cancelled) setStatusSuggestion(null); });
return () => { cancelled = true; };
}, [open, jobId]);
const applyStatusSuggestion = async () => {
if (!jobId || !statusSuggestion?.suggestedStatus) return;
setApplyingStatusSuggestion(true);
try {
await api.patch(`/jobapplications/${jobId}/status`, { status: statusSuggestion.suggestedStatus });
setJob((prev) => prev ? { ...prev, status: statusSuggestion.suggestedStatus! } : prev);
setStatusSuggestion(null);
toast(t("statusSuggestionApplied"), "success");
} catch (error: any) {
toast(getApiErrorMessage(error, t("statusSuggestionFailed")), "error");
} finally {
setApplyingStatusSuggestion(false);
}
};
useEffect(() => {
if (!open || !jobId || tab !== 6 || focusPlan) return;
const cacheKey = `${jobId}:focus-plan:${selectedAttachmentCsv || "none"}`;
@@ -598,6 +651,25 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
{attachmentPicker}
{statusSuggestion?.hasSuggestion ? (
<Box sx={{ p: 1.5, mb: 2, borderRadius: 3, border: "1px solid", borderColor: "warning.main", backgroundColor: (theme) => alpha(theme.palette.warning.main, 0.08), display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1.5, flexWrap: "wrap" }}>
<Box>
<Typography variant="body2" sx={{ fontWeight: 800 }}>
{t("statusSuggestionTitle", { status: statusLabel(t, statusSuggestion.suggestedStatus ?? "") })}
</Typography>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
{t("statusSuggestionReason", { signal: statusSuggestion.signal ?? "", current: statusLabel(t, statusSuggestion.currentStatus ?? "") })}
</Typography>
</Box>
<Box sx={{ display: "flex", gap: 1 }}>
<Button size="small" variant="contained" color="warning" disabled={applyingStatusSuggestion} onClick={() => void applyStatusSuggestion()}>
{t("statusSuggestionApply", { status: statusLabel(t, statusSuggestion.suggestedStatus ?? "") })}
</Button>
<Button size="small" variant="text" onClick={() => setStatusSuggestion(null)}>{t("statusSuggestionDismiss")}</Button>
</Box>
</Box>
) : null}
{tab === 0 && (
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 2 }}>
<Box sx={{ gridColumn: "1 / -1", display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
@@ -1058,6 +1130,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
{tab === 5 && (
<Box>
<MatchScoreCard score={matchScore} loading={loadingMatchScore} />
{loadingCandidateFit ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : candidateFit ? (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2.5 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
@@ -1136,6 +1209,73 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
);
}
function MatchScoreCard({ score, loading }: { score: MatchScore | null; loading: boolean }) {
const { t } = useI18n();
if (loading && !score) {
return (
<Box sx={{ p: 1.5, mb: 2, borderRadius: 3, border: "1px solid", borderColor: "divider", display: "flex", alignItems: "center", gap: 1.5 }}>
<CircularProgress size={18} />
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("matchScoreLoading")}</Typography>
</Box>
);
}
if (!score) return null;
const color: "success" | "warning" | "error" | "inherit" =
!score.hasEnoughSignal ? "inherit" : score.score >= 75 ? "success" : score.score >= 50 ? "warning" : "error";
const bandLabel = t(`matchScoreBand_${score.band}` as any) || score.band;
return (
<Box sx={{ p: 1.75, mb: 2, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap", mb: 1 }}>
<Box sx={{ display: "flex", alignItems: "baseline", gap: 1 }}>
<Typography variant="h4" sx={{ fontWeight: 800, fontVariantNumeric: "tabular-nums" }}>{score.hasEnoughSignal ? `${score.score}%` : "—"}</Typography>
<Typography variant="overline">{t("matchScoreTitle")}</Typography>
</Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
<Chip size="small" color={color === "inherit" ? "default" : color} label={bandLabel} />
<Chip size="small" variant="outlined" label={t("matchScoreKeywordsCovered", { matched: score.matchedCount, total: score.totalKeywords })} />
</Box>
</Box>
{score.hasEnoughSignal ? (
<LinearProgress
variant="determinate"
value={score.score}
color={color === "inherit" ? "primary" : color}
sx={{ height: 8, borderRadius: 4, mb: 1.5 }}
/>
) : (
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1 }}>{t("matchScoreNoSignal")}</Typography>
)}
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mb: 1 }}>{t("matchScoreDeterministicHint")}</Typography>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5 }}>
<Box>
<Typography variant="overline">{t("matchScoreMatched")}</Typography>
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 0.5 }}>
{score.matchedKeywords.length ? score.matchedKeywords.map((k) => <Chip key={k} label={k} color="success" size="small" />) : <Typography variant="body2" sx={{ color: "text.secondary" }}>{t("matchScoreNoneYet")}</Typography>}
</Box>
</Box>
<Box>
<Typography variant="overline">{t("matchScoreMissing")}</Typography>
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 0.5 }}>
{score.missingKeywords.length ? score.missingKeywords.map((k) => <Chip key={k} label={k} color="warning" variant="outlined" size="small" />) : <Typography variant="body2" sx={{ color: "text.secondary" }}>{t("matchScoreAllCovered")}</Typography>}
</Box>
</Box>
</Box>
{score.sectionCoverage.length ? (
<Box sx={{ mt: 1.5 }}>
<Typography variant="overline">{t("matchScoreSectionCoverage")}</Typography>
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 0.5 }}>
{score.sectionCoverage.map((s) => <Chip key={s.section} size="small" variant="outlined" label={`${s.section}: ${s.matched}/${s.total}`} />)}
</Box>
</Box>
) : null}
</Box>
);
}
function SectionChips({ title, items, color, outlined }: { title: string; items: string[]; color: "success" | "warning"; outlined?: boolean }) {
const { t } = useI18n();
+6 -23
View File
@@ -44,6 +44,8 @@ import { api } from "../api";
import ViewStateNotice from "./ViewStateNotice";
import { useCompanies } from "../hooks/useCompanies";
import { useDebouncedValue } from "../hooks/useDebouncedValue";
import { formatSalary } from "../salary";
import { statusLabel, statusTone } from "../pipeline";
import JobDetailsDialog from "./JobDetailsDialog";
import EditJobDialog from "./EditJobDialog";
import { useToast } from "../toast";
@@ -97,10 +99,6 @@ interface Props {
mode?: "jobs" | "trash";
}
function normalizeStatus(status: string): string {
return status === "Interviewing" ? "Interview" : status;
}
function parseTags(raw?: string | null): string[] {
if (!raw) return [];
try {
@@ -111,21 +109,6 @@ function parseTags(raw?: string | null): string[] {
}
}
function statusTone(status: string): string {
switch (normalizeStatus(status)) {
case "Offer":
return "success";
case "Rejected":
return "error";
case "Waiting":
case "Ghosted":
return "warning";
case "Interview":
return "info";
default:
return "primary";
}
}
function generateOverview(job: JobApplication): string {
if (job.fullSummary) return job.fullSummary;
@@ -546,7 +529,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
</Typography>
</Box>
</Box>
{columns.status ? <Chip label={normalizeStatus(job.status)} size="small" color={toneName as any} sx={{ fontWeight: 800 }} /> : null}
{columns.status ? <Chip label={statusLabel(t, job.status)} size="small" color={toneName as any} sx={{ fontWeight: 800 }} /> : null}
</Box>
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap" }}>
@@ -584,7 +567,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
</Box>
<Box>
<Typography variant="overline" sx={{ color: "text.secondary" }}>{t("addJobModalSalary")}</Typography>
<Typography variant="body2" sx={{ fontWeight: 600, fontVariantNumeric: "tabular-nums" }}>{job.salary ?? "-"}</Typography>
<Typography variant="body2" sx={{ fontWeight: 600, fontVariantNumeric: "tabular-nums" }}>{formatSalary(job) ?? "-"}</Typography>
</Box>
</Box>
@@ -694,7 +677,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
))}
</Box>
</TableCell>
{columns.status ? <TableCell><Chip label={normalizeStatus(job.status)} size="small" color={toneName as any} /></TableCell> : null}
{columns.status ? <TableCell><Chip label={statusLabel(t, job.status)} size="small" color={toneName as any} /></TableCell> : null}
{columns.dateApplied ? <TableCell>{appliedDateLabel}</TableCell> : null}
{columns.daysSince ? <TableCell>{job.daysSince}</TableCell> : null}
{columns.jobUrl ? <TableCell>{job.jobUrl ? <a href={job.jobUrl} target="_blank" rel="noreferrer">{t("jobTableLink")}</a> : ""}</TableCell> : null}
@@ -727,7 +710,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
<Collapse in={isExpanded} timeout="auto" unmountOnExit>
<Box sx={{ p: 2, display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr 1fr" }, gap: 2 }}>
<Box><Typography variant="overline">{t("jobTableLocation")}</Typography><Typography>{job.location ?? "-"}</Typography></Box>
<Box><Typography variant="overline">{t("addJobModalSalary")}</Typography><Typography>{job.salary ?? "-"}</Typography></Box>
<Box><Typography variant="overline">{t("addJobModalSalary")}</Typography><Typography>{formatSalary(job) ?? "-"}</Typography></Box>
<Box><Typography variant="overline">{t("settingsColumnJobUrl")}</Typography><Typography>{job.jobUrl ? <a href={job.jobUrl} target="_blank" rel="noreferrer">{t("jobTableOpenListing")}</a> : "-"}</Typography></Box>
<Box sx={{ gridColumn: "1 / -1" }}><Typography variant="overline">{t("jobTableSkills")}</Typography><Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 0.5 }}>{detailTags.length ? detailTags.map((tag) => <Chip key={tag} label={tag} size="small" />) : <Typography sx={{ color: "text.secondary" }}>{t("jobTableNoTags")}</Typography>}</Box></Box>
<Box sx={{ gridColumn: "1 / -1" }}><Typography variant="overline">{t("jobTableOverview")}</Typography><Typography sx={{ whiteSpace: "pre-wrap" }}>{overview || t("jobTableNoSummaryYet")}</Typography></Box>
+12 -31
View File
@@ -19,41 +19,22 @@ import ViewStateNotice from "./ViewStateNotice";
import { JobApplication } from "../types";
import { useI18n } from "../i18n/I18nProvider";
import { useViewResource } from "../hooks/useViewResource";
import { PIPELINE_STATUSES, PipelineStatus, normalizeStatus, statusLabel, statusTone } from "../pipeline";
const STATUSES = ["Applied", "Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const;
type Status = (typeof STATUSES)[number];
const STATUSES = PIPELINE_STATUSES;
type Status = PipelineStatus;
function normalizeStatus(status: string): Status | "Other" {
if (status === "Interviewing") return "Interview";
if ((STATUSES as readonly string[]).includes(status)) return status as Status;
return "Other";
}
const TONE_PALETTE: Record<string, (theme: any) => string> = {
error: (theme) => theme.palette.error.main,
warning: (theme) => theme.palette.warning.main,
success: (theme) => theme.palette.success.main,
info: (theme) => alpha(theme.palette.primary.main, 0.95),
primary: (theme) => theme.palette.primary.main,
default: (theme) => theme.palette.primary.main,
};
function toneColor(theme: any, status: Status | "Other"): string {
if (status === "Rejected") return theme.palette.error.main;
if (status === "Waiting" || status === "Ghosted") return theme.palette.warning.main;
if (status === "Offer") return theme.palette.success.main;
if (status === "Interview") return alpha(theme.palette.primary.main, 0.95);
return theme.palette.primary.main;
}
function statusLabel(t: (key: any, params?: any) => string, status: Status): string {
switch (status) {
case "Applied":
return t("statusApplied");
case "Waiting":
return t("statusWaiting");
case "Interview":
return t("statusInterview");
case "Offer":
return t("statusOffer");
case "Rejected":
return t("statusRejected");
case "Ghosted":
return t("statusGhosted");
default:
return status;
}
return TONE_PALETTE[statusTone(status)](theme);
}
export default function KanbanBoard() {
@@ -0,0 +1,69 @@
import React, { useEffect, useRef } from "react";
import { Box, Paper, TextField, Typography } from "@mui/material";
import { useI18n } from "../i18n/I18nProvider";
import { useToast } from "../toast";
/** The bookmarklet opens the app at /?add=<current page url>, which triggers quick-capture. */
function buildBookmarklet(origin: string): string {
// Kept as a single minified expression; opens a small popup so the user's tab is undisturbed.
return `javascript:void(window.open('${origin}/?add='+encodeURIComponent(location.href),'jobbjakt','width=520,height=720'))`;
}
export default function QuickCaptureCard() {
const { t } = useI18n();
const { toast } = useToast();
const linkRef = useRef<HTMLAnchorElement>(null);
const origin = typeof window !== "undefined" ? window.location.origin : "";
const bookmarklet = buildBookmarklet(origin);
// React refuses to render javascript: hrefs, so set it directly on the DOM node.
useEffect(() => {
if (linkRef.current) linkRef.current.setAttribute("href", bookmarklet);
}, [bookmarklet]);
return (
<Paper sx={{ p: 2 }}>
<Typography sx={{ fontWeight: 950, mb: 0.5 }}>{t("settingsQuickCaptureTitle")}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>{t("settingsQuickCaptureSubtitle")}</Typography>
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5, flexWrap: "wrap", mb: 1.5 }}>
<Box
component="a"
ref={linkRef}
onClick={(e: React.MouseEvent) => {
// Clicking (vs dragging) shouldn't navigate; the value is meant to be dragged to the bar.
e.preventDefault();
toast(t("settingsQuickCaptureDragHint"), "info");
}}
sx={{
display: "inline-block",
px: 2,
py: 1,
borderRadius: 2,
border: "1px solid",
borderColor: "primary.main",
color: "primary.main",
fontWeight: 800,
textDecoration: "none",
cursor: "grab",
userSelect: "none",
}}
>
{t("settingsQuickCaptureButton")}
</Box>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("settingsQuickCaptureDragHint")}</Typography>
</Box>
<TextField
label={t("settingsQuickCaptureManual")}
value={bookmarklet}
fullWidth
size="small"
InputProps={{ readOnly: true }}
onFocus={(e) => e.target.select()}
/>
</Paper>
);
}
@@ -24,6 +24,7 @@ import ImportExportJobs from "./ImportExportJobs";
import GoogleAuthCard from "./GoogleAuthCard";
import RulesSettingsCard from "./RulesSettingsCard";
import BackupCard from "./BackupCard";
import QuickCaptureCard from "./QuickCaptureCard";
import AuthStatusCard from "./AuthStatusCard";
import { ThemeModePref } from "../themePrefs";
import { useI18n } from "../i18n/I18nProvider";
@@ -45,7 +46,7 @@ function TabPanel({ value, index, children }: { value: number; index: number; ch
return <Box sx={{ mt: 2 }}>{children}</Box>;
}
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 = {
@@ -297,6 +298,8 @@ export default function SettingsView({
<ImportExportJobs />
</Paper>
<QuickCaptureCard />
</Box>
</TabPanel>
@@ -110,6 +110,20 @@ describe('end-to-end trust loop', () => {
if (url === '/jobapplications/42') return Promise.resolve({ data: jobRecord } as any);
if (url === '/auth/me') return Promise.resolve({ data: { roles: [], profileCvText: 'Master CV text' } } as any);
if (url === '/jobapplications/42/history') return Promise.resolve({ data: [] } as any);
if (url === '/jobapplications/42/tailored-cv-draft') {
return Promise.resolve({
data: {
templateId: 'ats-minimal',
headline: 'Backend Developer',
summary: ['Tailored for the Acme backend role'],
selectedSkills: [],
experience: [],
education: [],
customSections: [],
status: 'saved',
},
} as any);
}
if (url === '/attachments/42') return Promise.resolve({ data: [{ id: 9, fileName: 'resume.pdf', uploadDate: new Date().toISOString(), fileType: 'application/pdf', fileSize: 1234, purpose: 'resume', useForAi: true }] } as any);
if (url === '/correspondence/42') return Promise.resolve({ data: correspondenceMessages } as any);
if (url === '/gmail/status') return Promise.resolve({ data: { connected: true, gmailAddress: 'user@example.test', lastSyncedAt: new Date().toISOString() } } as any);
@@ -207,7 +221,7 @@ describe('end-to-end trust loop', () => {
fireEvent.click(screen.getByRole('tab', { name: /tailored cv/i }));
expect(await screen.findByDisplayValue('Saved CV')).toBeInTheDocument();
expect((await screen.findAllByDisplayValue(/tailored for the acme backend role/i)).length).toBeGreaterThan(0);
expect(await screen.findByDisplayValue('Saved cover letter')).toBeInTheDocument();
expect(await screen.findByDisplayValue('Saved application answer')).toBeInTheDocument();
expect(await screen.findByDisplayValue('Saved recruiter message')).toBeInTheDocument();
+7 -2
View File
@@ -65,11 +65,16 @@ export function useViewResource<T>(
const [hasLoaded, setHasLoaded] = useState(false);
const [error, setError] = useState<ViewResourceError | null>(null);
const hasLoadedRef = useRef(hasLoaded);
const loadRef = useRef(load);
useEffect(() => {
hasLoadedRef.current = hasLoaded;
}, [hasLoaded]);
useEffect(() => {
loadRef.current = load;
}, [load]);
const reload = useCallback(async () => {
if (!enabled) return;
@@ -77,7 +82,7 @@ export function useViewResource<T>(
setLoading(!alreadyLoaded);
setRefreshing(alreadyLoaded);
try {
const next = await load();
const next = await loadRef.current();
setData(next);
setError(null);
setHasLoaded(true);
@@ -88,7 +93,7 @@ export function useViewResource<T>(
setLoading(false);
setRefreshing(false);
}
}, [enabled, errorMessage, load]);
}, [enabled, errorMessage]);
useEffect(() => {
if (!enabled) {
+68
View File
@@ -77,6 +77,13 @@ export const translations = {
addJobModalStatus: "Status",
addJobModalJobTitle: "Job title",
addJobModalSalary: "Salary",
salaryMinLabel: "Salary min",
salaryMaxLabel: "Salary max",
salaryCurrencyLabel: "Currency",
salaryPeriodLabel: "Per",
salaryPeriodYear: "Year",
salaryPeriodMonth: "Month",
salaryPeriodHour: "Hour",
addJobModalDeadline: "Deadline",
addJobModalDescriptionOriginal: "Description (original)",
addJobModalTranslatedDescription: "Translated description ({language})",
@@ -150,6 +157,11 @@ export const translations = {
settingsOpenReminderInbox: "Open reminders",
settingsReviewJobs: "Review jobs",
settingsNotificationsTitle: "Notification settings",
settingsQuickCaptureTitle: "Quick capture bookmarklet",
settingsQuickCaptureSubtitle: "Drag this button to your bookmarks bar. On any job posting, click it to open Add Job pre-filled from that page.",
settingsQuickCaptureButton: " Save to Jobbjakt",
settingsQuickCaptureDragHint: "Drag me to your bookmarks bar",
settingsQuickCaptureManual: "Or copy the bookmarklet code",
settingsNotificationsBody: "Choose which reminders should show up in your workflow. SMTP delivery can be checked from the system page.",
settingsNotificationsDelivery: "SMTP delivery and test mail live under Admin → System → Settings.",
settingsNotificationsFollowUpReminders: "Email reminders for follow-ups",
@@ -328,6 +340,8 @@ export const translations = {
dashboardApplicationActivity: "Application activity",
dashboardMonthlyApplicationsResponses: "Monthly applications versus responses.",
dashboardConversionFunnelTitle: "Conversion funnel",
dashboardTimeInStageTitle: "Median time in stage",
dashboardTimeInStageValue: "{days}d · {count} active",
dashboardResponseSources: "Response sources",
dashboardTopCompaniesByActivity: "Top companies by activity",
dashboardTopSkills: "Top skills",
@@ -772,6 +786,12 @@ export const translations = {
jobDetailsTabFocusPlan: "Focus plan",
jobDetailsTabInterviewPrep: "Interview prep",
jobDetailsTabHistory: "History",
statusSuggestionTitle: "This email looks like a move to {status}",
statusSuggestionReason: "Matched \"{signal}\" · currently {current}",
statusSuggestionApply: "Move to {status}",
statusSuggestionDismiss: "Dismiss",
statusSuggestionApplied: "Status updated.",
statusSuggestionFailed: "Could not update status.",
jobDetailsTailoredCvMode: "Generation mode",
jobDetailsGenerationDefault: "Balanced",
jobDetailsGenerationConcise: "Concise",
@@ -860,6 +880,20 @@ export const translations = {
jobDetailsFollowUpSent: "Follow-up sent and logged.",
jobDetailsFollowUpSendFailed: "Failed to send follow-up.",
jobDetailsHowYouMatch: "How you match",
matchScoreTitle: "Match score",
matchScoreLoading: "Scoring your CV against this role…",
matchScoreBand_Strong: "Strong match",
matchScoreBand_Partial: "Partial match",
matchScoreBand_Low: "Low match",
matchScoreBand_Unknown: "Not enough signal",
matchScoreKeywordsCovered: "{matched}/{total} keywords",
matchScoreNoSignal: "Add more CV detail or a fuller job description to get a reliable score.",
matchScoreDeterministicHint: "Deterministic keyword coverage — no AI, so the score is stable and repeatable.",
matchScoreMatched: "Matched keywords",
matchScoreMissing: "Missing keywords",
matchScoreNoneYet: "No matches found yet.",
matchScoreAllCovered: "Every keyword is covered.",
matchScoreSectionCoverage: "Where your CV covers this role",
jobDetailsStrategySnapshot: "Strategy snapshot",
jobDetailsGenerateStrategySnapshot: "Generate strategy snapshot",
jobDetailsStrategySnapshotEmpty: "Generate a snapshot to see fit, positioning, and immediate priorities in one place.",
@@ -987,6 +1021,13 @@ export const translations = {
addJobModalStatus: "Status",
addJobModalJobTitle: "Stillingstittel",
addJobModalSalary: "Lønn",
salaryMinLabel: "Lønn fra",
salaryMaxLabel: "Lønn til",
salaryCurrencyLabel: "Valuta",
salaryPeriodLabel: "Per",
salaryPeriodYear: "År",
salaryPeriodMonth: "Måned",
salaryPeriodHour: "Time",
addJobModalDeadline: "Frist",
addJobModalDescriptionOriginal: "Beskrivelse (original)",
addJobModalTranslatedDescription: "Oversatt beskrivelse ({language})",
@@ -1060,6 +1101,11 @@ export const translations = {
settingsOpenReminderInbox: "Åpne påminnelser",
settingsReviewJobs: "Gå til jobber",
settingsNotificationsTitle: "Varslingsinnstillinger",
settingsQuickCaptureTitle: "Hurtiglagring (bokmerke)",
settingsQuickCaptureButton: " Lagre til Jobbjakt",
settingsQuickCaptureSubtitle: "Dra denne knappen til bokmerkelinjen. På en stillingsannonse klikker du på den for å åpne Legg til jobb forhåndsutfylt fra siden.",
settingsQuickCaptureDragHint: "Dra meg til bokmerkelinjen",
settingsQuickCaptureManual: "Eller kopier bokmerkekoden",
settingsNotificationsBody: "Velg hvilke påminnelser som skal vises i arbeidsflyten din. SMTP-levering kan kontrolleres fra systemsiden.",
settingsNotificationsDelivery: "SMTP-levering og test-epost ligger under Admin → System → Innstillinger.",
settingsNotificationsFollowUpReminders: "E-postpåminnelser for oppfølginger",
@@ -1238,6 +1284,8 @@ export const translations = {
dashboardApplicationActivity: "Søknadsaktivitet",
dashboardMonthlyApplicationsResponses: "Månedlige søknader versus svar.",
dashboardConversionFunnelTitle: "Konverteringstrakt",
dashboardTimeInStageTitle: "Median tid i fase",
dashboardTimeInStageValue: "{days}d · {count} aktive",
dashboardResponseSources: "Svar etter kilde",
dashboardTopCompaniesByActivity: "Topp selskaper etter aktivitet",
dashboardTopSkills: "Topp ferdigheter",
@@ -1682,6 +1730,12 @@ export const translations = {
jobDetailsTabFocusPlan: "Fokusplan",
jobDetailsTabInterviewPrep: "Intervjuforberedelse",
jobDetailsTabHistory: "Historikk",
statusSuggestionTitle: "Denne e-posten ser ut som en overgang til {status}",
statusSuggestionReason: "Traff \"{signal}\" · nå {current}",
statusSuggestionApply: "Flytt til {status}",
statusSuggestionDismiss: "Avvis",
statusSuggestionApplied: "Status oppdatert.",
statusSuggestionFailed: "Kunne ikke oppdatere status.",
jobDetailsTailoredCvMode: "Genereringsmodus",
jobDetailsGenerationDefault: "Balansert",
jobDetailsGenerationConcise: "Kortfattet",
@@ -1770,6 +1824,20 @@ export const translations = {
jobDetailsFollowUpSent: "Oppfølging sendt og loggført.",
jobDetailsFollowUpSendFailed: "Kunne ikke sende oppfølging.",
jobDetailsHowYouMatch: "Slik matcher du",
matchScoreTitle: "Match-score",
matchScoreLoading: "Vurderer CV-en mot denne stillingen…",
matchScoreBand_Strong: "Sterk match",
matchScoreBand_Partial: "Delvis match",
matchScoreBand_Low: "Lav match",
matchScoreBand_Unknown: "For lite grunnlag",
matchScoreKeywordsCovered: "{matched}/{total} nøkkelord",
matchScoreNoSignal: "Legg til mer CV-innhold eller en fyldigere stillingstekst for en pålitelig score.",
matchScoreDeterministicHint: "Deterministisk nøkkelorddekning — ingen AI, så scoren er stabil og repeterbar.",
matchScoreMatched: "Treff på nøkkelord",
matchScoreMissing: "Manglende nøkkelord",
matchScoreNoneYet: "Ingen treff ennå.",
matchScoreAllCovered: "Alle nøkkelord er dekket.",
matchScoreSectionCoverage: "Hvor CV-en dekker denne rollen",
jobDetailsStrategySnapshot: "Strategioversikt",
jobDetailsGenerateStrategySnapshot: "Generer strategioversikt",
jobDetailsStrategySnapshotEmpty: "Generer en oversikt for å se match, posisjonering og viktigste prioriteringer på ett sted.",
@@ -0,0 +1,113 @@
import React from 'react';
import '@testing-library/jest-dom';
import { fireEvent, render, screen } from '@testing-library/react';
import { ConfirmProvider } from './confirm';
import { PromptProvider } from './prompt';
import { ToastProvider } from './toast';
import { I18nProvider } from './i18n/I18nProvider';
import JobDetailsDialog from './components/JobDetailsDialog';
import { api } from './api';
jest.setTimeout(15000);
jest.mock('./api', () => ({
api: {
get: jest.fn(),
post: jest.fn(() => Promise.resolve({ data: {} })),
put: jest.fn(() => Promise.resolve({ data: {} })),
patch: jest.fn(() => Promise.resolve({ data: {} })),
delete: jest.fn(() => Promise.resolve({ data: {} })),
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
},
}));
const mockedApi = api as jest.Mocked<typeof api>;
const matchScore = {
score: 82,
band: 'Strong',
matchedCount: 4,
totalKeywords: 6,
matchedKeywords: ['C#', '.NET', 'SQL', 'Docker'],
missingKeywords: ['Kubernetes', 'GraphQL'],
sectionCoverage: [
{ section: 'Skills', matched: 4, total: 6 },
{ section: 'Experience', matched: 3, total: 6 },
],
hasEnoughSignal: true,
};
function renderDialog() {
return render(
<ToastProvider>
<I18nProvider>
<ConfirmProvider>
<PromptProvider>
<JobDetailsDialog open jobId={42} onClose={() => {}} initialTab={5} />
</PromptProvider>
</ConfirmProvider>
</I18nProvider>
</ToastProvider>,
);
}
beforeEach(() => {
mockedApi.get.mockImplementation((url: string) => {
if (url === '/jobapplications/42') {
return Promise.resolve({ data: { id: 42, jobTitle: 'Backend Developer', status: 'Applied', dateApplied: new Date().toISOString(), daysSince: 3, company: { name: 'Acme' } } } as any);
}
if (url === '/jobapplications/42/match-score') {
return Promise.resolve({ data: matchScore } as any);
}
if (url === '/auth/me') return Promise.resolve({ data: { roles: [] } } as any);
if (url === '/jobapplications/42/history') return Promise.resolve({ data: [] } as any);
if (url === '/attachments/42') return Promise.resolve({ data: [] } as any);
// Candidate-fit AI narrative: leave pending-ish/empty so we only assert on the fast panel.
if (url === '/jobapplications/42/candidate-fit') return Promise.resolve({ data: null } as any);
return Promise.resolve({ data: {} } as any);
});
});
afterEach(() => {
jest.clearAllMocks();
});
test('match score panel shows the score, matched and missing keywords', async () => {
renderDialog();
expect(await screen.findByText('82%')).toBeInTheDocument();
expect(await screen.findByText(/strong match/i)).toBeInTheDocument();
expect(await screen.findByText('4/6 keywords')).toBeInTheDocument();
// Matched keyword chips
expect(await screen.findByText('C#')).toBeInTheDocument();
expect(await screen.findByText('Docker')).toBeInTheDocument();
// Missing keyword chips
expect(await screen.findByText('Kubernetes')).toBeInTheDocument();
expect(await screen.findByText('GraphQL')).toBeInTheDocument();
// Section coverage
expect(await screen.findByText('Skills: 4/6')).toBeInTheDocument();
});
test('match score panel degrades gracefully when there is not enough signal', async () => {
mockedApi.get.mockImplementation((url: string) => {
if (url === '/jobapplications/42') {
return Promise.resolve({ data: { id: 42, jobTitle: 'Backend Developer', status: 'Applied', dateApplied: new Date().toISOString(), daysSince: 3, company: { name: 'Acme' } } } as any);
}
if (url === '/jobapplications/42/match-score') {
return Promise.resolve({ data: { ...matchScore, score: 0, band: 'Unknown', matchedCount: 0, matchedKeywords: [], missingKeywords: [], sectionCoverage: [], hasEnoughSignal: false } } as any);
}
if (url === '/auth/me') return Promise.resolve({ data: { roles: [] } } as any);
if (url === '/jobapplications/42/history') return Promise.resolve({ data: [] } as any);
if (url === '/attachments/42') return Promise.resolve({ data: [] } as any);
if (url === '/jobapplications/42/candidate-fit') return Promise.resolve({ data: null } as any);
return Promise.resolve({ data: {} } as any);
});
renderDialog();
expect(await screen.findByText('—')).toBeInTheDocument();
expect(await screen.findByText(/not enough signal/i)).toBeInTheDocument();
});
+237
View File
@@ -0,0 +1,237 @@
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: <DashboardIcon />, title: "Centralized pipeline", body: "Track every application across Applied, Waiting, Interview, Offer, Rejected and Ghosted — drag to update." },
{ icon: <AlarmIcon />, title: "Smart follow-ups", body: "Reminders surface what needs attention next, with a grounded draft ready to review and send." },
{ icon: <MatchIcon />, title: "Honest CV match", body: "A deterministic keyword-coverage score with matched vs missing skills — not an opaque black box." },
{ icon: <MailIcon />, title: "Email correspondence", body: "Link Gmail threads to a job; new replies appear automatically without re-importing." },
{ icon: <AttachIcon />, title: "Attachments & docs", body: "Keep resumes, cover letters and portfolios versioned per application, right where you need them." },
{ icon: <InsightsIcon />, 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." },
];
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);
// 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 (
<Box sx={{ minHeight: "100vh", display: "grid", placeItems: "center", bgcolor: BRAND_DARK }}>
<Typography sx={{ color: "#94a3b8" }}>Loading</Typography>
</Box>
);
}
const gradientText = {
background: "linear-gradient(90deg,#6366f1,#22d3ee)",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
} as const;
return (
<Box sx={{ bgcolor: "background.default" }}>
{/* Top bar */}
<Box sx={{ position: "sticky", top: 0, zIndex: 10, bgcolor: alpha(BRAND_DARK, 0.85), backdropFilter: "blur(8px)", borderBottom: `1px solid ${alpha("#ffffff", 0.08)}` }}>
<Container maxWidth="lg">
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ height: 64 }}>
<Stack direction="row" alignItems="center" spacing={1.25}>
<Box sx={{ width: 30, height: 30, borderRadius: "8px", background: "linear-gradient(135deg,#6366f1,#22d3ee)", display: "grid", placeItems: "center", color: BRAND_DARK, fontWeight: 900 }}></Box>
<Typography sx={{ color: "#fff", fontWeight: 800, fontSize: 20 }}>JobTrack</Typography>
</Stack>
<Button variant="contained" onClick={() => navigate("/login")} sx={{ background: "linear-gradient(90deg,#6366f1,#22d3ee)", color: "#0b1020", fontWeight: 700 }}>
Sign in
</Button>
</Stack>
</Container>
</Box>
{/* Hero */}
<Box sx={{ background: `radial-gradient(1200px 500px at 80% -10%, ${alpha("#6366f1", 0.35)}, transparent), linear-gradient(180deg, ${BRAND_DARK}, ${BRAND_PANEL})`, color: "#fff", py: { xs: 8, md: 12 } }}>
<Container maxWidth="lg">
<Box sx={{ maxWidth: 760 }}>
<Box sx={{ display: "inline-block", px: 1.5, py: 0.5, borderRadius: 999, bgcolor: alpha("#ffffff", 0.08), color: "#a5b4fc", fontSize: 13, fontWeight: 600, letterSpacing: 0.5, mb: 3 }}>
AI-ASSISTED JOB SEARCH WORKSPACE
</Box>
<Typography component="h1" sx={{ fontWeight: 800, fontSize: { xs: 40, md: 60 }, lineHeight: 1.05, mb: 2 }}>
Run your job search without losing <Box component="span" sx={gradientText}>the thread</Box>.
</Typography>
<Typography sx={{ color: "#94a3b8", fontSize: { xs: 17, md: 20 }, mb: 4 }}>
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.
</Typography>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<Button size="large" variant="contained" onClick={() => navigate("/login")} sx={{ background: "linear-gradient(90deg,#6366f1,#22d3ee)", color: "#0b1020", fontWeight: 800, px: 4, py: 1.25 }}>
Get started
</Button>
<Button size="large" variant="outlined" href="#features" sx={{ color: "#e2e8f0", borderColor: alpha("#ffffff", 0.25), px: 3, py: 1.25 }}>
See features
</Button>
</Stack>
<Typography sx={{ color: "#64748b", fontSize: 14, mt: 3 }}>
React · TypeScript · ASP.NET Core · EF Core · FastAPI AI · Gmail
</Typography>
</Box>
</Container>
</Box>
{/* Features */}
<Container id="features" maxWidth="lg" sx={{ py: { xs: 7, md: 10 } }}>
<Box sx={{ textAlign: "center", mb: 6 }}>
<Typography sx={{ color: "primary.main", fontWeight: 700, letterSpacing: 1, fontSize: 13 }}>WHAT IT DOES</Typography>
<Typography component="h2" sx={{ fontWeight: 800, fontSize: { xs: 28, md: 38 }, mt: 1 }}>One workspace for the whole search</Typography>
<Typography sx={{ color: "text.secondary", fontSize: 18, mt: 1.5 }}>
Everything from a single import to the final offer no more spreadsheets and scattered inboxes.
</Typography>
</Box>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr", md: "1fr 1fr 1fr" }, gap: 3 }}>
{FEATURES.map((f) => (
<Box key={f.title} sx={{ p: 3, borderRadius: 3, border: "1px solid", borderColor: "divider", bgcolor: "background.paper", transition: "box-shadow .2s, transform .2s", "&:hover": { boxShadow: 6, transform: "translateY(-2px)" } }}>
<Box sx={{ width: 48, height: 48, borderRadius: 2.5, display: "grid", placeItems: "center", bgcolor: alpha("#6366f1", 0.12), color: "primary.main", mb: 2 }}>{f.icon}</Box>
<Typography sx={{ fontWeight: 700, fontSize: 19, mb: 0.75 }}>{f.title}</Typography>
<Typography sx={{ color: "text.secondary", fontSize: 15 }}>{f.body}</Typography>
</Box>
))}
</Box>
</Container>
{/* How it works */}
<Box sx={{ background: `linear-gradient(180deg, ${BRAND_DARK}, ${BRAND_PANEL})`, color: "#fff", py: { xs: 7, md: 10 } }}>
<Container maxWidth="lg">
<Box sx={{ textAlign: "center", mb: 6 }}>
<Typography sx={{ color: "#a5b4fc", fontWeight: 700, letterSpacing: 1, fontSize: 13 }}>HOW IT WORKS</Typography>
<Typography component="h2" sx={{ fontWeight: 800, fontSize: { xs: 28, md: 38 }, mt: 1 }}>From a link to an offer</Typography>
</Box>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr", md: "1fr 1fr 1fr" }, gap: 3 }}>
{STEPS.map((s) => (
<Box key={s.n} sx={{ p: 3, borderRadius: 3, border: `1px solid ${alpha("#ffffff", 0.1)}`, bgcolor: alpha("#ffffff", 0.03) }}>
<Box sx={{ width: 40, height: 40, borderRadius: 999, display: "grid", placeItems: "center", background: "linear-gradient(135deg,#6366f1,#22d3ee)", color: "#0b1020", fontWeight: 900, mb: 1.5 }}>{s.n}</Box>
<Typography sx={{ fontWeight: 700, fontSize: 18, mb: 0.5 }}>{s.title}</Typography>
<Typography sx={{ color: "#94a3b8", fontSize: 15 }}>{s.body}</Typography>
</Box>
))}
</Box>
</Container>
</Box>
{/* Pricing */}
<Container id="pricing" maxWidth="lg" sx={{ py: { xs: 7, md: 10 } }}>
<Box sx={{ textAlign: "center", mb: 6 }}>
<Typography sx={{ color: "primary.main", fontWeight: 700, letterSpacing: 1, fontSize: 13 }}>PRICING</Typography>
<Typography component="h2" sx={{ fontWeight: 800, fontSize: { xs: 28, md: 38 }, mt: 1 }}>Honest, simple pricing</Typography>
<Typography sx={{ color: "text.secondary", fontSize: 18, mt: 1.5 }}>
Billed monthly or yearly never by the week. Cancel anytime.
</Typography>
</Box>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(3, 1fr)" }, gap: 3, alignItems: "start" }}>
{PRICING.map((tier) => (
<Box
key={tier.name}
sx={{
p: 3.5,
borderRadius: 3,
position: "relative",
bgcolor: "background.paper",
border: "2px solid",
borderColor: tier.highlight ? "primary.main" : "divider",
boxShadow: tier.highlight ? 8 : 0,
}}
>
{tier.highlight && (
<Box sx={{ position: "absolute", top: -13, left: 24, px: 1.5, py: 0.5, borderRadius: 999, background: "linear-gradient(90deg,#6366f1,#22d3ee)", color: "#0b1020", fontSize: 12, fontWeight: 800 }}>
Most popular
</Box>
)}
<Typography sx={{ fontWeight: 700, fontSize: 18 }}>{tier.name}</Typography>
<Stack direction="row" alignItems="baseline" spacing={0.75} sx={{ my: 1.5 }}>
<Typography sx={{ fontWeight: 900, fontSize: 40, lineHeight: 1 }}>{tier.price}</Typography>
<Typography sx={{ color: "text.secondary", fontSize: 14 }}>{tier.cadence}</Typography>
</Stack>
<Stack spacing={1.25} sx={{ my: 2.5 }}>
{tier.features.map((f) => (
<Stack key={f} direction="row" spacing={1.25} alignItems="flex-start">
<Box sx={{ color: "success.main", fontWeight: 900, lineHeight: 1.4 }}></Box>
<Typography sx={{ fontSize: 15, color: "text.secondary" }}>{f}</Typography>
</Stack>
))}
</Stack>
<Button
fullWidth
variant={tier.highlight ? "contained" : "outlined"}
onClick={() => navigate("/login")}
sx={tier.highlight ? { background: "linear-gradient(90deg,#6366f1,#22d3ee)", color: "#0b1020", fontWeight: 800 } : { fontWeight: 700 }}
>
{tier.cta}
</Button>
</Box>
))}
</Box>
<Typography sx={{ textAlign: "center", color: "text.secondary", fontSize: 13, mt: 3 }}>
Prices indicative assistive, never autonomous: you always review and send. No auto-apply spam.
</Typography>
</Container>
{/* CTA */}
<Container maxWidth="lg" sx={{ py: { xs: 7, md: 10 } }}>
<Box sx={{ borderRadius: 4, p: { xs: 4, md: 6 }, background: "linear-gradient(120deg,#0f172a,#1e293b)", color: "#fff", display: "flex", flexDirection: { xs: "column", md: "row" }, alignItems: { md: "center" }, justifyContent: "space-between", gap: 3 }}>
<Box>
<Typography sx={{ fontWeight: 800, fontSize: { xs: 24, md: 30 }, mb: 1 }}>Ready to organize your search?</Typography>
<Typography sx={{ color: "#94a3b8", fontSize: 17 }}>Sign in to start tracking applications, tailoring CVs, and following up with intent.</Typography>
</Box>
<Button size="large" variant="contained" onClick={() => navigate("/login")} sx={{ background: "linear-gradient(90deg,#6366f1,#22d3ee)", color: "#0b1020", fontWeight: 800, px: 4, py: 1.25, whiteSpace: "nowrap" }}>
Sign in
</Button>
</Box>
</Container>
{/* Footer */}
<Box sx={{ borderTop: "1px solid", borderColor: "divider", py: 4 }}>
<Container maxWidth="lg">
<Stack direction={{ xs: "column", sm: "row" }} justifyContent="space-between" alignItems="center" spacing={1}>
<Typography sx={{ color: "text.secondary", fontSize: 14 }}>© {new Date().getFullYear()} JobTrack a focused workspace for the modern job search.</Typography>
<Button variant="text" onClick={() => navigate("/login")} sx={{ fontWeight: 700 }}>Sign in</Button>
</Stack>
</Container>
</Box>
</Box>
);
}
+14 -2
View File
@@ -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<PdfCarouselItem[]>([]);
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);
+37
View File
@@ -0,0 +1,37 @@
import { normalizeStatus, statusTone, statusLabel, PIPELINE_STATUSES } from './pipeline';
describe('pipeline', () => {
test('normalizeStatus canonicalizes casing and synonyms', () => {
expect(normalizeStatus('applied')).toBe('Applied');
expect(normalizeStatus(' OFFER ')).toBe('Offer');
expect(normalizeStatus('Interviewing')).toBe('Interview');
expect(normalizeStatus('declined')).toBe('Rejected');
});
test('normalizeStatus preserves unknown as Other and empty as Applied', () => {
expect(normalizeStatus('Take-home')).toBe('Other');
expect(normalizeStatus('')).toBe('Applied');
expect(normalizeStatus(null)).toBe('Applied');
});
test('statusTone maps stages to palette keys', () => {
expect(statusTone('Offer')).toBe('success');
expect(statusTone('Rejected')).toBe('error');
expect(statusTone('Waiting')).toBe('warning');
expect(statusTone('Ghosted')).toBe('warning');
expect(statusTone('Interview')).toBe('info');
expect(statusTone('Applied')).toBe('primary');
expect(statusTone('Take-home')).toBe('default');
});
test('statusLabel localizes canonical and passes through custom', () => {
const t = (key: string) => ({ statusApplied: 'Applied', statusOffer: 'Offer' } as Record<string, string>)[key] ?? key;
expect(statusLabel(t, 'Applied')).toBe('Applied');
expect(statusLabel(t, 'Interviewing')).toBe('statusInterview'); // maps to canonical key
expect(statusLabel(t, 'Take-home assignment')).toBe('Take-home assignment');
});
test('canonical stage list is stable and ordered', () => {
expect(PIPELINE_STATUSES).toEqual(['Applied', 'Waiting', 'Interview', 'Offer', 'Rejected', 'Ghosted']);
});
});
+64
View File
@@ -0,0 +1,64 @@
// Single frontend source of truth for the canonical job pipeline.
// Mirrors the backend JobPipeline (JobTrackerApi/Services/JobPipeline.cs); keep the two in sync.
export const PIPELINE_STATUSES = ["Applied", "Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const;
export type PipelineStatus = (typeof PIPELINE_STATUSES)[number];
export type StatusTone = "primary" | "info" | "success" | "warning" | "error" | "default";
// Legacy/synonym spellings collapse onto a canonical stage (matches the backend alias map).
const ALIASES: Record<string, PipelineStatus> = {
interviewing: "Interview",
interviews: "Interview",
interviewed: "Interview",
declined: "Rejected",
"no response": "Ghosted",
"no reply": "Ghosted",
pending: "Waiting",
"awaiting response": "Waiting",
};
/** Canonical status for a raw value, or "Other" for unknown/custom statuses. */
export function normalizeStatus(status?: string | null): PipelineStatus | "Other" {
const trimmed = (status ?? "").trim();
if (!trimmed) return "Applied";
const exact = PIPELINE_STATUSES.find((s) => s.toLowerCase() === trimmed.toLowerCase());
if (exact) return exact;
const alias = ALIASES[trimmed.toLowerCase()];
return alias ?? "Other";
}
/** MUI palette key for a status; both chip color and board accent derive from this. */
export function statusTone(status?: string | null): StatusTone {
switch (normalizeStatus(status)) {
case "Offer":
return "success";
case "Rejected":
return "error";
case "Waiting":
case "Ghosted":
return "warning";
case "Interview":
return "info";
case "Applied":
return "primary";
default:
return "default";
}
}
const LABEL_KEYS: Record<PipelineStatus, string> = {
Applied: "statusApplied",
Waiting: "statusWaiting",
Interview: "statusInterview",
Offer: "statusOffer",
Rejected: "statusRejected",
Ghosted: "statusGhosted",
};
/** Localized label for a status, falling back to the raw value for custom statuses. */
export function statusLabel(t: (key: any, params?: any) => string, status: string): string {
const normalized = normalizeStatus(status);
return normalized === "Other" ? status : t(LABEL_KEYS[normalized]);
}
+70
View File
@@ -0,0 +1,70 @@
import React from 'react';
import '@testing-library/jest-dom';
import { render, screen, waitFor } from '@testing-library/react';
import { ToastProvider } from './toast';
import { I18nProvider } from './i18n/I18nProvider';
import { api } from './api';
// Avoid pulling the date-fns v4 ESM adapter into Jest; the picker isn't under test here.
jest.mock('@mui/x-date-pickers/DatePicker', () => ({
DatePicker: ({ label }: any) => <div>{label}</div>,
}));
// eslint-disable-next-line import/first
import AddJobModal from './components/AddJobModal';
jest.setTimeout(15000);
jest.mock('./api', () => ({
api: {
get: jest.fn(() => Promise.resolve({ data: [] })),
post: jest.fn(() => Promise.resolve({ data: {} })),
put: jest.fn(() => Promise.resolve({ data: {} })),
patch: jest.fn(() => Promise.resolve({ data: {} })),
delete: jest.fn(() => Promise.resolve({ data: {} })),
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
},
getApiErrorMessage: jest.fn(() => 'error'),
}));
const mockedApi = api as jest.Mocked<typeof api>;
function renderModal(initialUrl?: string) {
return render(
<ToastProvider>
<I18nProvider>
<AddJobModal open initialUrl={initialUrl} onClose={() => {}} onCreated={() => {}} />
</I18nProvider>
</ToastProvider>,
);
}
beforeEach(() => {
mockedApi.get.mockResolvedValue({ data: [] } as any);
mockedApi.post.mockImplementation((url: string) => {
if (url === '/jobimport/preview') {
return Promise.resolve({ data: { success: true, title: 'Imported Backend Role', company: 'Acme', location: 'Oslo', description: 'desc', tags: ['C#'] } } as any);
}
return Promise.resolve({ data: {} } as any);
});
});
afterEach(() => jest.clearAllMocks());
test('auto-imports from initialUrl and prefills the form', async () => {
renderModal('https://example.com/jobs/123');
await waitFor(() => {
expect(mockedApi.post).toHaveBeenCalledWith('/jobimport/preview', { url: 'https://example.com/jobs/123' });
});
expect(await screen.findByDisplayValue('Imported Backend Role')).toBeInTheDocument();
});
test('does not auto-import when no initialUrl is given', async () => {
renderModal(undefined);
// Wait for the modal to render, then confirm no import was triggered.
expect(await screen.findByRole('dialog')).toBeInTheDocument();
expect(mockedApi.post).not.toHaveBeenCalledWith('/jobimport/preview', expect.anything());
});
+21
View File
@@ -0,0 +1,21 @@
import { JobApplication } from "./types";
type SalaryFields = Pick<JobApplication, "salary" | "salaryMin" | "salaryMax" | "salaryCurrency" | "salaryPeriod">;
const PERIOD_SUFFIX: Record<string, string> = { year: "yr", month: "mo", hour: "hr" };
/** Structured salary when present ("60 00070 000 NOK/yr"), otherwise the free-text field. */
export function formatSalary(job: SalaryFields): string | null {
const { salaryMin, salaryMax, salaryCurrency, salaryPeriod } = job;
if (salaryMin == null && salaryMax == null) {
return job.salary?.trim() || null;
}
const fmt = (value: number) => value.toLocaleString();
const range = salaryMin != null && salaryMax != null && salaryMin !== salaryMax
? `${fmt(salaryMin)}${fmt(salaryMax)}`
: fmt((salaryMin ?? salaryMax) as number);
const currency = salaryCurrency ? ` ${salaryCurrency}` : "";
const period = salaryPeriod ? `/${PERIOD_SUFFIX[salaryPeriod] ?? salaryPeriod}` : "";
return `${range}${currency}${period}`;
}
+7
View File
@@ -1,4 +1,11 @@
import React from 'react';
import { configure } from '@testing-library/react';
// Heavy MUI views (job table, workspace dialog, profile page) can exceed the
// 1s default async query timeout on slower machines; findBy*/waitFor assertions
// still resolve as soon as the element appears.
configure({ asyncUtilTimeout: 4000 });
jest.setTimeout(30000);
jest.mock('./api', () => ({
api: {
@@ -0,0 +1,90 @@
import React from 'react';
import '@testing-library/jest-dom';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { ConfirmProvider } from './confirm';
import { PromptProvider } from './prompt';
import { ToastProvider } from './toast';
import { I18nProvider } from './i18n/I18nProvider';
import JobDetailsDialog from './components/JobDetailsDialog';
import { api } from './api';
jest.setTimeout(15000);
jest.mock('./api', () => ({
api: {
get: jest.fn(),
post: jest.fn(() => Promise.resolve({ data: {} })),
put: jest.fn(() => Promise.resolve({ data: {} })),
patch: jest.fn(() => Promise.resolve({ data: {} })),
delete: jest.fn(() => Promise.resolve({ data: {} })),
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
},
getApiErrorMessage: jest.fn(() => 'error'),
}));
const mockedApi = api as jest.Mocked<typeof api>;
function renderDialog() {
return render(
<ToastProvider>
<I18nProvider>
<ConfirmProvider>
<PromptProvider>
<JobDetailsDialog open jobId={42} onClose={() => {}} />
</PromptProvider>
</ConfirmProvider>
</I18nProvider>
</ToastProvider>,
);
}
beforeEach(() => {
mockedApi.get.mockImplementation((url: string) => {
if (url === '/jobapplications/42') {
return Promise.resolve({ data: { id: 42, jobTitle: 'Backend Developer', status: 'Applied', dateApplied: new Date().toISOString(), daysSince: 3, company: { name: 'Acme' } } } as any);
}
if (url === '/jobapplications/42/status-suggestion') {
return Promise.resolve({ data: { hasSuggestion: true, suggestedStatus: 'Interview', currentStatus: 'Applied', signal: 'schedule an interview', confidence: 'medium' } } as any);
}
if (url === '/auth/me') return Promise.resolve({ data: { roles: [] } } as any);
if (url === '/jobapplications/42/history') return Promise.resolve({ data: [] } as any);
if (url === '/attachments/42') return Promise.resolve({ data: [] } as any);
return Promise.resolve({ data: {} } as any);
});
});
afterEach(() => {
jest.clearAllMocks();
});
test('status suggestion banner appears and applies via PATCH', async () => {
renderDialog();
expect(await screen.findByText(/looks like a move to interview/i)).toBeInTheDocument();
fireEvent.click(await screen.findByRole('button', { name: /move to interview/i }));
await waitFor(() => {
expect(mockedApi.patch).toHaveBeenCalledWith('/jobapplications/42/status', { status: 'Interview' });
});
});
test('no banner when there is no suggestion', async () => {
mockedApi.get.mockImplementation((url: string) => {
if (url === '/jobapplications/42') {
return Promise.resolve({ data: { id: 42, jobTitle: 'Backend Developer', status: 'Applied', dateApplied: new Date().toISOString(), daysSince: 3, company: { name: 'Acme' } } } as any);
}
if (url === '/jobapplications/42/status-suggestion') {
return Promise.resolve({ data: { hasSuggestion: false } } as any);
}
if (url === '/auth/me') return Promise.resolve({ data: { roles: [] } } as any);
if (url === '/jobapplications/42/history') return Promise.resolve({ data: [] } as any);
if (url === '/attachments/42') return Promise.resolve({ data: [] } as any);
return Promise.resolve({ data: {} } as any);
});
renderDialog();
expect(await screen.findByText(/backend developer/i)).toBeInTheDocument();
expect(screen.queryByText(/looks like a move to/i)).not.toBeInTheDocument();
});
+8 -6
View File
@@ -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;
+1 -1
View File
@@ -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) {
+31
View File
@@ -89,6 +89,10 @@ export interface JobApplication {
dateApplied: string;
location?: string;
salary?: string;
salaryMin?: number | null;
salaryMax?: number | null;
salaryCurrency?: string | null;
salaryPeriod?: string | null;
nextAction?: string;
followUpAt?: string;
feedbackRequestedAt?: string;
@@ -128,6 +132,33 @@ export interface CandidateFitChannelGuidance {
recruiterMessage: string[];
}
export interface MatchScoreSectionCoverage {
section: string;
matched: number;
total: number;
}
export interface StatusSuggestion {
hasSuggestion: boolean;
suggestedStatus?: string | null;
currentStatus?: string | null;
signal?: string | null;
confidence?: string | null;
messageDate?: string | null;
messageSubject?: string | null;
}
export interface MatchScore {
score: number;
band: string;
matchedCount: number;
totalKeywords: number;
matchedKeywords: string[];
missingKeywords: string[];
sectionCoverage: MatchScoreSectionCoverage[];
hasEnoughSignal: boolean;
}
export interface CandidateFit {
matchSummary: string;
fitLevel: string;
@@ -1,16 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<key id="b3ca4672-1056-4ac2-ba47-0432608a4115" version="1">
<creationDate>2026-03-27T07:52:25.0540436Z</creationDate>
<activationDate>2026-03-27T07:52:25.0540436Z</activationDate>
<expirationDate>2026-06-25T07:52:25.0540436Z</expirationDate>
<descriptor deserializerType="Microsoft.AspNetCore.DataProtection.AuthenticatedEncryption.ConfigurationModel.AuthenticatedEncryptorDescriptorDeserializer, Microsoft.AspNetCore.DataProtection, Version=9.0.0.0, Culture=neutral, PublicKeyToken=adb9793829ddae60">
<descriptor>
<encryption algorithm="AES_256_CBC" />
<validation algorithm="HMACSHA256" />
<masterKey p4:requiresEncryption="true" xmlns:p4="http://schemas.asp.net/2015/03/dataProtection">
<!-- Warning: the key below is in an unencrypted form. -->
<value>mfglwuKFrMSiWcbTVDEbPYM0eGAqlsOMHe89hNOsZUguUMMiusdx3m3ZQJvxnBCxeXte6OS+zvpZl3tIizvgHg==</value>
</masterKey>
</descriptor>
</descriptor>
</key>
+100
View File
@@ -0,0 +1,100 @@
# PowerShell equivalent of start-ollama-cv.sh
# Starts Ollama service, pulls model if needed, waits, then restarts AI service
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# Change to the parent directory of scripts
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
Set-Location (Join-Path $scriptDir '..')
$MODEL = if ($env:OLLAMA_MODEL) { $env:OLLAMA_MODEL } else { 'qwen2.5:7b' }
$OLLAMA_WAIT_SECONDS = if ($env:OLLAMA_WAIT_SECONDS) { [int]$env:OLLAMA_WAIT_SECONDS } else { 180 }
$PULL_WAIT_SECONDS = if ($env:OLLAMA_PULL_WAIT_SECONDS) { [int]$env:OLLAMA_PULL_WAIT_SECONDS } else { 1800 }
function compose {
docker compose @args
}
function wait_for_ollama {
$deadline = (Get-Date).AddSeconds($OLLAMA_WAIT_SECONDS)
while ((Get-Date) -lt $deadline) {
try {
compose exec -T ollama ollama list | Out-Null
return $true
} catch {
# Ignore errors, just wait
}
Start-Sleep -Seconds 3
}
return $false
}
function model_present {
try {
$models = compose exec -T ollama ollama list 2>$null | Select-Object -Skip 1 | ForEach-Object { $_.Split()[0] }
return $models -contains $MODEL
} catch {
return $false
}
}
function wait_for_model {
$deadline = (Get-Date).AddSeconds($PULL_WAIT_SECONDS)
while ((Get-Date) -lt $deadline) {
if (model_present) {
return $true
}
Start-Sleep -Seconds 5
}
return $false
}
Write-Host "Starting Ollama service..."
compose up -d ollama
if (-not (wait_for_ollama)) {
Write-Host "Ollama did not become ready within ${OLLAMA_WAIT_SECONDS}s."
try { compose logs --tail=200 ollama } catch { }
exit 1
}
Write-Host "Ollama is responding."
if (model_present) {
Write-Host "Model already present: $MODEL"
} else {
Write-Host "Pulling Ollama model: $MODEL"
try {
compose exec -T ollama ollama pull $MODEL
} catch {
Write-Host "Model pull command failed."
try { compose logs --tail=200 ollama } catch { }
exit 1
}
}
if (-not (wait_for_model)) {
Write-Host "Model ${MODEL} did not appear within ${PULL_WAIT_SECONDS}s."
try { compose exec -T ollama ollama list } catch { }
exit 1
}
Write-Host "Ollama model ready: $MODEL"
Write-Host "Restarting AI service so it can use the ready Ollama model."
compose up -d ai-service
try {
$state = compose ps ai-service --format '{{.State}}' 2>$null | Select-Object -First 1 | ForEach-Object { $_.ToLower().Trim() }
if ($state -ne 'running') {
Write-Host "AI service is not running after Ollama warmup."
try { compose logs --tail=200 ai-service } catch { }
exit 1
}
} catch {
Write-Host "Failed to check AI service status."
exit 1
}
Write-Host "Ollama warmup complete."
+105 -48
View File
@@ -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
+104
View File
@@ -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