Compare commits

...

35 Commits

Author SHA1 Message Date
cesnimda fc356012e6 ci: install .NET via retrying dotnet-install.sh instead of setup-dotnet
CI and Deploy / test (pull_request) Successful in 2m5s
CI and Deploy / deploy (pull_request) Has been skipped
The single self-hosted act_runner intermittently fails actions/setup-dotnet:
a partial extraction sticks in the shared tool-cache (tar: Cannot open: File
exists) or the SDK tarball download corrupts. Install into a clean private
$HOME/.dotnet via dotnet-install.sh with a rm -rf + retry-once, matching the
npm ci and NuGet publish retries already in this pipeline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 13:02:37 +02:00
cesnimda e90835b51e Merge pull request 'fix(deploy): retry publish after clearing NuGet caches on NU3008' (#5) from fix/deploy-nuget-integrity into main
CI and Deploy / test (push) Successful in 2m10s
CI and Deploy / deploy (push) Successful in 2m46s
2026-07-06 01:12:06 +02:00
cesnimda 4b38f7c164 ci: retry npm ci once on the runner's intermittent SIGSEGV
CI and Deploy / test (pull_request) Successful in 2m12s
CI and Deploy / deploy (pull_request) Has been skipped
The frontend deps step occasionally crashes with "Segmentation fault (core
dumped)" (exit 139) during `npm ci` — a memory/native flake on the act_runner,
unrelated to the change under test (it failed the deploy-fix PR whose only change
is the Dockerfile). Retry once with a clean node_modules before failing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 01:08:00 +02:00
cesnimda 63e0300788 fix(deploy): retry publish after clearing NuGet caches on NU3008
CI and Deploy / test (pull_request) Failing after 2m9s
CI and Deploy / deploy (pull_request) Has been skipped
The prod deploy failed restoring a transitive package
(Microsoft.CodeAnalysis.Workspaces.Common) with NU3008 "package integrity check
failed / has changed since it was signed" — a transient corrupted download on the
build host, not a code change. Wrap the backend `dotnet publish` so that on any
failure it clears all NuGet caches and retries once, re-downloading the package
fresh.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 21:51:29 +02:00
cesnimda 1badff1437 Merge pull request 'perf(gmail): batch CreateSuggestedJob N+1 + landing product preview' (#4) from feat/email-provider-migration into main
CI and Deploy / test (push) Successful in 2m6s
CI and Deploy / deploy (push) Failing after 1m6s
2026-07-05 21:34:34 +02:00
cesnimda aa19edbc49 feat(ui): add product preview (mockups) to landing page
CI and Deploy / test (pull_request) Successful in 2m11s
CI and Deploy / deploy (pull_request) Has been skipped
"See it in action" section showing the dashboard, pipeline board and per-job
workspace. Uses the design-mockup SVGs (small + crisp, ~27KB total) served from
public/mockups/, honestly captioned "Interface preview". Verified live: all three
render at "/".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 21:23:51 +02:00
cesnimda 96b9489d49 perf(gmail): batch the duplicate-message check in CreateSuggestedJob
CreateSuggestedJob ran one AnyAsync per message in the thread to decide
imported-vs-skip — an N+1 that scales with thread length. Replace it with a
single query that loads the already-imported ExternalMessageIds for the job,
then check in memory (identical skip/import behaviour), mirroring the batched
pattern RelinkThread already uses.

Build clean; backend suite 135/135 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 21:21:36 +02:00
cesnimda af420a7ad1 Merge pull request 'perf(analytics): project minimal columns in GetStats/GetAnalyticsOverview' (#3) from perf/wave1-perf into main
CI and Deploy / test (push) Successful in 2m14s
CI and Deploy / deploy (push) Failing after 1m0s
2026-07-05 21:18:55 +02:00
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
49 changed files with 2443 additions and 223 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=
+22 -5
View File
@@ -13,10 +13,22 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '9.0.x'
- name: Setup .NET (resilient)
shell: bash
# actions/setup-dotnet on this single self-hosted runner intermittently
# leaves a partial extraction in the shared tool-cache ("tar: Cannot open:
# File exists") or corrupts the SDK download. Install into a clean private
# dir via dotnet-install.sh and retry once on failure, mirroring the
# npm ci / NuGet retries elsewhere in this workflow.
run: |
install() {
curl -fsSL https://dot.net/v1/dotnet-install.sh -o /tmp/dotnet-install.sh
rm -rf "$HOME/.dotnet"
bash /tmp/dotnet-install.sh --channel 9.0 --install-dir "$HOME/.dotnet"
}
install || ( echo "dotnet install failed ($?) — retrying once..." && install )
echo "$HOME/.dotnet" >> "$GITHUB_PATH"
"$HOME/.dotnet/dotnet" --info
- name: Setup Node
uses: actions/setup-node@v4
@@ -39,7 +51,12 @@ jobs:
run: |
node -v
npm -v
npm ci --no-audit --no-fund
# npm ci occasionally segfaults on the runner (SIGSEGV/139, a memory/native
# flake). Retry once with a clean node_modules before failing the job.
npm ci --no-audit --no-fund \
|| ( echo "npm ci failed ($?) — cleaning node_modules and retrying once..." \
&& rm -rf node_modules \
&& npm ci --no-audit --no-fund )
- name: Test frontend
working-directory: job-tracker-ui
+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,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,68 @@ 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()
{
@@ -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));
}
+7 -2
View File
@@ -641,12 +641,17 @@ public sealed class GmailController : ControllerBase
var threadMessages = await _gmail.ListThreadMessagesAsync(ownerUserId, request.ThreadId.Trim(), cancellationToken);
var distinctMessageIds = threadMessages.Select(message => message.Id).Where(static id => !string.IsNullOrWhiteSpace(id)).Distinct(StringComparer.Ordinal).ToList();
// Batch the "already imported?" check with a single query instead of one
// AnyAsync per message (N+1), mirroring RelinkThread below.
var existingMessageIds = await _db.Correspondences
.Where(message => message.JobApplicationId == job.Id && message.ExternalMessageId != null && distinctMessageIds.Contains(message.ExternalMessageId))
.Select(message => message.ExternalMessageId!)
.ToListAsync(cancellationToken);
var imported = 0;
var skipped = 0;
foreach (var messageId in distinctMessageIds)
{
var existing = await _db.Correspondences.AnyAsync(message => message.JobApplicationId == job.Id && message.ExternalMessageId == messageId, cancellationToken);
if (existing)
if (existingMessageIds.Contains(messageId, StringComparer.Ordinal))
{
skipped++;
continue;
@@ -23,9 +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, IJobCvMatchService? matchService = 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;
@@ -34,6 +35,7 @@ namespace JobTrackerApi.Controllers
_logger = logger;
_cvTemplateRenderer = cvTemplateRenderer ?? new CvTemplateRenderer();
_cvPdfExporter = cvPdfExporter ?? new ThrowingCvPdfExporter();
_analytics = analytics ?? new AnalyticsService(db);
_matchService = matchService ?? new JobCvMatchService();
}
@@ -1405,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.");
@@ -1604,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)
@@ -1782,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")]
@@ -2018,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);
@@ -2796,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(
+8 -1
View File
@@ -9,7 +9,14 @@ COPY Models/ Models/
COPY JobTrackerApi/ JobTrackerApi/
COPY JobTrackerBackend/ JobTrackerBackend/
RUN dotnet publish JobTrackerApi/JobTrackerApi.csproj -c Release -o /app/publish /p:UseAppHost=false
# Retry once after clearing NuGet caches. Transient download corruption on the
# build host can trip NU3008 ("package integrity check failed / has changed since
# it was signed") while restoring a transitive package; clearing the caches and
# re-downloading resolves it.
RUN dotnet publish JobTrackerApi/JobTrackerApi.csproj -c Release -o /app/publish /p:UseAppHost=false \
|| ( echo "Publish failed — clearing NuGet caches and retrying once..." \
&& dotnet nuget locals all --clear \
&& dotnet publish JobTrackerApi/JobTrackerApi.csproj -c Release -o /app/publish /p:UseAppHost=false )
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS runtime
+5
View File
@@ -156,6 +156,7 @@ 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>();
@@ -165,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;
+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,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;
}
}
}
+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);
}
}
}
@@ -505,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);
}
@@ -829,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();
+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
);
}
+4
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
@@ -205,6 +207,8 @@ Authentication:
- 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.
+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).
+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).
+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"
}
}
}
@@ -0,0 +1,82 @@
<svg width="1440" height="900" viewBox="0 0 1440 900" xmlns="http://www.w3.org/2000/svg" font-family="Inter, 'Segoe UI', system-ui, sans-serif">
<defs>
<linearGradient id="side" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#0f172a"/><stop offset="1" stop-color="#111a33"/></linearGradient>
<linearGradient id="ac" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#6366f1"/><stop offset="1" stop-color="#22d3ee"/></linearGradient>
<filter id="sh" x="-20%" y="-20%" width="140%" height="140%"><feDropShadow dx="0" dy="8" stdDeviation="14" flood-color="#0f172a" flood-opacity="0.10"/></filter>
</defs>
<rect width="1440" height="900" fill="#f4f6fb"/>
<!-- sidebar -->
<rect width="248" height="900" fill="url(#side)"/>
<g transform="translate(28,40)">
<rect x="0" y="0" width="32" height="32" rx="8" fill="url(#ac)"/><path d="M8 16 l5 5 l10 -11" stroke="#0b1020" stroke-width="3.2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
<text x="44" y="22" fill="#fff" font-size="20" font-weight="700">JobTrack</text>
</g>
<g transform="translate(20,120)" font-size="15">
<rect x="0" y="0" width="208" height="44" rx="10" fill="#6366f1" opacity="0.18"/><rect x="18" y="16" width="12" height="12" rx="3" fill="#a5b4fc"/><text x="42" y="28" fill="#c7d2fe" font-weight="600">Dashboard</text>
<text x="20" y="88" fill="#94a3b8">Applications</text>
<text x="20" y="140" fill="#94a3b8">Pipeline</text>
<text x="20" y="192" fill="#94a3b8">Reminders</text>
<text x="20" y="244" fill="#94a3b8">Correspondence</text>
<text x="20" y="296" fill="#94a3b8">Companies</text>
<text x="20" y="348" fill="#94a3b8">Profile &amp; CV</text>
</g>
<g transform="translate(20,820)"><rect width="208" height="52" rx="10" fill="#ffffff" opacity="0.06"/><circle cx="30" cy="26" r="15" fill="#6366f1"/><text x="30" y="31" fill="#fff" font-size="13" text-anchor="middle" font-weight="700">DC</text><text x="56" y="23" fill="#e2e8f0" font-size="13">dj@cesnimda.co.uk</text><text x="56" y="40" fill="#64748b" font-size="11">Personal workspace</text></g>
<!-- header -->
<g transform="translate(288,44)">
<text x="0" y="26" fill="#0f172a" font-size="28" font-weight="800">Dashboard</text>
<text x="0" y="52" fill="#64748b" font-size="15">Your job search at a glance — 34 active applications</text>
<rect x="740" y="6" width="180" height="42" rx="10" fill="#fff" filter="url(#sh)"/><text x="762" y="32" fill="#64748b" font-size="14">Last 30 days ▾</text>
<rect x="936" y="6" width="168" height="42" rx="10" fill="#0f172a"/><text x="1020" y="32" fill="#fff" font-size="14" text-anchor="middle" font-weight="600">+ Add job</text>
</g>
<!-- KPI row -->
<g transform="translate(288,120)">
<g filter="url(#sh)"><rect width="266" height="112" rx="14" fill="#fff"/><text x="22" y="36" fill="#64748b" font-size="14">Active applications</text><text x="22" y="80" fill="#0f172a" font-size="38" font-weight="800">34</text><text x="150" y="80" fill="#16a34a" font-size="14">▲ 8 this week</text></g>
<g transform="translate(282,0)" filter="url(#sh)"><rect width="266" height="112" rx="14" fill="#fff"/><text x="22" y="36" fill="#64748b" font-size="14">Response rate</text><text x="22" y="80" fill="#0f172a" font-size="38" font-weight="800">28%</text><text x="150" y="80" fill="#16a34a" font-size="14">▲ 4%</text></g>
<g transform="translate(564,0)" filter="url(#sh)"><rect width="266" height="112" rx="14" fill="#fff"/><text x="22" y="36" fill="#64748b" font-size="14">Interviews</text><text x="22" y="80" fill="#0f172a" font-size="38" font-weight="800">6</text><text x="150" y="80" fill="#64748b" font-size="14">2 upcoming</text></g>
<g transform="translate(846,0)" filter="url(#sh)"><rect width="270" height="112" rx="14" fill="#fff"/><text x="22" y="36" fill="#64748b" font-size="14">Needs follow-up</text><text x="22" y="80" fill="#dc2626" font-size="38" font-weight="800">3</text><text x="150" y="80" fill="#dc2626" font-size="14">overdue</text></g>
</g>
<!-- response trend chart -->
<g transform="translate(288,256)" filter="url(#sh)">
<rect width="700" height="300" rx="14" fill="#fff"/>
<text x="24" y="40" fill="#0f172a" font-size="17" font-weight="700">Applications &amp; responses</text>
<g stroke="#eef2f7" stroke-width="1">
<line x1="24" y1="90" x2="676" y2="90"/><line x1="24" y1="150" x2="676" y2="150"/><line x1="24" y1="210" x2="676" y2="210"/><line x1="24" y1="255" x2="676" y2="255"/>
</g>
<polyline points="40,240 140,200 240,210 340,150 440,160 540,110 640,120" fill="none" stroke="#6366f1" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="40,252 140,244 240,238 340,224 440,214 540,196 640,182" fill="none" stroke="#22c55e" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"/>
<g fill="#6366f1"><circle cx="340" cy="150" r="4.5"/><circle cx="540" cy="110" r="4.5"/></g>
<g font-size="12" fill="#94a3b8"><text x="34" y="278">Wk1</text><text x="134" y="278">Wk2</text><text x="234" y="278">Wk3</text><text x="334" y="278">Wk4</text><text x="434" y="278">Wk5</text><text x="534" y="278">Wk6</text><text x="628" y="278">Wk7</text></g>
<g font-size="12"><rect x="500" y="16" width="12" height="12" rx="3" fill="#6366f1"/><text x="518" y="26" fill="#64748b">Applied</text><rect x="590" y="16" width="12" height="12" rx="3" fill="#22c55e"/><text x="608" y="26" fill="#64748b">Responses</text></g>
</g>
<!-- time in stage -->
<g transform="translate(1004,256)" filter="url(#sh)">
<rect width="404" height="300" rx="14" fill="#fff"/>
<text x="24" y="40" fill="#0f172a" font-size="17" font-weight="700">Median time in stage</text>
<g font-size="13" fill="#334155">
<text x="24" y="82">Applied → Waiting</text><rect x="24" y="92" width="356" height="10" rx="5" fill="#eef2f7"/><rect x="24" y="92" width="120" height="10" rx="5" fill="#6366f1"/><text x="352" y="86" fill="#64748b">2d</text>
<text x="24" y="132">Waiting → Interview</text><rect x="24" y="142" width="356" height="10" rx="5" fill="#eef2f7"/><rect x="24" y="142" width="300" height="10" rx="5" fill="#818cf8"/><text x="348" y="136" fill="#64748b">9d</text>
<text x="24" y="182">Interview → Offer</text><rect x="24" y="192" width="356" height="10" rx="5" fill="#eef2f7"/><rect x="24" y="192" width="200" height="10" rx="5" fill="#22d3ee"/><text x="348" y="186" fill="#64748b">6d</text>
</g>
<text x="24" y="240" fill="#0f172a" font-size="15" font-weight="700">Top skill demand</text>
<g font-size="12"><rect x="24" y="252" width="70" height="26" rx="13" fill="#eef2ff"/><text x="59" y="269" fill="#4338ca" text-anchor="middle">React ·18</text>
<rect x="102" y="252" width="70" height="26" rx="13" fill="#eef2ff"/><text x="137" y="269" fill="#4338ca" text-anchor="middle">Azure ·12</text>
<rect x="180" y="252" width="86" height="26" rx="13" fill="#eef2ff"/><text x="223" y="269" fill="#4338ca" text-anchor="middle">Docker ·10</text>
<rect x="274" y="252" width="64" height="26" rx="13" fill="#eef2ff"/><text x="306" y="269" fill="#4338ca" text-anchor="middle">SQL ·9</text></g>
</g>
<!-- reminders strip -->
<g transform="translate(288,580)" filter="url(#sh)">
<rect width="1120" height="278" rx="14" fill="#fff"/>
<text x="24" y="40" fill="#0f172a" font-size="17" font-weight="700">Needs your attention</text>
<g transform="translate(24,60)">
<g><rect width="1072" height="60" rx="10" fill="#fef2f2"/><circle cx="28" cy="30" r="6" fill="#dc2626"/><text x="52" y="27" fill="#0f172a" font-size="15" font-weight="600">Senior Frontend Engineer · Vercel</text><text x="52" y="46" fill="#64748b" font-size="13">No reply in 9 days — follow-up drafted from your last thread</text><rect x="900" y="16" width="150" height="30" rx="8" fill="#0f172a"/><text x="975" y="36" fill="#fff" font-size="13" text-anchor="middle">Review draft</text></g>
<g transform="translate(0,72)"><rect width="1072" height="60" rx="10" fill="#fffbeb"/><circle cx="28" cy="30" r="6" fill="#f59e0b"/><text x="52" y="27" fill="#0f172a" font-size="15" font-weight="600">Platform Engineer · Finn.no</text><text x="52" y="46" fill="#64748b" font-size="13">Interview tomorrow 14:00 — prep pack ready</text><rect x="900" y="16" width="150" height="30" rx="8" fill="#eef2ff"/><text x="975" y="36" fill="#4338ca" font-size="13" text-anchor="middle">Open workspace</text></g>
<g transform="translate(0,144)"><rect width="1072" height="60" rx="10" fill="#f0fdf4"/><circle cx="28" cy="30" r="6" fill="#22c55e"/><text x="52" y="27" fill="#0f172a" font-size="15" font-weight="600">Backend Developer · NAV</text><text x="52" y="46" fill="#64748b" font-size="13">Offer received — compare against saved package</text><rect x="900" y="16" width="150" height="30" rx="8" fill="#dcfce7"/><text x="975" y="36" fill="#166534" font-size="13" text-anchor="middle">View offer</text></g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 8.8 KiB

@@ -0,0 +1,55 @@
<svg width="1440" height="900" viewBox="0 0 1440 900" xmlns="http://www.w3.org/2000/svg" font-family="Inter, 'Segoe UI', system-ui, sans-serif">
<defs>
<linearGradient id="ac" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#6366f1"/><stop offset="1" stop-color="#22d3ee"/></linearGradient>
<filter id="c" x="-30%" y="-30%" width="160%" height="160%"><feDropShadow dx="0" dy="4" stdDeviation="8" flood-color="#1e293b" flood-opacity="0.10"/></filter>
</defs>
<rect width="1440" height="900" fill="#f4f6fb"/>
<!-- header -->
<g transform="translate(48,44)">
<rect x="0" y="0" width="30" height="30" rx="8" fill="url(#ac)"/><path d="M8 15 l4 4 l10 -10" stroke="#0b1020" stroke-width="3" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
<text x="40" y="22" fill="#0f172a" font-size="22" font-weight="800">Pipeline</text>
<text x="150" y="22" fill="#94a3b8" font-size="15">Drag cards to move a job between stages</text>
<rect x="1150" y="-4" width="192" height="40" rx="10" fill="#fff" filter="url(#c)"/><text x="1170" y="21" fill="#64748b" font-size="14">Search &amp; filter…</text>
</g>
<!-- columns -->
<g transform="translate(48,110)">
<!-- column template values -->
<!-- Applied -->
<g transform="translate(0,0)">
<rect width="256" height="740" rx="14" fill="#eef2f7"/>
<circle cx="24" cy="30" r="6" fill="#6366f1"/><text x="40" y="35" fill="#0f172a" font-size="15" font-weight="700">Applied</text><text x="220" y="35" fill="#94a3b8" font-size="14" text-anchor="end">12</text>
<g transform="translate(14,52)" filter="url(#c)"><rect width="228" height="112" rx="12" fill="#fff"/><rect width="4" height="112" rx="2" fill="#6366f1"/><text x="18" y="30" fill="#0f172a" font-size="15" font-weight="700">Frontend Engineer</text><text x="18" y="52" fill="#64748b" font-size="13">Vercel · Remote</text><rect x="18" y="66" width="66" height="22" rx="11" fill="#eef2ff"/><text x="51" y="81" fill="#4338ca" font-size="11" text-anchor="middle">React</text><rect x="90" y="66" width="60" height="22" rx="11" fill="#eef2ff"/><text x="120" y="81" fill="#4338ca" font-size="11" text-anchor="middle">TS</text><text x="18" y="104" fill="#94a3b8" font-size="12">Applied 3d ago</text></g>
<g transform="translate(14,176)" filter="url(#c)"><rect width="228" height="96" rx="12" fill="#fff"/><rect width="4" height="96" rx="2" fill="#6366f1"/><text x="18" y="30" fill="#0f172a" font-size="15" font-weight="700">Product Engineer</text><text x="18" y="52" fill="#64748b" font-size="13">Linear · Remote</text><text x="18" y="80" fill="#94a3b8" font-size="12">Applied 5d ago · CV 81%</text></g>
<g transform="translate(14,284)" filter="url(#c)"><rect width="228" height="96" rx="12" fill="#fff"/><rect width="4" height="96" rx="2" fill="#6366f1"/><text x="18" y="30" fill="#0f172a" font-size="15" font-weight="700">Fullstack Dev</text><text x="18" y="52" fill="#64748b" font-size="13">Cognite · Oslo</text><text x="18" y="80" fill="#94a3b8" font-size="12">Applied 1w ago</text></g>
</g>
<!-- Waiting -->
<g transform="translate(276,0)">
<rect width="256" height="740" rx="14" fill="#eef2f7"/>
<circle cx="24" cy="30" r="6" fill="#818cf8"/><text x="40" y="35" fill="#0f172a" font-size="15" font-weight="700">Waiting</text><text x="220" y="35" fill="#94a3b8" font-size="14" text-anchor="end">9</text>
<g transform="translate(14,52)" filter="url(#c)"><rect width="228" height="112" rx="12" fill="#fff"/><rect width="4" height="112" rx="2" fill="#818cf8"/><text x="18" y="30" fill="#0f172a" font-size="15" font-weight="700">Platform Engineer</text><text x="18" y="52" fill="#64748b" font-size="13">Finn.no · Oslo</text><rect x="18" y="66" width="150" height="22" rx="11" fill="#fef9c3"/><text x="24" y="81" fill="#854d0e" font-size="11">⏳ Reply due in 2d</text><text x="18" y="104" fill="#94a3b8" font-size="12">Emailed 5d ago</text></g>
<g transform="translate(14,176)" filter="url(#c)"><rect width="228" height="96" rx="12" fill="#fff"/><rect width="4" height="96" rx="2" fill="#f59e0b"/><text x="18" y="30" fill="#0f172a" font-size="15" font-weight="700">DevOps Engineer</text><text x="18" y="52" fill="#64748b" font-size="13">Kahoot! · Oslo</text><rect x="18" y="66" width="120" height="22" rx="11" fill="#fee2e2"/><text x="24" y="81" fill="#991b1b" font-size="11">⚠ Follow up now</text></g>
</g>
<!-- Interview -->
<g transform="translate(552,0)">
<rect width="256" height="740" rx="14" fill="#eef2f7"/>
<circle cx="24" cy="30" r="6" fill="#22d3ee"/><text x="40" y="35" fill="#0f172a" font-size="15" font-weight="700">Interview</text><text x="220" y="35" fill="#94a3b8" font-size="14" text-anchor="end">4</text>
<g transform="translate(14,52)" filter="url(#c)"><rect width="228" height="128" rx="12" fill="#fff"/><rect width="4" height="128" rx="2" fill="#22d3ee"/><text x="18" y="30" fill="#0f172a" font-size="15" font-weight="700">Sr. Backend Dev</text><text x="18" y="52" fill="#64748b" font-size="13">NAV · Oslo</text><rect x="18" y="66" width="180" height="22" rx="11" fill="#cffafe"/><text x="24" y="81" fill="#155e75" font-size="11">📅 Tomorrow 14:00</text><rect x="18" y="94" width="90" height="22" rx="11" fill="#f0fdf4"/><text x="24" y="109" fill="#166534" font-size="11">Prep ready</text></g>
<g transform="translate(14,192)" filter="url(#c)"><rect width="228" height="96" rx="12" fill="#fff"/><rect width="4" height="96" rx="2" fill="#22d3ee"/><text x="18" y="30" fill="#0f172a" font-size="15" font-weight="700">Cloud Engineer</text><text x="18" y="52" fill="#64748b" font-size="13">Visma · Bergen</text><text x="18" y="80" fill="#94a3b8" font-size="12">Round 2 scheduled</text></g>
</g>
<!-- Offer -->
<g transform="translate(828,0)">
<rect width="256" height="740" rx="14" fill="#eef2f7"/>
<circle cx="24" cy="30" r="6" fill="#22c55e"/><text x="40" y="35" fill="#0f172a" font-size="15" font-weight="700">Offer</text><text x="220" y="35" fill="#94a3b8" font-size="14" text-anchor="end">2</text>
<g transform="translate(14,52)" filter="url(#c)"><rect width="228" height="112" rx="12" fill="#fff"/><rect width="4" height="112" rx="2" fill="#22c55e"/><text x="18" y="30" fill="#0f172a" font-size="15" font-weight="700">Backend Developer</text><text x="18" y="52" fill="#64748b" font-size="13">NAV · Oslo</text><rect x="18" y="66" width="150" height="22" rx="11" fill="#dcfce7"/><text x="24" y="81" fill="#166534" font-size="11">🎉 720k NOK / yr</text><text x="18" y="104" fill="#94a3b8" font-size="12">Respond by Fri</text></g>
</g>
<!-- Closed -->
<g transform="translate(1104,0)">
<rect width="240" height="740" rx="14" fill="#eef2f7"/>
<circle cx="24" cy="30" r="6" fill="#94a3b8"/><text x="40" y="35" fill="#0f172a" font-size="15" font-weight="700">Rejected / Ghosted</text><text x="216" y="35" fill="#94a3b8" font-size="14" text-anchor="end">7</text>
<g transform="translate(14,52)" filter="url(#c)"><rect width="212" height="88" rx="12" fill="#fff" opacity="0.75"/><rect width="4" height="88" rx="2" fill="#94a3b8"/><text x="18" y="30" fill="#475569" font-size="15" font-weight="700">Data Engineer</text><text x="18" y="52" fill="#94a3b8" font-size="13">Spotify · Remote</text><text x="18" y="74" fill="#cbd5e1" font-size="12">Rejected · logged</text></g>
<g transform="translate(14,152)" filter="url(#c)"><rect width="212" height="88" rx="12" fill="#fff" opacity="0.75"/><rect width="4" height="88" rx="2" fill="#cbd5e1"/><text x="18" y="30" fill="#475569" font-size="15" font-weight="700">iOS Engineer</text><text x="18" y="52" fill="#94a3b8" font-size="13">Tise · Oslo</text><text x="18" y="74" fill="#cbd5e1" font-size="12">Ghosted · 30d</text></g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.7 KiB

+102
View File
@@ -0,0 +1,102 @@
<svg width="1440" height="900" viewBox="0 0 1440 900" xmlns="http://www.w3.org/2000/svg" font-family="Inter, 'Segoe UI', system-ui, sans-serif">
<defs>
<linearGradient id="ac" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#6366f1"/><stop offset="1" stop-color="#22d3ee"/></linearGradient>
<filter id="c" x="-30%" y="-30%" width="160%" height="160%"><feDropShadow dx="0" dy="6" stdDeviation="12" flood-color="#1e293b" flood-opacity="0.10"/></filter>
</defs>
<rect width="1440" height="900" fill="#f4f6fb"/>
<!-- top bar -->
<g transform="translate(48,40)">
<text x="0" y="16" fill="#94a3b8" font-size="14">Pipeline / Waiting /</text>
<text x="0" y="52" fill="#0f172a" font-size="30" font-weight="800">Platform Engineer</text>
<text x="330" y="52" fill="#64748b" font-size="18">· Finn.no</text>
<rect x="0" y="70" width="112" height="30" rx="15" fill="#fef9c3"/><text x="56" y="90" fill="#854d0e" font-size="13" text-anchor="middle">● Waiting</text>
<text x="128" y="90" fill="#94a3b8" font-size="14">Oslo · 780920k NOK · Applied 5 days ago</text>
</g>
<!-- tabs -->
<g transform="translate(48,148)" font-size="14">
<text x="0" y="0" fill="#4338ca" font-weight="700">Overview</text><rect x="-4" y="10" width="66" height="3" rx="2" fill="#6366f1"/>
<text x="90" y="0" fill="#64748b">Correspondence</text>
<text x="230" y="0" fill="#64748b">Attachments</text>
<text x="352" y="0" fill="#64748b">Candidate Fit</text>
<text x="470" y="0" fill="#64748b">Timeline</text>
</g>
<!-- left: summary + description -->
<g transform="translate(48,180)" filter="url(#c)">
<rect width="600" height="300" rx="14" fill="#fff"/>
<text x="24" y="40" fill="#0f172a" font-size="17" font-weight="700">AI summary</text>
<rect x="480" y="24" width="96" height="26" rx="13" fill="#eef2ff"/><text x="528" y="41" fill="#4338ca" font-size="12" text-anchor="middle">↻ regenerate</text>
<text x="24" y="72" fill="#475569" font-size="14">Platform team building internal developer tooling on Kubernetes.</text>
<text x="24" y="96" fill="#475569" font-size="14">Owns CI/CD, observability, and cloud cost. Strong Go + Terraform</text>
<text x="24" y="120" fill="#475569" font-size="14">focus; hybrid, 2 days in Oslo office.</text>
<line x1="24" y1="146" x2="576" y2="146" stroke="#eef2f7"/>
<text x="24" y="178" fill="#0f172a" font-size="15" font-weight="700">Next action</text>
<rect x="24" y="192" width="552" height="52" rx="10" fill="#fff7ed"/><circle cx="46" cy="218" r="6" fill="#f59e0b"/>
<text x="66" y="214" fill="#0f172a" font-size="14" font-weight="600">Follow up on application status</text>
<text x="66" y="234" fill="#94a3b8" font-size="12">Due in 2 days · draft prepared from your last email</text>
<rect x="452" y="202" width="112" height="32" rx="8" fill="#0f172a"/><text x="508" y="223" fill="#fff" font-size="13" text-anchor="middle">Review draft</text>
<text x="24" y="278" fill="#64748b" font-size="13">Skills detected: Go · Kubernetes · Terraform · AWS · CI/CD</text>
</g>
<!-- left lower: correspondence -->
<g transform="translate(48,500)" filter="url(#c)">
<rect width="600" height="358" rx="14" fill="#fff"/>
<text x="24" y="40" fill="#0f172a" font-size="17" font-weight="700">Correspondence</text>
<rect x="410" y="22" width="166" height="28" rx="8" fill="#eef2ff"/><text x="493" y="41" fill="#4338ca" font-size="12" text-anchor="middle">Linked Gmail thread ✓</text>
<g transform="translate(24,58)">
<rect width="552" height="82" rx="10" fill="#f8fafc"/><circle cx="26" cy="26" r="14" fill="#6366f1"/><text x="26" y="31" fill="#fff" font-size="12" text-anchor="middle" font-weight="700">R</text>
<text x="52" y="24" fill="#0f172a" font-size="14" font-weight="600">Recruiter · Finn.no</text><text x="530" y="24" fill="#94a3b8" font-size="12" text-anchor="end">5d ago</text>
<text x="52" y="46" fill="#64748b" font-size="13">Thanks for applying! We're reviewing and will be in touch</text><text x="52" y="64" fill="#64748b" font-size="13">within two weeks.</text>
</g>
<g transform="translate(24,150)">
<rect width="552" height="70" rx="10" fill="#eef2ff"/><circle cx="26" cy="26" r="14" fill="#0f172a"/><text x="26" y="31" fill="#fff" font-size="12" text-anchor="middle" font-weight="700">You</text>
<text x="52" y="24" fill="#0f172a" font-size="14" font-weight="600">You · sent reply</text><text x="530" y="24" fill="#94a3b8" font-size="12" text-anchor="end">5d ago</text>
<text x="52" y="46" fill="#64748b" font-size="13">Thank you — looking forward to hearing about next steps.</text>
</g>
<rect x="24" y="234" width="552" height="46" rx="10" fill="#f0fdf4"/><text x="40" y="262" fill="#166534" font-size="13">↻ Thread auto-refreshes — new replies appear here without re-importing</text>
<line x1="24" y1="298" x2="576" y2="298" stroke="#eef2f7"/>
<rect x="24" y="312" width="440" height="34" rx="8" fill="#f1f5f9"/><text x="40" y="334" fill="#94a3b8" font-size="13">Draft a grounded follow-up…</text>
<rect x="476" y="312" width="100" height="34" rx="8" fill="url(#ac)"/><text x="526" y="334" fill="#0b1020" font-size="13" text-anchor="middle" font-weight="700">AI draft</text>
</g>
<!-- right: match + attachments + tailor -->
<g transform="translate(672,180)" filter="url(#c)">
<rect width="720" height="300" rx="14" fill="#fff"/>
<text x="28" y="40" fill="#0f172a" font-size="17" font-weight="700">Candidate fit — keyword coverage</text>
<circle cx="110" cy="150" r="64" fill="none" stroke="#e2e8f0" stroke-width="16"/>
<circle cx="110" cy="150" r="64" fill="none" stroke="#22c55e" stroke-width="16" stroke-linecap="round" stroke-dasharray="309 402" transform="rotate(-90 110 150)"/>
<text x="110" y="146" fill="#0f172a" font-size="34" font-weight="800" text-anchor="middle">77%</text>
<text x="110" y="172" fill="#64748b" font-size="12" text-anchor="middle">coverage</text>
<text x="110" y="238" fill="#94a3b8" font-size="12" text-anchor="middle">deterministic · explainable</text>
<g transform="translate(230,72)" font-size="13">
<text x="0" y="0" fill="#166534" font-weight="700">Matched</text>
<g><rect x="0" y="12" width="80" height="26" rx="13" fill="#dcfce7"/><text x="40" y="29" fill="#166534" text-anchor="middle">AWS</text></g>
<g><rect x="88" y="12" width="84" height="26" rx="13" fill="#dcfce7"/><text x="130" y="29" fill="#166534" text-anchor="middle">CI/CD</text></g>
<g><rect x="180" y="12" width="110" height="26" rx="13" fill="#dcfce7"/><text x="235" y="29" fill="#166534" text-anchor="middle">Terraform</text></g>
<g><rect x="300" y="12" width="70" height="26" rx="13" fill="#dcfce7"/><text x="335" y="29" fill="#166534" text-anchor="middle">Go</text></g>
<text x="0" y="78" fill="#991b1b" font-weight="700">Missing — add if you have it</text>
<g><rect x="0" y="90" width="118" height="26" rx="13" fill="#fee2e2"/><text x="59" y="107" fill="#991b1b" text-anchor="middle">Kubernetes ✕</text></g>
<g><rect x="126" y="90" width="96" height="26" rx="13" fill="#fee2e2"/><text x="174" y="107" fill="#991b1b" text-anchor="middle">Grafana ✕</text></g>
<rect x="0" y="132" width="220" height="42" rx="10" fill="url(#ac)"/><text x="110" y="159" fill="#0b1020" font-size="14" font-weight="700" text-anchor="middle">✨ Tailor my CV for this role</text>
<rect x="234" y="132" width="150" height="42" rx="10" fill="none" stroke="#cbd5e1"/><text x="309" y="159" fill="#475569" font-size="14" text-anchor="middle">Keep original</text>
</g>
</g>
<!-- attachments -->
<g transform="translate(672,500)" filter="url(#c)">
<rect width="720" height="358" rx="14" fill="#fff"/>
<text x="28" y="40" fill="#0f172a" font-size="17" font-weight="700">Attachments</text>
<rect x="560" y="22" width="132" height="30" rx="8" fill="#eef2ff"/><text x="626" y="42" fill="#4338ca" font-size="12" text-anchor="middle">⤒ Drop files</text>
<g transform="translate(28,60)">
<g><rect width="330" height="88" rx="12" fill="#f8fafc" stroke="#e2e8f0"/><rect x="16" y="20" width="40" height="48" rx="6" fill="#6366f1"/><text x="36" y="49" fill="#fff" font-size="11" text-anchor="middle">CV</text><text x="70" y="38" fill="#0f172a" font-size="14" font-weight="600">CV_Platform_v3.pdf</text><text x="70" y="60" fill="#94a3b8" font-size="12">Tailored · 214 KB · submitted</text><rect x="70" y="66" width="70" height="16" rx="8" fill="#dcfce7"/></g>
<g transform="translate(350,0)"><rect width="330" height="88" rx="12" fill="#f8fafc" stroke="#e2e8f0"/><rect x="16" y="20" width="40" height="48" rx="6" fill="#22d3ee"/><text x="36" y="49" fill="#083344" font-size="10" text-anchor="middle">DOC</text><text x="70" y="38" fill="#0f172a" font-size="14" font-weight="600">Cover_Letter.pdf</text><text x="70" y="60" fill="#94a3b8" font-size="12">AI-assisted · 98 KB</text></g>
<g transform="translate(0,104)"><rect width="330" height="88" rx="12" fill="#f8fafc" stroke="#e2e8f0"/><rect x="16" y="20" width="40" height="48" rx="6" fill="#f59e0b"/><text x="36" y="49" fill="#fff" font-size="10" text-anchor="middle">PNG</text><text x="70" y="38" fill="#0f172a" font-size="14" font-weight="600">Portfolio.png</text><text x="70" y="60" fill="#94a3b8" font-size="12">1.2 MB</text></g>
<g transform="translate(350,104)"><rect width="330" height="88" rx="12" fill="#fff" stroke="#cbd5e1" stroke-dasharray="5 5"/><text x="165" y="44" fill="#94a3b8" font-size="13" text-anchor="middle">Drag &amp; drop or click</text><text x="165" y="64" fill="#cbd5e1" font-size="12" text-anchor="middle">resume · cover letter · portfolio</text></g>
</g>
<line x1="28" y1="268" x2="692" y2="268" stroke="#eef2f7"/>
<text x="28" y="300" fill="#0f172a" font-size="14" font-weight="700">Checklist</text>
<g font-size="13" transform="translate(28,320)"><text x="0" y="0" fill="#16a34a">✓ Resume</text><text x="110" y="0" fill="#16a34a">✓ Cover letter</text><text x="250" y="0" fill="#16a34a">✓ Portfolio</text><text x="370" y="0" fill="#cbd5e1">○ References</text></g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 10 KiB

+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;
}
+22 -5
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";
@@ -37,6 +37,7 @@ interface Props {
open: boolean;
onClose: () => void;
onCreated: () => void;
initialUrl?: string;
}
type DuplicateCandidate = {
@@ -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();
@@ -137,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("");
@@ -223,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"));
@@ -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>
@@ -18,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, MatchScore, 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";
@@ -172,6 +174,8 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
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);
@@ -205,6 +209,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
setFollowUpDraft(null);
setCandidateFit(null);
setMatchScore(null);
setStatusSuggestion(null);
setFocusPlan(null);
setInterviewPrep(null);
setReadiness(null);
@@ -303,6 +308,31 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
}).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"}`;
@@ -621,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" }}>
@@ -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>
+26
View File
@@ -157,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",
@@ -335,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",
@@ -779,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",
@@ -1088,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",
@@ -1266,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",
@@ -1710,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",
+257
View File
@@ -0,0 +1,257 @@
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>
{/* Product preview */}
<Container maxWidth="lg" sx={{ py: { xs: 6, md: 9 } }}>
<Box sx={{ textAlign: "center", mb: 5 }}>
<Typography sx={{ color: "primary.main", fontWeight: 700, letterSpacing: 1, fontSize: 13 }}>SEE IT IN ACTION</Typography>
<Typography component="h2" sx={{ fontWeight: 800, fontSize: { xs: 26, md: 34 }, mt: 1 }}>Your whole search, at a glance</Typography>
</Box>
<Box sx={{ borderRadius: 3, overflow: "hidden", border: "1px solid", borderColor: "divider", boxShadow: 10, mb: 3, bgcolor: "background.paper" }}>
<Box component="img" src="/mockups/dashboard.svg" alt="JobTrack dashboard — KPIs, funnel, response trend and follow-ups" sx={{ width: "100%", display: "block" }} />
</Box>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 3 }}>
<Box sx={{ borderRadius: 3, overflow: "hidden", border: "1px solid", borderColor: "divider", boxShadow: 6, bgcolor: "background.paper" }}>
<Box component="img" src="/mockups/pipeline.svg" alt="Drag-and-drop pipeline board" sx={{ width: "100%", display: "block" }} />
</Box>
<Box sx={{ borderRadius: 3, overflow: "hidden", border: "1px solid", borderColor: "divider", boxShadow: 6, bgcolor: "background.paper" }}>
<Box component="img" src="/mockups/workspace.svg" alt="Per-job workspace with match score, correspondence and attachments" sx={{ width: "100%", display: "block" }} />
</Box>
</Box>
<Typography sx={{ textAlign: "center", color: "text.secondary", fontSize: 13, mt: 2 }}>Interface preview.</Typography>
</Container>
{/* 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);
+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());
});
@@ -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) {
+10
View File
@@ -138,6 +138,16 @@ export interface MatchScoreSectionCoverage {
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;
+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