Compare commits

...

23 Commits

Author SHA1 Message Date
cesnimda 102938c28b docs: add Career Workspace research, teardown, strategy, and roadmap
Four discovery documents backing the Career Workspace redesign:

- cv-builder-competitor-deep-research.md: teardown of Novoresume,
  Reactive Resume, FlowCV, Teal, Enhancv, Canva, Resume.io, Kickresume
  -- positioning, UX patterns, pricing/trust failures, technical
  architecture lessons (esp. Reactive Resume's content/theme
  separation and PDF pipeline history).
- cv-builder-product-teardown.md: critical as-is audit of this app's
  CV builder -- data model, editor UX, AI workflow, rendering
  pipeline, feature gaps -- including the OAuth CV lockout bug fixed
  in a prior commit.
- career-workspace-product-strategy.md: product vision, positioning,
  personas, core object model, feature roadmap (MVP/V2/future),
  AI/monetization strategy, and the first 10 engineering tasks.
- career-workspace-implementation-roadmap.md: the execution plan --
  product boundary (Career Workspace is a bounded domain supporting
  job tracking, not replacing it), phased sequencing (F0-F6), and the
  migration mechanics specific to this repo's raw-SQL schema
  reconciler.
2026-07-12 15:21:17 +02:00
cesnimda cbd045a0d3 feat: show diff view for AI CV rewrites
The master-CV rewrite preview replaced text without showing what
changed -- the teardown flagged this as the biggest unmanaged AI risk
(a rewrite silently upgrading "assisted with migration" to "led
migration" was invisible). Add a "Show changes" toggle on the rewrite
preview panel that renders a word-level diff (before = current master
text or the targeted section's stored content, after = the AI's
rewrite) instead of the flat replacement text.

Defaults to off: an existing test proved diff-by-default breaks the
familiar plain-text read (word-fragmented spans aren't matchable as
one block), and it's a better UX default regardless -- read normally,
opt into the diff when you want the trust signal.

Uses the `diff` package (word-level diffWords) rather than hand-rolled
LCS; no existing dependency covers this, and it's a solved problem.
2026-07-12 15:21:04 +02:00
cesnimda 66384bda60 test: add regression coverage for CV template renderer
CvTemplateRenderer (six hardcoded HTML templates, ~450 lines) had zero
test coverage. Lock in current behavior before any future theme
extraction touches it: renders without throwing and contains the
content it was given, deterministic for identical input, falls back
to ats-minimal for an unknown template id, and HTML-encodes
user-supplied content (regression guard against CV text containing
markup).
2026-07-12 15:19:02 +02:00
cesnimda 235e291d8f feat: add career profile foundation with versioned history
Introduces the Career Workspace's bounded data foundation, additive
and backwards-compatible: ApplicationUser.ProfileCvStructureJson
stays the authoritative column every existing read path uses; the new
CareerProfiles/CareerProfileVersions tables mirror it via
ICareerProfileService so future Career Workspace features (variants,
history UI) have real tables to build on rather than starting a
second migration later.

- CareerProfile: one snapshot row per user (Version, ProfileJson).
- CareerProfileVersion: append-only history, one row per save
  (upload/rebuild/improve/reprocess/parse), so a profile edit is never
  silently lost the way ProfileCvStructureJson overwrites are today.
- Stable item IDs assigned to jobs/education/certifications/projects
  on first save and preserved across later saves -- the prerequisite
  for CV variants to reference "this job" by identity instead of
  array position.
- CvDateNormalizer: best-effort free-string -> "YYYY-MM" parsing for
  job/education/certification/project date ranges, kept alongside
  (never replacing) the original free-string fields.
- Both SQLite (dev) and MySQL/MariaDB (prod) reconciler dialects,
  matching this repo's schema-via-raw-SQL-reconciler convention
  rather than EF migrations.

Job tracking is untouched -- this is entirely within the profile/CV
domain per the Career Workspace product boundary.
2026-07-12 15:18:05 +02:00
cesnimda a4c8e4ac5d fix: unlock CV builder for Google/Microsoft-authenticated users
ProfilePage gated every CV control (upload/rebuild/improve/reprocess/
rewrite) behind isLocal, which is true only for password-authenticated
accounts. Every OAuth signup landed on a CV builder with every button
disabled, even though the backend never restricted these endpoints by
provider (all providers share the same local-scheme session token
after sign-in).

Replace the CV-feature gates with canEditCv (true for any
authenticated user). isLocal is kept for the fields it was actually
meant to protect: password change and provider-managed identity
fields on OAuth accounts.
2026-07-12 15:16:49 +02:00
cesnimda 0cd1ba398e Merge pull request 'feat(ux): product/UX review implementation (onboarding, empty states, a11y, mobile kanban)' (#27) from feat/ux-review-quick-wins into main
CI and Deploy / test (push) Successful in 2m7s
CI and Deploy / deploy (push) Successful in 40s
2026-07-12 04:30:00 +02:00
cesnimda d5d82cb528 feat(ux): onboarding checklist, dashboard-first landing (fixed)
CI and Deploy / test (pull_request) Successful in 2m5s
CI and Deploy / deploy (pull_request) Has been skipped
Dashboard onboarding checklist: a dismissible card with 3 steps (add
CV, import first job, check match score), each linking straight to
where you'd do it. Auto-hides once both CV and a job exist; otherwise
persists per-user via localStorage until dismissed.

Fixes the actual authenticated-landing redirect to /dashboard: my
earlier commit changed App.tsx's inner Shell route for "/", which
turned out to be dead code -- the outer router claims "/" for
LandingPage first, so Shell's own "/" route is never reached on a
direct hit. The real redirect lives in LandingPage.tsx's post-auth-check
navigate() and LoginPage.tsx's post-login nextPath default; both now
point at /dashboard. Verified live: an authenticated visitor hitting
"/" now lands on Dashboard with the onboarding checklist visible,
confirmed via rendered page text and screenshot.
2026-07-12 04:26:17 +02:00
cesnimda 9615ee3f41 feat(ux): per-view subtitles, correspondence cross-links, mobile kanban, a11y
Continuing the product/UX review's deferred items:

- Every top-level view now gets a one-line subtitle under its title
  (Dashboard/Jobs/Kanban/Reminders/Correspondence/Gmail review) stating
  what that specific view is for, instead of navigation being the only
  signal of what each page does.
- Correspondence inbox and Gmail review queue cross-link to each other
  instead of being two unexplained flat sidebar items -- kept both nav
  entries (renaming/nesting risked breaking muscle memory) but made the
  relationship between them explicit in the UI itself.
- Kanban board switches to a horizontal scroll-snap row on phone-width
  viewports instead of stacking all 5 columns vertically, which meant
  a lot of scrolling to see anything past "Applied".
- Match-score ring gets an aria-label with the actual percentage --
  it was two nested decorative CircularProgress elements with no
  accessible text. (Keyboard-accessible status changes on kanban cards
  were already covered by the existing "..." menu -- no gap there.)
2026-07-12 04:14:37 +02:00
cesnimda 58868fc2b6 feat(ux): first-time onboarding, empty states, and copy fixes
Implements the six "propose first" items from the product/UX review:

- "/" now redirects to /dashboard instead of the empty /jobs table --
  a new user's first screen is now an overview with orientation, not
  a data table with zero rows and four filter dropdowns.
- Jobs table gets a real first-time empty state (distinct from "no
  results match your filters") pointing at Add Job and the bookmarklet,
  instead of a bare "No jobs found."
- Match Score card and Candidate Fit tab now each get a one-line
  caption explaining what they are and how they differ (deterministic
  keyword coverage vs. AI opinion) -- they previously sat side by side
  with no explanation of why there are two.
- Google sign-in hint now reflects self-serve signup when
  Auth:AllowRegistration is on, instead of always implying you need an
  existing linked account.
- Quick Search button now shows its keyboard shortcut (Ctrl+K / ⌘K)
  inline instead of being undiscoverable.
2026-07-12 04:05:32 +02:00
cesnimda 7dadf8dde4 Merge pull request 'fix(auth): Google Sign-In audience mismatch + remove per-user accent color' (#26) from fix/google-signin-and-theming-cleanup into main
CI and Deploy / test (push) Successful in 2m5s
CI and Deploy / deploy (push) Successful in 1m10s
2026-07-12 03:06:35 +02:00
cesnimda 33d899c243 fix(auth): Google Sign-In audience mismatch + remove per-user accent color
CI and Deploy / test (pull_request) Successful in 2m8s
CI and Deploy / deploy (pull_request) Has been skipped
Root cause of "Google authentication failed": appsettings.Development.json
had Auth:GoogleClientId set to the literal placeholder
"CHANGE_ME_GOOGLE_CLIENT_ID" while the frontend's .env.development had a
real (already-public, already-committed) client ID -- every Google ID
token's audience check failed against the backend's placeholder. Fixed
by setting the same real client ID on both sides (a client ID is a
public identifier, not a secret, safe to commit -- unlike a client
secret). Also enabled Auth:AllowRegistration in dev so the existing
Google-first self-serve-signup path (auto-create on unmatched verified
email, auto-link on matching verified email -- built during Wave 7) is
actually exercisable locally.

Wired the previously-missing Auth__MicrosoftClientId /
NEXT_PUBLIC_MICROSOFT_CLIENT_ID into docker-compose.yml/.env.example
(distinct from the existing MICROSOFT_CLIENT_ID used for Outlook mail
linking) -- Microsoft sign-in was never deployable, a leftover gap from
when it was built. Fixed a stale env-var name in the Microsoft setup
hint copy (still said REACT_APP_*, predates the Next.js migration).

Removed the per-user accent color picker entirely: it was purely
client-side (localStorage + theme.ts), never touched the backend/DB.
theme.ts now hardcodes a single ACCENT constant; themePrefs.ts drops
get/set/clearAccentColor; App.tsx and SettingsView.tsx drop the
accentColor prop threading. Dead accent-related i18n keys removed from
both locales.

Consolidated Settings' "Account" tab (duplicated GoogleAuthCard, which
already lives on the Profile page) into Profile: moved AuthStatusCard
and EmailProviderConnections there alongside the existing Google/
Microsoft auth cards, so identity/account-linking lives in one place.
Settings drops from 5 tabs to 4 and its General tab uses a consistent
SectionCard layout instead of ad-hoc per-card styling.

Verified: dotnet build/test (177/177) and npm build/test (57/57) both
green; confirmed live against a running dev server that /auth/config
now reports googleEnabled with the corrected client ID, Settings has
no accent controls, and Profile shows the consolidated auth section.
2026-07-12 02:43:10 +02:00
cesnimda b2e176940c Merge pull request 'fix(deploy): copy .npmrc before npm ci in frontend Dockerfile' (#25) from fix/docker-npmrc-not-copied into main
CI and Deploy / test (push) Successful in 2m1s
CI and Deploy / deploy (push) Successful in 2m13s
2026-07-12 02:18:47 +02:00
cesnimda 86cdafb3ef fix(deploy): copy .npmrc before npm ci in frontend Dockerfile
CI and Deploy / test (pull_request) Successful in 2m1s
CI and Deploy / deploy (pull_request) Has been skipped
Production deploy has been broken since the Next.js migration merged:
the Dockerfile ran `npm ci` right after COPY package*.json, before the
later `COPY . .` that would bring in .npmrc -- so the legacy-peer-deps
fix for react-scripts' stale TS ^4 peer constraint (added for CI in
dbb1580) never took effect in the actual deploy image, and every
deploy since has failed with the same ERESOLVE error CI hit before
that fix. Copy .npmrc alongside package*.json so npm ci sees it.
2026-07-12 02:15:49 +02:00
cesnimda 0e5845a95a Merge pull request 'feat(ui): circular match-score ring in job workspace' (#24) from feature/ui-rework-match-score-ring into main
CI and Deploy / test (push) Successful in 2m0s
CI and Deploy / deploy (push) Failing after 45s
2026-07-12 02:05:18 +02:00
cesnimda ffb9888fb4 feat(ui): circular match-score ring in job workspace
CI and Deploy / test (pull_request) Successful in 2m3s
CI and Deploy / deploy (pull_request) Has been skipped
Second UI-rework pass. The job workspace mockup's signature element is
a donut "coverage" ring for the deterministic CV match score; the app
had a linear progress bar instead. Replaced with a layered
CircularProgress ring (track + value arc, percentage centered) while
keeping every existing feature (band chip, matched/missing keyword
chips, section coverage) -- this is a pure visual upgrade to the
existing MatchScoreCard, not a feature reduction to match the mockup's
simpler single-panel layout.

Fixed match-score-panel.test.tsx's no-signal-state assertion, which
expected the removed inline "—" placeholder; restored it outside the
ring's conditional render.
2026-07-12 01:59:03 +02:00
cesnimda f4503f7b2c Merge pull request 'feat(ui): dark navy sidebar + restrained kanban status colors' (#23) from feature/ui-rework-sidebar-kanban into main
CI and Deploy / test (push) Successful in 2m2s
CI and Deploy / deploy (push) Failing after 39s
2026-07-12 01:53:27 +02:00
cesnimda 7cfbdf504a feat(ui): dark navy sidebar + restrained kanban status colors
CI and Deploy / test (pull_request) Successful in 2m2s
CI and Deploy / deploy (pull_request) Has been skipped
First pass of the /frontend-design overhaul against the mockups at
F:\Pictures\website\jobtracker\new. Two highest-leverage gaps from the
backlog note ("dark sidebar, KPI cards, exact status colours"):

- AppShell: nav rail is now a fixed dark navy (#0f172a) regardless of
  the app's light/dark theme toggle, matching the mockup's signature
  look -- selected item gets an indigo-tinted pill + icon accent,
  muted slate text for the rest. Kept icon+label rows (mockup's sidebar
  is text-only) since the existing collapsed-sidebar mode depends on
  icons; that's a deliberate deviation, not an oversight.
- JobbjaktMark: replaced the briefcase glyph with the gradient
  checkmark-in-square mark used throughout the mockups (hero, dashboard,
  kanban) -- also fixed a latent SVG gradient id collision across
  multiple rendered instances via useId().
- KanbanBoard: mockup uses color sparingly (a small dot in the column
  header, a 4px accent on the card's left edge) rather than tinting the
  whole column/card background as the previous version did. Reworked
  to match; also swapped card title/subtitle order (job title bold,
  company/location as subtitle) per the mockup.

Remaining for follow-up passes: Dashboard KPI card layout and the job
workspace (candidate-fit ring, AI summary card) -- both structurally
close already but not yet pixel-matched.

Verified: `next build` clean, all 57 frontend tests green, dark
sidebar confirmed live (computed bg #0f172a) against a running dev
server with light content mode forced.
2026-07-12 01:45:20 +02:00
cesnimda 8a9e402baa Merge pull request 'build(frontend): migrate CRA to Next.js (CSR lift-and-shift)' (#21) from feature/wave6-nextjs-migration into main
CI and Deploy / test (push) Successful in 2m4s
CI and Deploy / deploy (push) Failing after 1m2s
2026-07-12 01:25:22 +02:00
cesnimda dbb15804a3 fix(frontend): relax npm peer-dep resolution for react-scripts vs TS 5.9
CI and Deploy / test (pull_request) Successful in 2m6s
CI and Deploy / deploy (pull_request) Has been skipped
CI's npm ci (strict peer resolution) rejected the TypeScript 5.9 bump
from the Next.js migration: react-scripts still declares typescript
^3.2.1||^4 as a peer. Local `npm install` didn't catch this -- it
resolves peer conflicts leniently by default; only `npm ci` enforces
them. react-scripts is kept solely as the Jest test runner now (it
doesn't type-check), so relaxing this one peer constraint is safe.
2026-07-12 01:22:18 +02:00
cesnimda 6903032c3b Merge pull request 'feat(auth): Microsoft OAuth sign-in/link + self-serve signup via Google/Microsoft' (#22) from feature/wave7-oauth-signup into main
CI and Deploy / test (push) Successful in 2m8s
CI and Deploy / deploy (push) Failing after 1m24s
2026-07-12 01:15:41 +02:00
cesnimda 53d05dd4c4 Merge pull request 'feat(ai): prompt-injection delimiters + synonym-aware match scoring' (#20) from feature/wave4-ai-hardening into main
CI and Deploy / test (push) Successful in 2m5s
CI and Deploy / deploy (push) Successful in 45s
2026-07-12 01:13:39 +02:00
cesnimda acf60c2a07 build(frontend): migrate CRA to Next.js (CSR lift-and-shift)
CI and Deploy / test (pull_request) Failing after 50s
CI and Deploy / deploy (pull_request) Has been skipped
Wave 6. Swaps react-scripts' build/dev tooling for Next.js while
keeping the app's actual routing/rendering model unchanged -- the app
is almost entirely behind auth with no proven SSR/SEO need, so a real
App Router rewrite would touch ~90 files for zero user-visible benefit.

- next.config.js: output:'export' (static HTML+JS, same "single
  index.html served by nginx with try_files fallback" deploy as CRA).
- app/layout.tsx + app/page.tsx: root shell ports public/index.html's
  <head>, mounts the whole existing App tree client-only (ssr:false)
  since it reads window/localStorage during initial render and Next's
  static prerender would otherwise execute that on the server.
- Renamed src/pages/ -> src/views/ (Next's Pages Router auto-detects
  any `pages/` dir under the app root and tried to build our React
  Router page components as its own routes).
- REACT_APP_* -> NEXT_PUBLIC_* across code, .env.development,
  Dockerfile, docker-compose.yml build args.
- Replaced the CRA SVGR import (`ReactComponent` from .svg, unsupported
  under Turbopack) with a small inline JobbjaktMark component.
- TypeScript 4.9 -> 5.9 (MUI v8's type-checked build needs syntax
  4.9's parser rejects; CRA never hit this because babel doesn't
  type-check).
- Dropped CRA-only files (index.tsx, reportWebVitals, react-app-env.d.ts,
  public/index.html); kept react-scripts as the Jest test runner only
  (next/jest migration not needed -- the existing config already works).

Verified: `next build` static export succeeds, `next dev` serves the
landing page and client-side routes (login etc.) correctly, all 57
frontend tests + 172 backend tests still green.

Known caveat: deep-linking straight to a sub-route (e.g. /login) 404s
in `next dev` since there's no server route for it -- the app only
ever mounts at "/". Production is unaffected: nginx's existing
try_files fallback still serves index.html for any path.
2026-07-12 00:50:45 +02:00
cesnimda 3081d99355 feat(auth): Microsoft OAuth sign-in/link + self-serve signup via Google/Microsoft
CI and Deploy / test (pull_request) Successful in 2m9s
CI and Deploy / deploy (pull_request) Has been skipped
Wave 7. Mirrors the existing Google ID-token-exchange pattern (Program.cs
smart-scheme dispatch, JWT bearer scheme, AuthController exchange/link/
unlink endpoints, ApplicationUser fields, reconciler columns) for
Microsoft Entra ID + personal accounts via the multi-tenant "common"
endpoint.

Google/Microsoft sign-in previously only worked for accounts already
linked to an existing local user -- there was no way to actually sign
up via OAuth. Both exchange endpoints now create a new user when no
match is found and Auth:AllowRegistration is true, same gate as
email/password registration.

Frontend: new MicrosoftAuthCard (MSAL popup flow -- Microsoft has no
vanilla-JS equivalent to Google's Identity Services script) wired into
the login page's provider tabs and the profile page's account-linking
section. REACT_APP_MICROSOFT_CLIENT_ID env var, Auth:MicrosoftClientId
config gate on the backend.
2026-07-12 00:12:23 +02:00
74 changed files with 3819 additions and 514 deletions
+3
View File
@@ -5,6 +5,9 @@ AUTH_JWT_KEY=CHANGE_ME_LONG_RANDOM_SECRET
AUTH_ADMIN_EMAIL=admin@example.com
AUTH_ADMIN_PASSWORD=CHANGE_ME_STRONG_PASSWORD
AUTH_GOOGLE_CLIENT_ID=CHANGE_ME_GOOGLE_CLIENT_ID
# Optional: enables the "Continue with Microsoft" sign-in tab (separate from the
# MICROSOFT_CLIENT_ID below, which is for Outlook mail linking, not sign-in).
AUTH_MICROSOFT_CLIENT_ID=
GOOGLE_GMAIL_CLIENT_SECRET=CHANGE_ME_GOOGLE_OAUTH_CLIENT_SECRET
# Optional. If omitted, the backend uses https://<your-domain>/api/gmail/oauth/callback
GOOGLE_GMAIL_REDIRECT_URI=
+23
View File
@@ -28,6 +28,8 @@ namespace JobTrackerApi.Data
public DbSet<CvUploadArtifact> CvUploadArtifacts => Set<CvUploadArtifact>();
public DbSet<CvExtractionRun> CvExtractionRuns => Set<CvExtractionRun>();
public DbSet<TailoredCvDraft> TailoredCvDrafts => Set<TailoredCvDraft>();
public DbSet<CareerProfile> CareerProfiles => Set<CareerProfile>();
public DbSet<CareerProfileVersion> CareerProfileVersions => Set<CareerProfileVersion>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -141,6 +143,27 @@ namespace JobTrackerApi.Data
.WithOne(j => j.TailoredCvDraft)
.HasForeignKey<TailoredCvDraft>(x => x.JobApplicationId)
.OnDelete(DeleteBehavior.Cascade);
// Career Workspace foundation (docs/career-workspace-implementation-roadmap.md Phase F1).
// One CareerProfile per user for now -- see roadmap "Not now: multiple profiles per user".
modelBuilder.Entity<CareerProfile>()
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
modelBuilder.Entity<CareerProfile>()
.HasIndex(x => x.OwnerUserId)
.IsUnique();
modelBuilder.Entity<CareerProfileVersion>()
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
modelBuilder.Entity<CareerProfileVersion>()
.HasIndex(x => new { x.OwnerUserId, x.CareerProfileId, x.Version });
modelBuilder.Entity<CareerProfileVersion>()
.HasOne(x => x.CareerProfile)
.WithMany()
.HasForeignKey(x => x.CareerProfileId)
.OnDelete(DeleteBehavior.Cascade);
}
}
}
@@ -25,7 +25,7 @@ public sealed class AuthAndSystemControllerTests
userManager.Setup(x => x.GetUserAsync(It.IsAny<System.Security.Claims.ClaimsPrincipal>())).ReturnsAsync(user);
userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), NullLogger<AuthController>.Instance);
var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance);
var result = await controller.UpdateProfile(new AuthController.UpdateProfileRequest(" new@example.com ", " newuser ", " Ada ", " Lovelace ", " Ada L. ", null, null));
@@ -50,7 +50,7 @@ public sealed class AuthAndSystemControllerTests
.Setup(x => x.SendAsync(user.Email!, It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("SMTP unavailable"));
var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), emailSender.Object, Mock.Of<IGoogleTokenValidator>(), NullLogger<AuthController>.Instance)
var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), emailSender.Object, Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance)
{
ControllerContext = new ControllerContext
{
@@ -91,7 +91,7 @@ public sealed class AuthAndSystemControllerTests
.Setup(x => x.ValidateAsync("google-token", It.IsAny<CancellationToken>()))
.ReturnsAsync(new GoogleTokenPrincipal("google-subject", "dj@cesnimda.co.uk", true, "Dan", "Jones", "Dan Jones"));
var controller = new AuthController(BuildConfig(), userManager.Object, tokenService.Object, Mock.Of<IAppEmailSender>(), googleValidator.Object, NullLogger<AuthController>.Instance)
var controller = new AuthController(BuildConfig(), userManager.Object, tokenService.Object, Mock.Of<IAppEmailSender>(), googleValidator.Object, Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance)
{
ControllerContext = new ControllerContext
{
@@ -110,6 +110,76 @@ public sealed class AuthAndSystemControllerTests
Assert.NotNull(user.GoogleLinkedAt);
}
[Fact]
public async Task Exchange_microsoft_token_creates_new_user_when_registration_allowed()
{
var userManager = CreateUserManager();
userManager.Setup(x => x.Users).Returns(new TestAsyncEnumerable<ApplicationUser>(new List<ApplicationUser>()));
userManager.Setup(x => x.FindByEmailAsync("new.hire@example.com")).ReturnsAsync((ApplicationUser?)null);
ApplicationUser? created = null;
userManager
.Setup(x => x.CreateAsync(It.IsAny<ApplicationUser>()))
.Callback<ApplicationUser>(u => created = u)
.ReturnsAsync(IdentityResult.Success);
userManager.Setup(x => x.UpdateAsync(It.IsAny<ApplicationUser>())).ReturnsAsync(IdentityResult.Success);
var tokenService = new Mock<ITokenService>();
tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny<ApplicationUser>(), It.IsAny<CancellationToken>())).ReturnsAsync("app-token");
var microsoftValidator = new Mock<IMicrosoftTokenValidator>();
microsoftValidator
.Setup(x => x.ValidateAsync("microsoft-token", It.IsAny<CancellationToken>()))
.ReturnsAsync(new MicrosoftTokenPrincipal("ms-subject", "new.hire@example.com", true, "New", "Hire", "New Hire"));
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> { ["Auth:AllowRegistration"] = "true" })
.Build();
var controller = new AuthController(config, userManager.Object, tokenService.Object, Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), microsoftValidator.Object, NullLogger<AuthController>.Instance)
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext()
}
};
var result = await controller.ExchangeMicrosoftToken(new AuthController.MicrosoftTokenRequest("microsoft-token"), CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result.Result);
var payload = Assert.IsType<AuthController.AuthSessionResult>(ok.Value);
Assert.True(payload.Authenticated);
Assert.Equal("microsoft", payload.Provider);
Assert.NotNull(created);
Assert.Equal("new.hire@example.com", created!.Email);
Assert.Equal("ms-subject", created.MicrosoftSubject);
}
[Fact]
public async Task Exchange_microsoft_token_rejects_unmatched_account_when_registration_disabled()
{
var userManager = CreateUserManager();
userManager.Setup(x => x.Users).Returns(new TestAsyncEnumerable<ApplicationUser>(new List<ApplicationUser>()));
userManager.Setup(x => x.FindByEmailAsync("nobody@example.com")).ReturnsAsync((ApplicationUser?)null);
var microsoftValidator = new Mock<IMicrosoftTokenValidator>();
microsoftValidator
.Setup(x => x.ValidateAsync("microsoft-token", It.IsAny<CancellationToken>()))
.ReturnsAsync(new MicrosoftTokenPrincipal("ms-subject", "nobody@example.com", true, null, null, null));
var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), microsoftValidator.Object, NullLogger<AuthController>.Instance)
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext()
}
};
var result = await controller.ExchangeMicrosoftToken(new AuthController.MicrosoftTokenRequest("microsoft-token"), CancellationToken.None);
Assert.IsType<UnauthorizedObjectResult>(result.Result);
userManager.Verify(x => x.CreateAsync(It.IsAny<ApplicationUser>()), Times.Never);
}
[Fact]
public void Me_result_includes_google_link_details_for_local_users()
{
@@ -0,0 +1,101 @@
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using Microsoft.EntityFrameworkCore;
using Moq;
using Xunit;
namespace JobTrackerApi.Tests;
public sealed class CareerProfileServiceTests
{
private static JobTrackerContext NewContext(string userId)
{
var options = new DbContextOptionsBuilder<JobTrackerContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var currentUser = new Mock<ICurrentUserService>();
currentUser.SetupGet(service => service.UserId).Returns(userId);
return new JobTrackerContext(options, currentUser.Object);
}
[Fact]
public async Task SaveVersionAsync_assigns_stable_ids_to_items_missing_one()
{
await using var db = NewContext("user-1");
var service = new CareerProfileService(db);
var profile = new StructuredCvProfile
{
Jobs = { new StructuredCvJob { Title = "Engineer", Company = "Acme" } },
};
var saved = await service.SaveVersionAsync("user-1", profile, "upload", default);
Assert.False(string.IsNullOrWhiteSpace(saved.Jobs[0].Id));
}
[Fact]
public async Task SaveVersionAsync_preserves_existing_ids_across_saves()
{
await using var db = NewContext("user-1");
var service = new CareerProfileService(db);
var profile = new StructuredCvProfile
{
Jobs = { new StructuredCvJob { Title = "Engineer", Company = "Acme" } },
};
await service.SaveVersionAsync("user-1", profile, "upload", default);
var firstId = profile.Jobs[0].Id;
await service.SaveVersionAsync("user-1", profile, "rebuild", default);
Assert.Equal(firstId, profile.Jobs[0].Id);
}
[Theory]
[InlineData("January 2020", "2020-01")]
[InlineData("Mar 2019", "2019-03")]
[InlineData("03/2019", "2019-03")]
[InlineData("2019-03", "2019-03")]
[InlineData("Present", null)]
[InlineData("2020", null)]
[InlineData(null, null)]
public void CvDateNormalizer_parses_common_formats_without_guessing(string? input, string? expected)
{
Assert.Equal(expected, CvDateNormalizer.TryParseYearMonth(input));
}
[Fact]
public async Task SaveVersionAsync_persists_current_snapshot_and_append_only_history()
{
await using var db = NewContext("user-1");
var service = new CareerProfileService(db);
var profile = new StructuredCvProfile { Summary = { "First version" } };
await service.SaveVersionAsync("user-1", profile, "upload", default);
await service.SaveVersionAsync("user-1", profile, "improve", default);
var snapshots = await db.CareerProfiles.IgnoreQueryFilters().Where(x => x.OwnerUserId == "user-1").ToListAsync();
var history = await db.CareerProfileVersions.IgnoreQueryFilters().Where(x => x.OwnerUserId == "user-1").ToListAsync();
Assert.Single(snapshots);
Assert.Equal(2, snapshots[0].Version);
Assert.Equal(2, history.Count);
Assert.Equal(new[] { "upload", "improve" }, history.OrderBy(x => x.Version).Select(x => x.Source));
}
[Fact]
public async Task SaveVersionAsync_does_not_normalize_end_date_when_job_is_current()
{
await using var db = NewContext("user-1");
var service = new CareerProfileService(db);
var profile = new StructuredCvProfile
{
Jobs = { new StructuredCvJob { Title = "Engineer", Start = "Jan 2020", End = "Present", IsCurrent = true } },
};
var saved = await service.SaveVersionAsync("user-1", profile, "upload", default);
Assert.Equal("2020-01", saved.Jobs[0].StartDate);
Assert.Null(saved.Jobs[0].EndDate);
}
}
@@ -51,7 +51,7 @@ public sealed class ClientErrorsControllerTests
var userManager = TestHostFactory.CreateUserManager();
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<ILogger<AuthController>>())
var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), Mock.Of<ILogger<AuthController>>())
{
ControllerContext = new ControllerContext
{
@@ -0,0 +1,86 @@
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using Xunit;
namespace JobTrackerApi.Tests;
// Regression safety net for the renderer ahead of the theme-extraction work in
// career-workspace-implementation-roadmap.md Phase F3. The renderer had zero test coverage;
// these tests lock in "renders without throwing, is deterministic, and contains the content it
// was given" for every shipped template so a future refactor (or accidental edit) that changes
// output is caught immediately.
public sealed class CvTemplateRendererTests
{
private static readonly string[] AllTemplateIds = { "ats-minimal", "harvard", "auckland", "edinburgh", "monarch", "fjord" };
private readonly CvTemplateRenderer _renderer = new();
private static TailoredCvDocument SampleDocument(string templateId) => new()
{
TemplateId = templateId,
Headline = "Senior Backend Engineer",
Summary = { "Builds reliable distributed systems." },
SelectedSkills = { "C#", ".NET", "SQL" },
Experience =
{
new TailoredCvExperienceItem
{
Title = "Backend Engineer",
Company = "Acme Corp",
Start = "2020",
End = "Present",
IsCurrent = true,
Bullets = { "Shipped the payments service." },
},
},
Education =
{
new TailoredCvEducationItem { Qualification = "BSc Computer Science", Institution = "Example University" },
},
};
[Theory]
[MemberData(nameof(TemplateIds))]
public void Render_produces_html_containing_candidate_and_content(string templateId)
{
var result = _renderer.Render(SampleDocument(templateId), templateId, "Jamie Rivera", "Backend Engineer", "Acme Corp");
Assert.Equal(templateId, result.TemplateId);
Assert.Contains("Jamie Rivera", result.Html);
Assert.Contains("Acme Corp", result.Html);
Assert.Contains("Shipped the payments service.", result.Html);
Assert.Contains("<!DOCTYPE html>", result.Html);
}
[Theory]
[MemberData(nameof(TemplateIds))]
public void Render_is_deterministic_for_identical_input(string templateId)
{
var a = _renderer.Render(SampleDocument(templateId), templateId, "Jamie Rivera", "Backend Engineer", "Acme Corp");
var b = _renderer.Render(SampleDocument(templateId), templateId, "Jamie Rivera", "Backend Engineer", "Acme Corp");
Assert.Equal(a.Html, b.Html);
}
[Fact]
public void Unknown_template_id_falls_back_to_ats_minimal()
{
var result = _renderer.Render(SampleDocument("unknown"), "not-a-real-template", "Jamie Rivera", "Backend Engineer", null);
Assert.Equal("ats-minimal", result.TemplateId);
}
[Fact]
public void Html_encodes_user_supplied_content_to_prevent_injection()
{
var document = SampleDocument("ats-minimal");
document.Summary[0] = "<script>alert(1)</script>";
var result = _renderer.Render(document, "ats-minimal", "Jamie Rivera", "Backend Engineer", null);
Assert.DoesNotContain("<script>alert(1)</script>", result.Html);
Assert.Contains("&lt;script&gt;", result.Html);
}
public static IEnumerable<object[]> TemplateIds() => AllTemplateIds.Select(id => new object[] { id });
}
@@ -0,0 +1,77 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using JobTrackerApi.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.IdentityModel.Protocols;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Tokens;
using Moq;
using Xunit;
namespace JobTrackerApi.Tests;
public sealed class MicrosoftTokenValidatorTests
{
private static (IConfiguration Config, Mock<IConfigurationManager<OpenIdConnectConfiguration>> ConfigManager, SymmetricSecurityKey Key) BuildHarness()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> { ["Auth:MicrosoftClientId"] = "client-123" })
.Build();
var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("super-secret-signing-key-super-secret"));
var oidc = new OpenIdConnectConfiguration();
oidc.SigningKeys.Add(signingKey);
var configManager = new Mock<IConfigurationManager<OpenIdConnectConfiguration>>();
configManager.Setup(x => x.GetConfigurationAsync(It.IsAny<CancellationToken>())).ReturnsAsync(oidc);
return (config, configManager, signingKey);
}
[Fact]
public async Task ValidateAsync_accepts_tenant_scoped_issuer_and_maps_oid_to_subject()
{
var (config, configManager, signingKey) = BuildHarness();
var token = new JwtSecurityTokenHandler().WriteToken(new JwtSecurityToken(
issuer: "https://login.microsoftonline.com/9f2c1e3a-tenant/v2.0",
audience: "client-123",
claims: new[]
{
new Claim("oid", "ms-subject-1"),
new Claim("email", "demo@example.com"),
new Claim("given_name", "Demo"),
new Claim("family_name", "User"),
new Claim("name", "Demo User"),
},
expires: DateTime.UtcNow.AddMinutes(10),
signingCredentials: new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256)));
var validator = new MicrosoftTokenValidator(config, configManager.Object);
var result = await validator.ValidateAsync(token);
Assert.Equal("ms-subject-1", result.Subject);
Assert.Equal("demo@example.com", result.Email);
Assert.True(result.EmailVerified);
Assert.Equal("Demo", result.GivenName);
Assert.Equal("User", result.FamilyName);
}
[Fact]
public async Task ValidateAsync_rejects_non_microsoft_issuer()
{
var (config, configManager, signingKey) = BuildHarness();
var token = new JwtSecurityTokenHandler().WriteToken(new JwtSecurityToken(
issuer: "https://evil.example.com/v2.0",
audience: "client-123",
claims: new[] { new Claim("oid", "ms-subject-1") },
expires: DateTime.UtcNow.AddMinutes(10),
signingCredentials: new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256)));
var validator = new MicrosoftTokenValidator(config, configManager.Object);
await Assert.ThrowsAsync<InvalidOperationException>(() => validator.ValidateAsync(token));
}
}
+177 -6
View File
@@ -19,15 +19,17 @@ public sealed class AuthController : ControllerBase
private readonly ITokenService _tokens;
private readonly IAppEmailSender _email;
private readonly IGoogleTokenValidator _googleTokens;
private readonly IMicrosoftTokenValidator _microsoftTokens;
private readonly ILogger<AuthController> _logger;
public AuthController(IConfiguration cfg, UserManager<ApplicationUser> users, ITokenService tokens, IAppEmailSender email, IGoogleTokenValidator googleTokens, ILogger<AuthController> logger)
public AuthController(IConfiguration cfg, UserManager<ApplicationUser> users, ITokenService tokens, IAppEmailSender email, IGoogleTokenValidator googleTokens, IMicrosoftTokenValidator microsoftTokens, ILogger<AuthController> logger)
{
_cfg = cfg;
_users = users;
_tokens = tokens;
_email = email;
_googleTokens = googleTokens;
_microsoftTokens = microsoftTokens;
_logger = logger;
}
@@ -37,12 +39,14 @@ public sealed class AuthController : ControllerBase
{
var requireAuth = _cfg.GetValue("Auth:Require", false);
var googleEnabled = !string.IsNullOrWhiteSpace((_cfg["Auth:GoogleClientId"] ?? string.Empty).Trim());
var microsoftEnabled = !string.IsNullOrWhiteSpace((_cfg["Auth:MicrosoftClientId"] ?? string.Empty).Trim());
var allowRegistration = _cfg.GetValue("Auth:AllowRegistration", false);
return Ok(new
{
requireAuth,
googleEnabled,
microsoftEnabled,
localEnabled = true,
allowRegistration,
});
@@ -52,6 +56,7 @@ public sealed class AuthController : ControllerBase
public sealed record RegisterRequest(string Email, string Password, bool RememberMe = true);
public sealed record AuthSessionResult(bool Authenticated, string Provider);
public sealed record GoogleLinkDto(bool Linked, string? Email, DateTimeOffset? LinkedAt);
public sealed record MicrosoftLinkDto(bool Linked, string? Email, DateTimeOffset? LinkedAt);
public sealed record MeResult(
string Provider,
string? Id,
@@ -64,7 +69,8 @@ public sealed class AuthController : ControllerBase
string? ProfileCvStructureJson,
string? AvatarImageDataUrl,
IList<string> Roles,
GoogleLinkDto? GoogleLink);
GoogleLinkDto? GoogleLink,
MicrosoftLinkDto? MicrosoftLink);
private const int MaxAvatarBytes = 1_000_000;
private static readonly HashSet<string> AllowedAvatarExtensions = new(StringComparer.OrdinalIgnoreCase)
{
@@ -72,6 +78,7 @@ public sealed class AuthController : ControllerBase
};
public sealed record UpdateProfileRequest(string? Email, string? UserName, string? FirstName, string? LastName, string? DisplayName, string? ProfileCvText, string? ProfileCvStructureJson);
public sealed record GoogleTokenRequest(string Token, bool RememberMe = true);
public sealed record MicrosoftTokenRequest(string Token, bool RememberMe = true);
[HttpPost("login")]
[AllowAnonymous]
@@ -155,7 +162,24 @@ public sealed class AuthController : ControllerBase
if (user is null)
{
return Unauthorized("This Google account is not linked to a Jobbjakt user yet.");
if (!google.EmailVerified || string.IsNullOrWhiteSpace(google.Email))
{
return Unauthorized("This Google account is not linked to a Jobbjakt user yet.");
}
var allowRegistration = _cfg.GetValue("Auth:AllowRegistration", false);
if (!allowRegistration)
{
return Unauthorized("This Google account is not linked to a Jobbjakt user yet.");
}
user = new ApplicationUser { UserName = google.Email, Email = google.Email, EmailConfirmed = true };
var created = await _users.CreateAsync(user);
if (!created.Succeeded)
{
return BadRequest(string.Join("; ", created.Errors.Select(e => e.Description)));
}
_logger.LogInformation("Created new user via Google sign-up for {Email}", google.Email);
}
if (string.IsNullOrWhiteSpace(user.GoogleSubject) || !string.Equals(user.GoogleSubject, google.Subject, StringComparison.Ordinal))
@@ -173,6 +197,74 @@ public sealed class AuthController : ControllerBase
return Ok(new AuthSessionResult(true, "google"));
}
[HttpPost("microsoft/exchange")]
[AllowAnonymous]
[EnableRateLimiting("auth-login")]
public async Task<ActionResult<AuthSessionResult>> ExchangeMicrosoftToken([FromBody] MicrosoftTokenRequest request, CancellationToken cancellationToken)
{
var token = (request.Token ?? string.Empty).Trim();
if (token.Length == 0) return BadRequest("Microsoft token is required.");
MicrosoftTokenPrincipal microsoft;
try
{
microsoft = await _microsoftTokens.ValidateAsync(token, cancellationToken);
}
catch (Exception ex)
{
return Unauthorized(ex.Message);
}
var user = await _users.Users.FirstOrDefaultAsync(
x => x.MicrosoftSubject == microsoft.Subject || (!string.IsNullOrWhiteSpace(microsoft.Email) && x.MicrosoftEmail == microsoft.Email),
cancellationToken);
if (user is null && microsoft.EmailVerified && !string.IsNullOrWhiteSpace(microsoft.Email))
{
user = await _users.FindByEmailAsync(microsoft.Email);
if (user is not null)
{
_logger.LogInformation("Auto-linking Microsoft sign-in for existing local account {Email}", microsoft.Email);
}
}
if (user is null)
{
if (!microsoft.EmailVerified || string.IsNullOrWhiteSpace(microsoft.Email))
{
return Unauthorized("This Microsoft account is not linked to a Jobbjakt user yet.");
}
var allowRegistration = _cfg.GetValue("Auth:AllowRegistration", false);
if (!allowRegistration)
{
return Unauthorized("This Microsoft account is not linked to a Jobbjakt user yet.");
}
user = new ApplicationUser { UserName = microsoft.Email, Email = microsoft.Email, EmailConfirmed = true };
var created = await _users.CreateAsync(user);
if (!created.Succeeded)
{
return BadRequest(string.Join("; ", created.Errors.Select(e => e.Description)));
}
_logger.LogInformation("Created new user via Microsoft sign-up for {Email}", microsoft.Email);
}
if (string.IsNullOrWhiteSpace(user.MicrosoftSubject) || !string.Equals(user.MicrosoftSubject, microsoft.Subject, StringComparison.Ordinal))
{
user.MicrosoftSubject = microsoft.Subject;
user.MicrosoftEmail = microsoft.Email;
user.MicrosoftLinkedAt ??= DateTimeOffset.UtcNow;
user.DisplayName ??= TrimOrNull(microsoft.Name);
user.FirstName ??= TrimOrNull(microsoft.GivenName);
user.LastName ??= TrimOrNull(microsoft.FamilyName);
await _users.UpdateAsync(user);
}
await SignInWithAppSessionAsync(user, request.RememberMe, cancellationToken);
return Ok(new AuthSessionResult(true, "microsoft"));
}
[HttpPost("logout")]
public IActionResult Logout()
{
@@ -202,7 +294,11 @@ public sealed class AuthController : ControllerBase
var email = User.FindFirstValue(ClaimTypes.Email) ?? User.FindFirstValue("email");
var sub = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
var iss = User.FindFirstValue("iss") ?? string.Empty;
var provider = iss.Contains("accounts.google.com", StringComparison.OrdinalIgnoreCase) ? "google" : "external";
var provider = iss.Contains("accounts.google.com", StringComparison.OrdinalIgnoreCase)
? "google"
: iss.Contains("login.microsoftonline.com", StringComparison.OrdinalIgnoreCase)
? "microsoft"
: "external";
return Ok(new MeResult(
Provider: provider,
@@ -216,7 +312,8 @@ public sealed class AuthController : ControllerBase
ProfileCvStructureJson: null,
AvatarImageDataUrl: null,
Roles: Array.Empty<string>(),
GoogleLink: provider == "google" ? new GoogleLinkDto(false, email, null) : null));
GoogleLink: provider == "google" ? new GoogleLinkDto(false, email, null) : null,
MicrosoftLink: provider == "microsoft" ? new MicrosoftLinkDto(false, email, null) : null));
}
[HttpPut("profile")]
@@ -322,6 +419,76 @@ public sealed class AuthController : ControllerBase
return NoContent();
}
[HttpPost("microsoft/link")]
[Authorize(AuthenticationSchemes = "local")]
public async Task<ActionResult<MicrosoftLinkDto>> LinkMicrosoft([FromBody] MicrosoftTokenRequest request, CancellationToken cancellationToken)
{
var user = await _users.GetUserAsync(User);
if (user is null)
{
return Unauthorized();
}
var token = (request.Token ?? string.Empty).Trim();
if (token.Length == 0) return BadRequest("Microsoft token is required.");
MicrosoftTokenPrincipal microsoft;
try
{
microsoft = await _microsoftTokens.ValidateAsync(token, cancellationToken);
}
catch (Exception ex)
{
return BadRequest(ex.Message);
}
var conflict = await _users.Users
.Where(x => x.Id != user.Id)
.FirstOrDefaultAsync(x => x.MicrosoftSubject == microsoft.Subject || (!string.IsNullOrWhiteSpace(microsoft.Email) && x.MicrosoftEmail == microsoft.Email), cancellationToken);
if (conflict is not null)
{
return Conflict("That Microsoft account is already linked to another Jobbjakt user.");
}
user.MicrosoftSubject = microsoft.Subject;
user.MicrosoftEmail = microsoft.Email;
user.MicrosoftLinkedAt = DateTimeOffset.UtcNow;
user.DisplayName ??= TrimOrNull(microsoft.Name);
user.FirstName ??= TrimOrNull(microsoft.GivenName);
user.LastName ??= TrimOrNull(microsoft.FamilyName);
var result = await _users.UpdateAsync(user);
if (!result.Succeeded)
{
return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description)));
}
return Ok(new MicrosoftLinkDto(true, user.MicrosoftEmail, user.MicrosoftLinkedAt));
}
[HttpDelete("microsoft/link")]
[Authorize(AuthenticationSchemes = "local")]
public async Task<IActionResult> UnlinkMicrosoft()
{
var user = await _users.GetUserAsync(User);
if (user is null)
{
return Unauthorized();
}
user.MicrosoftSubject = null;
user.MicrosoftEmail = null;
user.MicrosoftLinkedAt = null;
var result = await _users.UpdateAsync(user);
if (!result.Succeeded)
{
return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description)));
}
return NoContent();
}
[HttpPost("avatar")]
[Authorize(AuthenticationSchemes = "local")]
[RequestSizeLimit(MaxAvatarBytes)]
@@ -571,6 +738,10 @@ public sealed class AuthController : ControllerBase
GoogleLink: new GoogleLinkDto(
Linked: !string.IsNullOrWhiteSpace(user.GoogleSubject),
Email: user.GoogleEmail,
LinkedAt: user.GoogleLinkedAt));
LinkedAt: user.GoogleLinkedAt),
MicrosoftLink: new MicrosoftLinkDto(
Linked: !string.IsNullOrWhiteSpace(user.MicrosoftSubject),
Email: user.MicrosoftEmail,
LinkedAt: user.MicrosoftLinkedAt));
}
}
@@ -71,8 +71,9 @@ public sealed class ProfileCvController : ControllerBase
private readonly ICvPdfExporter _cvPdfExporter;
private readonly ICvProcessingQueue _cvProcessingQueue;
private readonly IAppEmailSender _emailSender;
private readonly ICareerProfileService _careerProfileService;
public ProfileCvController(UserManager<ApplicationUser> users, ISummarizerService aiService, JobTrackerContext db, AppPaths paths, ILogger<ProfileCvController>? logger = null, ICvAiClassifier? cvAiClassifier = null, ICvAiNormalizer? cvAiNormalizer = null, ICvTemplateRenderer? cvTemplateRenderer = null, ICvPdfExporter? cvPdfExporter = null, ICvProcessingQueue? cvProcessingQueue = null, IAppEmailSender? emailSender = null)
public ProfileCvController(UserManager<ApplicationUser> users, ISummarizerService aiService, JobTrackerContext db, AppPaths paths, ILogger<ProfileCvController>? logger = null, ICvAiClassifier? cvAiClassifier = null, ICvAiNormalizer? cvAiNormalizer = null, ICvTemplateRenderer? cvTemplateRenderer = null, ICvPdfExporter? cvPdfExporter = null, ICvProcessingQueue? cvProcessingQueue = null, IAppEmailSender? emailSender = null, ICareerProfileService? careerProfileService = null)
{
_users = users;
_aiService = aiService;
@@ -85,6 +86,7 @@ public sealed class ProfileCvController : ControllerBase
_cvPdfExporter = cvPdfExporter ?? new ThrowingCvPdfExporter();
_cvProcessingQueue = cvProcessingQueue ?? NoOpCvProcessingQueue.Instance;
_emailSender = emailSender ?? NoOpEmailSender.Instance;
_careerProfileService = careerProfileService ?? new CareerProfileService(db);
}
private sealed class NoOpEmailSender : IAppEmailSender
@@ -114,7 +116,13 @@ public sealed class ProfileCvController : ControllerBase
public string? Language { get; set; }
}
public sealed record ParseCvRequest(string? Text);
public sealed record CvTemplateDescriptor(string Id, string Title, string Tone, string AccentColor, string PreviewTagline, string PreviewSummary, List<string> PreviewBullets);
// LayoutFamily/AtsRating formalize the template catalog as data (career-workspace-implementation-roadmap.md
// Phase F3): "single-column" templates read top-to-bottom with no CSS grid split, so an ATS parser's
// extraction order matches visual order (High). "sidebar" templates use a CSS grid column split
// (competitor research flagged this pattern -- Canva's floating-box layouts -- as the #1 ATS risk
// factor), so they're rated Medium even though our structured-data rendering keeps them far safer
// than a canvas tool's undefined reading order.
public sealed record CvTemplateDescriptor(string Id, string Title, string Tone, string AccentColor, string PreviewTagline, string PreviewSummary, List<string> PreviewBullets, string LayoutFamily, string AtsRating);
public sealed record ProfileCvPreviewDto(string TemplateId, string Html, string SuggestedFileName, string FullText, string RewrittenText, string? SectionName, StructuredCvProfile StructuredCv, TailoredCvDocument Document, string? TargetRole, int? JobApplicationId);
public sealed record CvRewriteFailureDto(string Code, string Message, string? Detail = null, string? LastAiError = null);
@@ -172,6 +180,7 @@ public sealed class ProfileCvController : ControllerBase
result.StructuredCv.Metadata.ProfileVersion = (user.CurrentCvProfileVersion ?? 0) + 1;
result.StructuredCv.Metadata.AppliedExtractionRunId = run.Id;
result.StructuredCv.Metadata.UpdatedAtUtc = DateTimeOffset.UtcNow;
await _careerProfileService.SaveVersionAsync(user.Id, result.StructuredCv, "upload", HttpContext.RequestAborted);
var structuredJson = StructuredCvProfileJson.Serialize(result.StructuredCv);
run.RawExtractedText = result.RawText;
@@ -571,12 +580,12 @@ public sealed class ProfileCvController : ControllerBase
{
return new[]
{
new CvTemplateDescriptor("ats-minimal", "ATS Minimal", "Scanner-friendly", "slate", "Compact, direct, and easy to parse.", "Best for broad application flows and recruiter scanning.", new List<string> { "Tight hierarchy", "Keyword-friendly", "Low visual risk" }),
new CvTemplateDescriptor("harvard", "Harvard", "Traditional", "brick", "Formal and restrained.", "Good for conservative hiring flows or academic-adjacent applications.", new List<string> { "Classic serif rhythm", "Strong chronology", "Credible tone" }),
new CvTemplateDescriptor("auckland", "Auckland", "Modern sidebar", "emerald", "Sharper highlights with a contemporary cadence.", "Pulls key strengths into a faster visual scan.", new List<string> { "Sidebar details", "Compact highlights", "Modern contrast" }),
new CvTemplateDescriptor("edinburgh", "Edinburgh", "Editorial", "plum", "More personality without losing clarity.", "Useful when the CV should feel polished and distinctive.", new List<string> { "Premium spacing", "Stronger personality", "Readable density" }),
new CvTemplateDescriptor("monarch", "Monarch", "Executive", "#7c2d12", "High-contrast leadership emphasis.", "Works well for senior, strategic, or client-facing roles.", new List<string> { "Executive summary weight", "Premium accenting", "Decision-maker friendly" }),
new CvTemplateDescriptor("fjord", "Fjord", "Technical", "#0f4c5c", "Calm, dense, technical layout.", "Optimized for engineering resumes with richer project and skills detail.", new List<string> { "Technical depth", "Dense but readable", "Practical hierarchy" }),
new CvTemplateDescriptor("ats-minimal", "ATS Minimal", "Scanner-friendly", "slate", "Compact, direct, and easy to parse.", "Best for broad application flows and recruiter scanning.", new List<string> { "Tight hierarchy", "Keyword-friendly", "Low visual risk" }, "single-column", "High"),
new CvTemplateDescriptor("harvard", "Harvard", "Traditional", "brick", "Formal and restrained.", "Good for conservative hiring flows or academic-adjacent applications.", new List<string> { "Classic serif rhythm", "Strong chronology", "Credible tone" }, "single-column", "High"),
new CvTemplateDescriptor("auckland", "Auckland", "Modern sidebar", "emerald", "Sharper highlights with a contemporary cadence.", "Pulls key strengths into a faster visual scan.", new List<string> { "Sidebar details", "Compact highlights", "Modern contrast" }, "sidebar", "Medium"),
new CvTemplateDescriptor("edinburgh", "Edinburgh", "Editorial", "plum", "More personality without losing clarity.", "Useful when the CV should feel polished and distinctive.", new List<string> { "Premium spacing", "Stronger personality", "Readable density" }, "sidebar", "Medium"),
new CvTemplateDescriptor("monarch", "Monarch", "Executive", "#7c2d12", "High-contrast leadership emphasis.", "Works well for senior, strategic, or client-facing roles.", new List<string> { "Executive summary weight", "Premium accenting", "Decision-maker friendly" }, "single-column", "High"),
new CvTemplateDescriptor("fjord", "Fjord", "Technical", "#0f4c5c", "Calm, dense, technical layout.", "Optimized for engineering resumes with richer project and skills detail.", new List<string> { "Technical depth", "Dense but readable", "Practical hierarchy" }, "sidebar", "Medium"),
};
}
@@ -841,6 +850,7 @@ public sealed class ProfileCvController : ControllerBase
structuredCv.Metadata.ProfileVersion = (user.CurrentCvProfileVersion ?? 0) + 1;
structuredCv.Metadata.AppliedExtractionRunId = run.Id;
structuredCv.Metadata.UpdatedAtUtc = DateTimeOffset.UtcNow;
await _careerProfileService.SaveVersionAsync(user.Id, structuredCv, trigger, cancellationToken);
var structuredJson = StructuredCvProfileJson.Serialize(structuredCv);
run.StructuredProfileJson = structuredJson;
@@ -981,6 +991,7 @@ public sealed class ProfileCvController : ControllerBase
structuredCv.Metadata.ProfileVersion = (user.CurrentCvProfileVersion ?? 0) + 1;
structuredCv.Metadata.AppliedExtractionRunId = run.Id;
structuredCv.Metadata.UpdatedAtUtc = DateTimeOffset.UtcNow;
await _careerProfileService.SaveVersionAsync(user.Id, structuredCv, run.Trigger, cancellationToken);
var structuredJson = StructuredCvProfileJson.Serialize(structuredCv);
run.RawExtractedText = rawText;
+26 -4
View File
@@ -37,6 +37,7 @@ builder.Services.AddSingleton<ICvProcessingQueue, CvProcessingQueue>();
builder.Services.AddTransient<ProfileCvController>();
builder.Services.AddSingleton<ICvTemplateRenderer, CvTemplateRenderer>();
builder.Services.AddSingleton<ICvPdfExporter, PlaywrightCvPdfExporter>();
builder.Services.AddScoped<ICareerProfileService, CareerProfileService>();
builder.Services.AddSingleton<AppPaths>();
builder.Services.AddSingleton<IStartupReadiness, StartupReadiness>();
@@ -162,6 +163,7 @@ builder.Services.AddSingleton<IJobCvMatchService, JobCvMatchService>();
builder.Services.AddSingleton<ICvAiClassifier, CvAiClassifier>();
builder.Services.AddSingleton<ICvAiNormalizer, CvAiNormalizer>();
builder.Services.AddSingleton<IGoogleTokenValidator, GoogleTokenValidator>();
builder.Services.AddSingleton<IMicrosoftTokenValidator, MicrosoftTokenValidator>();
builder.Services.AddScoped<IGmailOAuthService, GmailOAuthService>();
builder.Services.AddSingleton<IGmailJobMatchingService, GmailJobMatchingService>();
builder.Services.AddSingleton<IGmailCorrespondenceEnrichmentService, NoOpGmailCorrespondenceEnrichmentService>();
@@ -209,6 +211,7 @@ builder.Services.AddScoped<JobImportService>();
var requireAuth = builder.Configuration.GetValue("Auth:Require", false);
var googleClientId = (builder.Configuration["Auth:GoogleClientId"] ?? "").Trim();
var microsoftClientId = (builder.Configuration["Auth:MicrosoftClientId"] ?? "").Trim();
var jwtKey = (builder.Configuration["Auth:JwtKey"] ?? "").Trim();
var ephemeralJwtKey = false;
@@ -234,7 +237,7 @@ builder.Services.AddAuthentication(options =>
{
options.ForwardDefaultSelector = ctx =>
{
if (string.IsNullOrWhiteSpace(googleClientId))
if (string.IsNullOrWhiteSpace(googleClientId) && string.IsNullOrWhiteSpace(microsoftClientId))
return "local";
var auth = ctx.Request.Headers.Authorization.ToString();
@@ -250,9 +253,11 @@ builder.Services.AddAuthentication(options =>
{
var jwt = handler.ReadJwtToken(token);
var iss = jwt.Issuer ?? "";
return iss is "accounts.google.com" or "https://accounts.google.com"
? "google"
: "local";
if (!string.IsNullOrWhiteSpace(googleClientId) && iss is "accounts.google.com" or "https://accounts.google.com")
return "google";
if (!string.IsNullOrWhiteSpace(microsoftClientId) && iss.StartsWith("https://login.microsoftonline.com/", StringComparison.OrdinalIgnoreCase))
return "microsoft";
return "local";
}
catch
{
@@ -322,6 +327,23 @@ if (!string.IsNullOrWhiteSpace(googleClientId))
});
}
if (!string.IsNullOrWhiteSpace(microsoftClientId))
{
builder.Services.AddAuthentication().AddJwtBearer("microsoft", options =>
{
// Validate Microsoft (Entra ID / personal account) ID tokens as bearer tokens.
// "common" authority + ValidateIssuer=false: multi-tenant issuer varies per tenant id.
options.Authority = "https://login.microsoftonline.com/common/v2.0";
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = false,
ValidateAudience = true,
ValidAudience = microsoftClientId,
ValidateLifetime = true,
};
});
}
builder.Services.AddAuthorization(options =>
{
if (requireAuth)
@@ -0,0 +1,169 @@
using System.Text.RegularExpressions;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using Microsoft.EntityFrameworkCore;
namespace JobTrackerApi.Services;
// Career Workspace foundation (see docs/career-workspace-implementation-roadmap.md, Phase F1).
// Bounded to the profile/CV domain -- job tracking is untouched. Every existing read path still
// goes through ApplicationUser.ProfileCvStructureJson (dual-write window); this service is the
// single place stable item IDs and normalized dates get assigned, and where profile history is
// captured so it's never silently overwritten on the next rebuild/improve/upload.
public interface ICareerProfileService
{
// Mutates the given profile in place (assigns missing item IDs + normalized dates), persists
// it as the current CareerProfile snapshot plus an append-only CareerProfileVersion row, and
// returns the same profile so the caller can go on to serialize it into the legacy column.
Task<StructuredCvProfile> SaveVersionAsync(string ownerUserId, StructuredCvProfile profile, string source, CancellationToken cancellationToken);
}
public sealed class CareerProfileService : ICareerProfileService
{
private readonly JobTrackerContext _db;
public CareerProfileService(JobTrackerContext db)
{
_db = db;
}
public async Task<StructuredCvProfile> SaveVersionAsync(string ownerUserId, StructuredCvProfile profile, string source, CancellationToken cancellationToken)
{
AssignStableIds(profile);
NormalizeDates(profile);
var json = StructuredCvProfileJson.Serialize(profile);
var existing = await _db.CareerProfiles.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
if (existing is null)
{
existing = new CareerProfile
{
OwnerUserId = ownerUserId,
ProfileJson = json,
Version = 1,
CreatedAtUtc = DateTimeOffset.UtcNow,
UpdatedAtUtc = DateTimeOffset.UtcNow,
};
_db.CareerProfiles.Add(existing);
}
else
{
existing.ProfileJson = json;
existing.Version += 1;
existing.UpdatedAtUtc = DateTimeOffset.UtcNow;
}
await _db.SaveChangesAsync(cancellationToken);
_db.CareerProfileVersions.Add(new CareerProfileVersion
{
OwnerUserId = ownerUserId,
CareerProfileId = existing.Id,
Version = existing.Version,
ProfileJson = json,
Source = string.IsNullOrWhiteSpace(source) ? "manual" : source.Trim(),
CreatedAtUtc = DateTimeOffset.UtcNow,
});
await _db.SaveChangesAsync(cancellationToken);
return profile;
}
private static void AssignStableIds(StructuredCvProfile profile)
{
foreach (var job in profile.Jobs)
{
if (string.IsNullOrWhiteSpace(job.Id)) job.Id = NewItemId();
}
foreach (var education in profile.Education)
{
if (string.IsNullOrWhiteSpace(education.Id)) education.Id = NewItemId();
}
foreach (var certification in profile.Certifications)
{
if (string.IsNullOrWhiteSpace(certification.Id)) certification.Id = NewItemId();
}
foreach (var project in profile.Projects)
{
if (string.IsNullOrWhiteSpace(project.Id)) project.Id = NewItemId();
}
}
private static string NewItemId() => Guid.NewGuid().ToString("N")[..12];
private static void NormalizeDates(StructuredCvProfile profile)
{
foreach (var job in profile.Jobs)
{
job.StartDate = CvDateNormalizer.TryParseYearMonth(job.Start);
job.EndDate = job.IsCurrent ? null : CvDateNormalizer.TryParseYearMonth(job.End);
}
foreach (var education in profile.Education)
{
education.StartDate = CvDateNormalizer.TryParseYearMonth(education.Start);
education.EndDate = CvDateNormalizer.TryParseYearMonth(education.End);
}
foreach (var certification in profile.Certifications)
{
certification.DateNormalized = CvDateNormalizer.TryParseYearMonth(certification.Date);
}
foreach (var project in profile.Projects)
{
project.StartDate = CvDateNormalizer.TryParseYearMonth(project.Start);
project.EndDate = CvDateNormalizer.TryParseYearMonth(project.End);
}
}
}
// Best-effort free-string -> "YYYY-MM" parser. Never throws, never loses data: the original
// free-string field is always kept alongside whatever this returns (null on anything it can't
// confidently parse -- callers must not treat null as "no date", only as "unparsed").
public static class CvDateNormalizer
{
private static readonly Dictionary<string, int> MonthNames = new(StringComparer.OrdinalIgnoreCase)
{
["jan"] = 1, ["january"] = 1,
["feb"] = 2, ["february"] = 2,
["mar"] = 3, ["march"] = 3,
["apr"] = 4, ["april"] = 4,
["may"] = 5,
["jun"] = 6, ["june"] = 6,
["jul"] = 7, ["july"] = 7,
["aug"] = 8, ["august"] = 8,
["sep"] = 9, ["sept"] = 9, ["september"] = 9,
["oct"] = 10, ["october"] = 10,
["nov"] = 11, ["november"] = 11,
["dec"] = 12, ["december"] = 12,
};
public static string? TryParseYearMonth(string? raw)
{
var value = (raw ?? string.Empty).Trim();
if (value.Length == 0) return null;
if (value.Equals("present", StringComparison.OrdinalIgnoreCase) || value.Equals("current", StringComparison.OrdinalIgnoreCase)) return null;
// "2020" -> January is an assumption we don't want to make silently; year-only stays unparsed.
var monthYear = Regex.Match(value, @"^(?<month>[A-Za-z]+)\.?\s+(?<year>\d{4})$");
if (monthYear.Success && MonthNames.TryGetValue(monthYear.Groups["month"].Value, out var month))
{
return $"{monthYear.Groups["year"].Value}-{month:D2}";
}
var slash = Regex.Match(value, @"^(?<month>\d{1,2})/(?<year>\d{4})$");
if (slash.Success)
{
var m = int.Parse(slash.Groups["month"].Value);
if (m is >= 1 and <= 12) return $"{slash.Groups["year"].Value}-{m:D2}";
}
var isoLike = Regex.Match(value, @"^(?<year>\d{4})-(?<month>\d{1,2})$");
if (isoLike.Success)
{
var m = int.Parse(isoLike.Groups["month"].Value);
if (m is >= 1 and <= 12) return $"{isoLike.Groups["year"].Value}-{m:D2}";
}
return null;
}
}
@@ -0,0 +1,100 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using Microsoft.IdentityModel.Protocols;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Tokens;
namespace JobTrackerApi.Services;
public sealed record MicrosoftTokenPrincipal(string Subject, string? Email, bool EmailVerified, string? GivenName, string? FamilyName, string? Name);
public interface IMicrosoftTokenValidator
{
Task<MicrosoftTokenPrincipal> ValidateAsync(string idToken, CancellationToken cancellationToken = default);
}
public sealed class MicrosoftTokenValidator : IMicrosoftTokenValidator
{
private readonly IConfiguration _cfg;
private readonly IConfigurationManager<OpenIdConnectConfiguration> _configManager;
public MicrosoftTokenValidator(IConfiguration cfg)
{
_cfg = cfg;
// "common" endpoint: accepts both personal Microsoft accounts and work/school (Entra ID) tenants.
_configManager = new ConfigurationManager<OpenIdConnectConfiguration>(
"https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration",
new OpenIdConnectConfigurationRetriever());
}
public MicrosoftTokenValidator(IConfiguration cfg, IConfigurationManager<OpenIdConnectConfiguration> configManager)
{
_cfg = cfg;
_configManager = configManager;
}
public async Task<MicrosoftTokenPrincipal> ValidateAsync(string idToken, CancellationToken cancellationToken = default)
{
var audience = (_cfg["Auth:MicrosoftClientId"] ?? "").Trim();
if (string.IsNullOrWhiteSpace(audience))
{
throw new InvalidOperationException("Microsoft sign-in is not configured.");
}
var config = await _configManager.GetConfigurationAsync(cancellationToken);
var handler = new JwtSecurityTokenHandler
{
// The handler's default inbound claim map rewrites "oid"/"tid" to long Microsoft
// schema URIs (an AAD-specific quirk not shared by Google's OIDC claims) -- keep
// claim names as issued so FindFirst("oid") below actually matches.
MapInboundClaims = false,
};
// ponytail: multi-tenant "common" app -- each tenant's issuer embeds its own tenant id
// (https://login.microsoftonline.com/{tenantId}/v2.0), so issuer is checked by shape below
// rather than pinned to one value. Signature/audience/lifetime are still fully validated.
var principal = handler.ValidateToken(idToken, new TokenValidationParameters
{
ValidateIssuer = false,
ValidateAudience = true,
ValidAudience = audience,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
IssuerSigningKeys = config.SigningKeys,
ClockSkew = TimeSpan.FromMinutes(2),
}, out var validatedToken);
var issuer = (validatedToken as JwtSecurityToken)?.Issuer ?? principal.FindFirst("iss")?.Value ?? "";
if (!IsMicrosoftIssuer(issuer))
{
throw new InvalidOperationException("Microsoft token has an unexpected issuer.");
}
var subject = principal.FindFirst("oid")?.Value?.Trim()
?? principal.FindFirst(JwtRegisteredClaimNames.Sub)?.Value?.Trim()
?? principal.FindFirst(ClaimTypes.NameIdentifier)?.Value?.Trim();
if (string.IsNullOrWhiteSpace(subject))
{
throw new InvalidOperationException("Microsoft token is missing a subject.");
}
var email = principal.FindFirst("email")?.Value?.Trim()
?? principal.FindFirst(ClaimTypes.Email)?.Value?.Trim()
?? principal.FindFirst("preferred_username")?.Value?.Trim();
return new MicrosoftTokenPrincipal(
Subject: subject,
Email: email,
// Microsoft ID tokens don't carry an email_verified claim; presence of an email claim
// from a signature-validated token is treated as verified, same trust level Microsoft's
// own APIs give it.
EmailVerified: !string.IsNullOrWhiteSpace(email),
GivenName: principal.FindFirst("given_name")?.Value?.Trim(),
FamilyName: principal.FindFirst("family_name")?.Value?.Trim(),
Name: principal.FindFirst("name")?.Value?.Trim() ?? principal.Identity?.Name?.Trim()
);
}
private static bool IsMicrosoftIssuer(string issuer)
=> issuer.StartsWith("https://login.microsoftonline.com/", StringComparison.OrdinalIgnoreCase)
&& issuer.EndsWith("/v2.0", StringComparison.OrdinalIgnoreCase);
}
@@ -241,6 +241,9 @@ public static class StartupInitializationExtensions
`GoogleSubject` longtext NULL,
`GoogleEmail` longtext NULL,
`GoogleLinkedAt` datetime(6) NULL,
`MicrosoftSubject` longtext NULL,
`MicrosoftEmail` longtext NULL,
`MicrosoftLinkedAt` datetime(6) NULL,
PRIMARY KEY (`Id`)
) CHARACTER SET=utf8mb4;
@@ -353,7 +356,10 @@ public static class StartupInitializationExtensions
"AvatarImageDataUrl" TEXT NULL,
"GoogleSubject" TEXT NULL,
"GoogleEmail" TEXT NULL,
"GoogleLinkedAt" TEXT NULL
"GoogleLinkedAt" TEXT NULL,
"MicrosoftSubject" TEXT NULL,
"MicrosoftEmail" TEXT NULL,
"MicrosoftLinkedAt" TEXT NULL
);
""");
@@ -431,6 +437,9 @@ public static class StartupInitializationExtensions
EnsureColumn(conn, "AspNetUsers", "GoogleSubject", "ALTER TABLE AspNetUsers ADD COLUMN GoogleSubject TEXT NULL;");
EnsureColumn(conn, "AspNetUsers", "GoogleEmail", "ALTER TABLE AspNetUsers ADD COLUMN GoogleEmail TEXT NULL;");
EnsureColumn(conn, "AspNetUsers", "GoogleLinkedAt", "ALTER TABLE AspNetUsers ADD COLUMN GoogleLinkedAt TEXT NULL;");
EnsureColumn(conn, "AspNetUsers", "MicrosoftSubject", "ALTER TABLE AspNetUsers ADD COLUMN MicrosoftSubject TEXT NULL;");
EnsureColumn(conn, "AspNetUsers", "MicrosoftEmail", "ALTER TABLE AspNetUsers ADD COLUMN MicrosoftEmail TEXT NULL;");
EnsureColumn(conn, "AspNetUsers", "MicrosoftLinkedAt", "ALTER TABLE AspNetUsers ADD COLUMN MicrosoftLinkedAt TEXT NULL;");
static void EnsureUserRuleSettingsTable(DbConnection c)
{
@@ -614,10 +623,45 @@ public static class StartupInitializationExtensions
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_TailoredCvDrafts_JobApplicationId" ON "TailoredCvDrafts" ("JobApplicationId");""");
}
// Career Workspace foundation (docs/career-workspace-implementation-roadmap.md
// Phase F1). Additive tables: ApplicationUser.ProfileCvStructureJson remains the
// authoritative column every existing read path uses; these mirror it so future
// Career Workspace features (variants, history UI) have a real table to build on.
static void EnsureCareerProfileTables(DbConnection c)
{
Exec(c, """
CREATE TABLE IF NOT EXISTS "CareerProfiles" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_CareerProfiles" PRIMARY KEY AUTOINCREMENT,
"OwnerUserId" TEXT NOT NULL,
"ProfileJson" TEXT NOT NULL,
"Version" INTEGER NOT NULL,
"CreatedAtUtc" TEXT NOT NULL,
"UpdatedAtUtc" TEXT NOT NULL
);
""");
Exec(c, """
CREATE TABLE IF NOT EXISTS "CareerProfileVersions" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_CareerProfileVersions" PRIMARY KEY AUTOINCREMENT,
"OwnerUserId" TEXT NOT NULL,
"CareerProfileId" INTEGER NOT NULL,
"Version" INTEGER NOT NULL,
"ProfileJson" TEXT NOT NULL,
"Source" TEXT NOT NULL,
"CreatedAtUtc" TEXT NOT NULL,
CONSTRAINT "FK_CareerProfileVersions_CareerProfiles_CareerProfileId" FOREIGN KEY ("CareerProfileId") REFERENCES "CareerProfiles" ("Id") ON DELETE CASCADE
);
""");
Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_CareerProfiles_OwnerUserId" ON "CareerProfiles" ("OwnerUserId");""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CareerProfileVersions_OwnerUserId_CareerProfileId_Version" ON "CareerProfileVersions" ("OwnerUserId", "CareerProfileId", "Version");""");
}
EnsureGmailConnectionsTable(conn);
EnsureMicrosoftGraphConnectionsTable(conn);
EnsureImapConnectionsTable(conn);
EnsureCvTables(conn);
EnsureCareerProfileTables(conn);
// Legacy DB signature: migration history exists (AddCorrespondence applied), but 20260310195000 not recorded,
// and at least one of the new columns already exists.
@@ -743,6 +787,22 @@ public static class StartupInitializationExtensions
EnsureMySqlAutoIncrementPrimaryKey(conn, "CvUploadArtifacts", "Id");
EnsureMySqlAutoIncrementPrimaryKey(conn, "CvExtractionRuns", "Id");
EnsureMySqlAutoIncrementPrimaryKey(conn, "TailoredCvDrafts", "Id");
EnsureMySqlAutoIncrementPrimaryKey(conn, "CareerProfiles", "Id");
EnsureMySqlAutoIncrementPrimaryKey(conn, "CareerProfileVersions", "Id");
if (!MySqlIndexExists(conn, "CareerProfiles", "IX_CareerProfiles_OwnerUserId"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "CREATE UNIQUE INDEX `IX_CareerProfiles_OwnerUserId` ON `CareerProfiles` (`OwnerUserId`);";
cmd.ExecuteNonQuery();
}
if (!MySqlIndexExists(conn, "CareerProfileVersions", "IX_CareerProfileVersions_OwnerUserId_CareerProfileId_Version"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "CREATE INDEX `IX_CareerProfileVersions_OwnerUserId_CareerProfileId_Version` ON `CareerProfileVersions` (`OwnerUserId`, `CareerProfileId`, `Version`);";
cmd.ExecuteNonQuery();
}
// Ad-hoc columns for the tables Migrate() creates (Companies/JobApplications/
// Correspondences/Attachments) -- re-run once more after Migrate() below via
@@ -757,6 +817,9 @@ public static class StartupInitializationExtensions
EnsureMySqlColumn(conn, "AspNetUsers", "GoogleSubject", "ALTER TABLE `AspNetUsers` ADD COLUMN `GoogleSubject` longtext NULL;");
EnsureMySqlColumn(conn, "AspNetUsers", "GoogleEmail", "ALTER TABLE `AspNetUsers` ADD COLUMN `GoogleEmail` longtext NULL;");
EnsureMySqlColumn(conn, "AspNetUsers", "GoogleLinkedAt", "ALTER TABLE `AspNetUsers` ADD COLUMN `GoogleLinkedAt` datetime NULL;");
EnsureMySqlColumn(conn, "AspNetUsers", "MicrosoftSubject", "ALTER TABLE `AspNetUsers` ADD COLUMN `MicrosoftSubject` longtext NULL;");
EnsureMySqlColumn(conn, "AspNetUsers", "MicrosoftEmail", "ALTER TABLE `AspNetUsers` ADD COLUMN `MicrosoftEmail` longtext NULL;");
EnsureMySqlColumn(conn, "AspNetUsers", "MicrosoftLinkedAt", "ALTER TABLE `AspNetUsers` ADD COLUMN `MicrosoftLinkedAt` datetime NULL;");
if (!HasMySqlTable(conn, "RuleSettings"))
{
@@ -965,6 +1028,41 @@ public static class StartupInitializationExtensions
cmd.ExecuteNonQuery();
}
// Career Workspace foundation (docs/career-workspace-implementation-roadmap.md
// Phase F1). Additive: AspNetUsers.ProfileCvStructureJson stays authoritative
// for every existing read path during the dual-write window.
if (!HasMySqlTable(conn, "CareerProfiles"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `CareerProfiles` (
`Id` int NOT NULL AUTO_INCREMENT,
`OwnerUserId` varchar(255) NOT NULL,
`ProfileJson` longtext NOT NULL,
`Version` int NOT NULL,
`CreatedAtUtc` datetime(6) NOT NULL,
`UpdatedAtUtc` datetime(6) NOT NULL,
PRIMARY KEY (`Id`)
);";
cmd.ExecuteNonQuery();
}
if (!HasMySqlTable(conn, "CareerProfileVersions"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `CareerProfileVersions` (
`Id` int NOT NULL AUTO_INCREMENT,
`OwnerUserId` varchar(255) NOT NULL,
`CareerProfileId` int NOT NULL,
`Version` int NOT NULL,
`ProfileJson` longtext NOT NULL,
`Source` varchar(100) NOT NULL,
`CreatedAtUtc` datetime(6) NOT NULL,
PRIMARY KEY (`Id`),
CONSTRAINT `FK_CareerProfileVersions_CareerProfiles_CareerProfileId` FOREIGN KEY (`CareerProfileId`) REFERENCES `CareerProfiles` (`Id`) ON DELETE CASCADE
);";
cmd.ExecuteNonQuery();
}
if (!MySqlIndexExists(conn, "Companies", "IX_Companies_OwnerUserId"))
{
using var cmd = conn.CreateCommand();
+3 -2
View File
@@ -19,14 +19,15 @@
},
"Auth": {
"Require": true,
"AllowRegistration": false,
"AllowRegistration": true,
"JwtKey": "CHANGE_ME_DEV_ONLY_LONG_RANDOM_SECRET",
"JwtIssuer": "JobTrackerApi",
"JwtAudience": "job-tracker-ui",
"JwtExpiresMinutes": 720,
"AdminEmail": "admin@example.com",
"AdminPassword": "CHANGE_ME_STRONG_DEV_PASSWORD",
"GoogleClientId": "CHANGE_ME_GOOGLE_CLIENT_ID"
"GoogleClientId": "723556162227-llqucvpog2esn1dutmtvuul1lv374or6.apps.googleusercontent.com",
"MicrosoftClientId": "CHANGE_ME_MICROSOFT_CLIENT_ID"
},
"App": {
"PublicBaseUrl": "https://jobs.cesnimda.uk"
+3
View File
@@ -16,4 +16,7 @@ public sealed class ApplicationUser : IdentityUser
public string? GoogleSubject { get; set; }
public string? GoogleEmail { get; set; }
public DateTimeOffset? GoogleLinkedAt { get; set; }
public string? MicrosoftSubject { get; set; }
public string? MicrosoftEmail { get; set; }
public DateTimeOffset? MicrosoftLinkedAt { get; set; }
}
+29
View File
@@ -0,0 +1,29 @@
namespace JobTrackerApi.Models;
// The Career Workspace's durable source of truth. Bounded to one row per user for now
// (see career-workspace-implementation-roadmap.md Phase F1) -- ProfileJson mirrors
// ApplicationUser.ProfileCvStructureJson during the dual-write window and will become
// authoritative once every read path is migrated (F5).
public sealed class CareerProfile
{
public int Id { get; set; }
public string OwnerUserId { get; set; } = string.Empty;
public string ProfileJson { get; set; } = string.Empty;
public int Version { get; set; }
public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
}
// Append-only history: one row per save, so profile edits are never silently lost.
public sealed class CareerProfileVersion
{
public int Id { get; set; }
public string OwnerUserId { get; set; } = string.Empty;
public int CareerProfileId { get; set; }
public CareerProfile? CareerProfile { get; set; }
public int Version { get; set; }
public string ProfileJson { get; set; } = string.Empty;
// Where this version came from: "upload" | "rebuild" | "improve" | "reprocess" | "parse" | "manual".
public string Source { get; set; } = string.Empty;
public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
}
+16
View File
@@ -49,11 +49,19 @@ public sealed class StructuredCvContact
public sealed class StructuredCvJob
{
// Stable item ID (assigned by CareerProfileService on first save). Required for CV variants
// to reference "this job" across profile edits, instead of by array position. Nullable/empty
// on freshly-parsed or legacy data until the first save assigns it.
public string? Id { get; set; }
public string? Title { get; set; }
public string? Company { get; set; }
public string? Location { get; set; }
public string? Start { get; set; }
public string? End { get; set; }
// Best-effort "YYYY-MM" normalization of Start/End, computed alongside Id assignment.
// Null when Start/End can't be parsed; the free-string fields above remain the display source.
public string? StartDate { get; set; }
public string? EndDate { get; set; }
public bool IsCurrent { get; set; }
public List<string> Bullets { get; set; } = new();
public List<string> Skills { get; set; } = new();
@@ -61,31 +69,39 @@ public sealed class StructuredCvJob
public sealed class StructuredCvEducation
{
public string? Id { get; set; }
public string? Qualification { get; set; }
public string? QualificationLevel { get; set; }
public string? Institution { get; set; }
public string? Location { get; set; }
public string? Start { get; set; }
public string? End { get; set; }
public string? StartDate { get; set; }
public string? EndDate { get; set; }
public List<string> Details { get; set; } = new();
}
public sealed class StructuredCvCertification
{
public string? Id { get; set; }
public string? Name { get; set; }
public string? Issuer { get; set; }
public string? Location { get; set; }
public string? Date { get; set; }
public string? DateNormalized { get; set; }
public List<string> Details { get; set; } = new();
}
public sealed class StructuredCvProject
{
public string? Id { get; set; }
public string? Name { get; set; }
public string? Role { get; set; }
public string? Location { get; set; }
public string? Start { get; set; }
public string? End { get; set; }
public string? StartDate { get; set; }
public string? EndDate { get; set; }
public List<string> Bullets { get; set; } = new();
public List<string> Skills { get; set; } = new();
}
+7 -5
View File
@@ -19,8 +19,9 @@ services:
- Auth__JwtKey=${AUTH_JWT_KEY}
- Auth__AdminEmail=${AUTH_ADMIN_EMAIL}
- Auth__AdminPassword=${AUTH_ADMIN_PASSWORD}
# Optional: allow Google ID-token bearer auth
# Optional: allow Google / Microsoft ID-token bearer auth (sign-in, not mail access)
- Auth__GoogleClientId=${AUTH_GOOGLE_CLIENT_ID}
- Auth__MicrosoftClientId=${AUTH_MICROSOFT_CLIENT_ID}
- Google__GmailClientSecret=${GOOGLE_GMAIL_CLIENT_SECRET}
- Google__GmailRedirectUri=${GOOGLE_GMAIL_REDIRECT_URI}
# Optional: Outlook / Microsoft 365 mail linking via Microsoft Graph
@@ -59,13 +60,14 @@ 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`.
# Next'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}
- NEXT_PUBLIC_GOOGLE_CLIENT_ID=${AUTH_GOOGLE_CLIENT_ID}
- NEXT_PUBLIC_MICROSOFT_CLIENT_ID=${AUTH_MICROSOFT_CLIENT_ID}
# Optional override; default in production is `/api`
- REACT_APP_API_BASE_URL=${REACT_APP_API_BASE_URL}
- NEXT_PUBLIC_API_BASE_URL=${REACT_APP_API_BASE_URL}
ports:
- "3000:80"
depends_on:
@@ -0,0 +1,118 @@
# Career Workspace — Implementation Roadmap (ADR + sequencing)
**Date:** 2026-07-12
**Status:** Active. This is the execution plan that turns the three strategy docs into code, incrementally, without breaking the live app.
**Source of truth:** `cv-builder-competitor-deep-research.md`, `cv-builder-product-teardown.md`, `career-workspace-product-strategy.md`.
---
## Product boundary (non-negotiable, per owner directive 2026-07-12)
The **primary product stays Job Search & Application Management.** The Career Workspace is a **bounded supporting domain** that makes applications better. Rules that constrain every change below:
- Job applications **reference** career outputs; they do **not own** them.
- Career profile data is **independent of any single job**.
- Themes/templates are **independent of application workflow**.
- Do **not** remove, replace, or redesign job-tracking functionality. The goal is **integration**, not replacement.
```
Main product: Job Search & Application Management
└─ tracking · applications · company research · interviews · matching
Supporting: Career Workspace (bounded domain)
└─ profile · CV variants · tailored CVs · cover letters · outputs · AI assist
Integration: JobApplication ──references──▶ TailoredApplication ──uses──▶ CvVariant ──inherits──▶ CareerProfile
```
The tracker references a career output by id; deleting a job never deletes profile/variant data; a variant exists with zero jobs attached.
---
## Migration mechanics (how this codebase actually changes schema)
**No EF migrations in practice.** Schema is provisioned by the idempotent raw-SQL reconciler in `StartupInitializationExtensions.InitializeJobTrackerAsync`. Both dialects are hand-maintained:
- **SQLite (dev):** `Ensure*Table(conn)` helpers with `CREATE TABLE IF NOT EXISTS` + `EnsureColumn` guards, called in the `useSqliteBootstrap` branch (near `EnsureCvTables`).
- **MySQL/MariaDB (prod):** `if (!HasMySqlTable(...))` blocks + `EnsureMySqlColumn`, in the `else` branch.
**Every new table therefore needs BOTH dialect blocks + a `DbSet` + `OnModelCreating` config** (query filter + indexes). The EF `ModelSnapshot` is known-stale; do not rely on `Migrate()` to create Career tables — add them to the reconciler.
**Rule for this roadmap:** all new tables are **additive**. Nothing drops `ApplicationUser.ProfileCvStructureJson` or `TailoredCvDrafts` until the new path is proven in production and dual-read has run clean. Backwards-compatible at every step.
---
## Target entities (only what earns its place)
| Entity | Purpose | Replaces / relates | When |
|---|---|---|---|
| `CareerProfile` | The durable source of truth; one per user (N later). Structure canonical, text derived. | Lifts `ApplicationUser.ProfileCvStructureJson` off the Identity row | Phase F1 |
| `CareerProfileVersion` | Append-only history of the profile; diff/restore | New (teardown gap) | F1 (table) / F4 (UI) |
| `CvVariant` | Persistent lens on the profile: selections + per-item overrides, named ("Backend focus") | Generalizes today's single implicit CV | F2 |
| `CvVersion` | Append-only history of a variant (every generation/save) | New; kills overwrite-anxiety | F2 |
| `CvTheme` (data, not table yet) | Declarative theme descriptor; sibling input to renderer, never welded to content | Formalizes `CvTemplateRenderer` catalog | F3 |
| `TailoredApplication` | Variant × JobApplication event; gap-driven tweaks; **the integration seam** | Reworks `TailoredCvDraft`'s job-lock into a reference | F2/F5 |
**Schema amendments forced by the teardown (apply at table-creation time — cheap now, brutal later):**
1. **Stable item IDs** on every profile item (jobs, bullets, skills, education, projects). Without them, variant lineage / "update everywhere" / inherit-with-override are unimplementable.
2. **Normalized dates** (`YYYY-MM` + `isCurrent`) — backfilled during migration by best-effort parse of the free-string `Start`/`End`. Timeline / tenure / skills-recency depend on it.
3. **Structure is canonical; text is derived** — stated in code. Match scoring reads structure (F5), not raw text.
---
## Phased sequence (each phase ships independently, app stays green)
### Phase F0 — Immediate fixes (no architecture) ✅ shipped this session
- **OAuth CV lockout fixed** (`ProfilePage.tsx`): CV controls gated on a new `canEditCv` (any authenticated user) instead of `isLocal`. Identity/password fields remain local-only. Unblocks every Google/Microsoft user.
### Phase F1 — Career Profile as first-class data (backwards-compatible seam)
1. Add `CareerProfile` + `CareerProfileVersion` tables to the reconciler (both dialects), `DbSet`s, query filters, indexes.
2. Introduce `ICareerProfileService` — the single accessor for the user's structured profile. Initially **dual-writes**: persists to the new `CareerProfiles` table **and** keeps `ApplicationUser.ProfileCvStructureJson` in sync (so nothing that still reads the column breaks).
3. Assign stable item IDs + normalize dates when materializing a profile into the new table (one-time backfill on first read/write per user).
4. Point `ProfileCvController` read/write paths at the service (behavior identical).
5. Test: round-trip a profile through the service; assert IDs stable across saves, dates normalized, legacy column still mirrored.
**Exit:** new table is authoritative; column is a mirror. Zero user-visible change.
### Phase F2 — Variants + versions (additive, opt-in)
1. `CvVariant` + `CvVersion` tables. A variant references a `CareerProfile` and holds selection/override JSON keyed by item ID.
2. `TailoredApplication` table: `(CvVariantId, JobApplicationId)` — the reference seam. Job references the tailored output; does not own the variant.
3. Backfill: each existing `TailoredCvDraft` → one `CvVariant` (job-linked) + its render options extracted toward a theme ref, wrapped in a `TailoredApplication`. Legacy `TailoredCvDrafts` retained (dual-read) until proven.
4. Endpoints under `/career/*` grow beside legacy `/profile-cv/*` and the job-scoped tailored routes.
**Exit:** variants exist; regeneration writes a new `CvVersion` instead of overwriting.
### Phase F3 — Rendering as data (theme catalog)
1. Extract the `CvTemplateRenderer` template catalog into a `CvTheme` descriptor set (id, label, layout shell, font stack, palette, heading style, default accent, ATS rating). Content pipeline (`RenderMainSections` + section renderers) already theme-agnostic — formalize the boundary: **renderer consumes `(document, theme)`; theme carries no CV logic.**
2. Layout **shells** stay a small fixed set (single-column, sidebar, rail, bordered); a theme selects a shell + tokens. Adding a theme that reuses a shell = a data entry, no code.
3. Golden test: render each existing template id before/after; assert byte-identical output (pure refactor).
4. **Deferred:** external template engine (Scriban) + user/marketplace themes — only when a marketplace is real (strategy §9). Do not add the dependency now.
**Exit:** adding a theme on an existing layout is data-only; PDF output unchanged.
### Phase F4 — CV Builder UX (structured editor + tailoring workspace)
- Structured profile editor route (`/career/profile`): section forms, per-bullet reorder, provenance-flagged review queue for low-confidence fields.
- From-scratch + paste-text entry paths (removes import-only dead end).
- Tailoring workspace route (`/jobs/:id/tailor`): JD gap chips ↔ variant editor ↔ live themed preview ↔ rescore. Retire the modal editor. **Reached from a job** (integration), full page.
- **Career** becomes a top-level nav pillar (Profile · Variants); "CV" ceases to be a nav noun. Tracker nav untouched.
### Phase F5 — AI depth + integration
- Diff / accept-reject on every AI mutation (rewrite, improve, generation). Trust primitive; also kills silent hallucination.
- Retarget `JobCvMatchService` to read from structured profile (not raw text) — removes the dual-truth divergence; validates F1's model with an existing consumer.
- Fact-constraint validator (novel named-entity/number flagging) on generation.
- Persist interview-prep / fit outputs (stop regenerating).
### Phase F6+ — Career Workspace horizons (architecture-ready, not built now)
Cover letters (profile+JD+thread) · ATS plain-text view · skills-gap analytics · public profile (theme over live profile) · DOCX adapter · portfolio/LinkedIn adapters. Each ≈ one `IOutputAdapter` + optional theme. Keep the adapter boundary swap-clean (Reactive Resume abandoned server-Chromium for cost — our Playwright PDF adapter must stay replaceable without touching themes).
---
## Risk register
| Risk | Mitigation |
|---|---|
| Live-DB schema migration breaks prod | Additive-only tables; dual-write/dual-read; never drop legacy until proven; test reconciler against a prod DB copy |
| Backfill mis-parses free-string dates | Best-effort normalize, keep original string alongside normalized fields; never lose data |
| PDF output regression in theme refactor | Golden byte-identical test before merge |
| Scope creep into job-tracker redesign | Boundary rules above; tracker code out of scope |
| Two sources of truth diverge (F1 interim) | Time-box the dual-write window; F5 retargets the last consumer (match scoring) then column is dropped |
## Working-summary convention
Each session updates **## Completed / ## Current / ## Next / ## Decisions** in the response. This file is the durable plan; the running summary is the session delta.
+328
View File
@@ -0,0 +1,328 @@
# Career Workspace — Product Strategy & Roadmap
**Date:** 2026-07-12
**Inputs:** `docs/cv-builder-competitor-deep-research.md` (market), `docs/cv-builder-product-teardown.md` (as-is audit), CV Builder Architecture Proposal (target design).
**Nature:** This is a *decision document*, not a summary. Where the research allowed multiple directions, this document picks one and says why. Intended as the foundation for Fable 5 product planning, the engineering roadmap, UX design, and architecture decisions.
---
## 1. Product Vision
### The one-sentence vision
> **Jobbjakt is the workspace where your career data lives once and works everywhere — every CV, cover letter, application, and interview prep session is generated from, and feeds back into, one structured career profile.**
### Evaluating "the CV is not the product; the profile is"
The premise is **correct but incomplete**. Correct: the teardown proved every current defect traces to documents-as-primary-objects, and the research proved no competitor owns the profile-first position. Incomplete: a profile sitting still is a database, not a product. What users pay attention to (and would pay money for) is what the profile *does under pressure* — during an active job search.
**Refined thesis:** the product is the **loop**, and the profile is its engine:
```
Career Profile → tailored application (CV + letter, gap-driven)
↑ ↓
learns from ← application outcome (tracked, email-observed)
```
Every competitor owns at most one arc of this loop. Teal has profile→tailor but weak rendering and stops at "Applied." Design-led builders have rendering but no career data. Reactive Resume has clean documents but zero context. **Nobody closes the loop — and we already own its rarest arc: the outcome-observation side (Gmail intelligence, status suggestions, tracking).** That asymmetry is the strategy. We don't build a CV builder and add tracking; we already have the tracking-and-intelligence layer competitors can't cheaply replicate, and we're adding the one commodity piece (a good CV builder) they all have.
### The problems we solve
1. **Fragmentation:** the modern search runs across 57 tools (builder, tracker, spreadsheet, keyword checker, letter writer, prep notes) with career data re-typed into each. One profile, one workspace.
2. **Tailoring cost:** serious applicants need 35 tailored variants; today that means duplicated documents that rot independently. Variants inherit from the profile; the profile updates everywhere.
3. **Blind applications:** applicants don't know what the ATS sees or what the JD wants. Match scoring + gap-driven tailoring + ATS-view make it visible.
4. **Career amnesia:** achievements evaporate between searches. A durable, versioned profile means the next search starts warm.
5. **Trust:** the incumbent market monetises desperation (subscription traps, resume hostage-taking, secret AI caps). We are structurally incapable of it (self-hostable, export-always-free) and say so.
### Unique value proposition
> "Other tools build you a document. Jobbjakt runs your search: one career profile that generates every tailored CV and cover letter, scores you against every job you track, reads the recruiter's reply, and preps you for the interview it lands — without ever holding your data hostage."
---
## 2. Product Positioning
### Landscape verdict (from the research, compressed to decisions)
| Cluster | Their strength | Their structural weakness | Our exploit |
|---|---|---|---|
| Design-led (Novoresume, Resume.io, Kickresume, Enhancv) | Template quality, polish, distribution | Predatory billing = trust vacuum; zero career context; PDF-only lock-ins | Fair-exit guarantee + career context they'd have to rebuild their revenue model to match |
| Free/OSS (Reactive Resume, FlowCV) | Trust, editor UX, price | Document-centric; no job context; thin AI | Match their fairness, exceed them on the loop |
| Workspace (Teal) | The loop's left half; tracker distribution | Weak rendering; stops at the application; shallow profile | Deeper profile + real rendering + post-application intelligence |
| Canvas (Canva) | Design freedom | 72% ATS failure — architecturally unfixable | "ATS-safe by construction," provable |
### Why choose us over each
- **Over Novoresume/Resume.io:** same-or-better output quality, no watermark, no hostage, and your CV knows about your job search.
- **Over FlowCV:** everything FlowCV's editor does, plus the variant you're editing is scored against the actual job and updated from your profile.
- **Over Reactive Resume:** same data-ownership ethos (self-hostable, schema published), plus tailoring, tracking, and email intelligence a document tool can't have.
- **Over Teal:** your tailored CV actually looks professional, your profile is deep (provenance, versions), and the workspace doesn't go silent after you click Apply — it reads the interview invite and preps you for it.
- **Over Canva:** structured data → deterministic parse order → every theme ATS-safe by construction, with a "view as ATS" proof.
### Positioning statement
> **For active job seekers who apply to many roles, Jobbjakt is the AI career workspace that turns one structured career profile into every tailored application — unlike resume builders that produce disconnected documents and trackers that abandon you after you apply, Jobbjakt closes the loop from profile to interview, and never holds your data hostage.**
Two positioning disciplines that follow:
1. **Never market as "resume builder."** In that category we have 6 templates against Novoresume's brand; we'd be comparison-shopped on our weakest axis. The category is *career workspace* — young enough (GigForge/ResumeTrakr-tier entrants only) that a quality product can define it.
2. **Trust is a feature with a spec:** export always free (PDF+JSON minimum), no watermark, visible AI quotas, published profile schema, one-click full data export. Each is cheap for us and revenue-model-breaking for incumbents — the definition of a durable wedge.
---
## 3. Target User Personas
Priority order is a decision, not a list: build for P1/P2 first — they exercise the loop hardest and match the product's existing DNA (the current user *is* P2).
### P1 — "Marcus," the high-volume applicant (primary)
Mid-career, applying to 3080 roles across 23 title families. **Goals:** volume without quality collapse; know which applications are alive. **Frustrations:** re-tailoring is an hour per application so he stops tailoring; loses track of threads; every builder wants $25/mo at his most broke moment. **Workflow:** job boards → save → tailor (or guiltily don't) → apply → chaos of follow-ups. **Needs most:** variants with cheap tailoring (gap chips, one-click fixes), tracker + email auto-linking (exists), follow-up nudges (exists), match score triage ("which of these 12 saved jobs am I actually competitive for?").
### P2 — "Dana," the technical professional (primary; the current user)
Developer/engineer, deliberate search, 515 applications. **Goals:** precision-target roles; CV that survives both ATS and senior-engineer skim; own her data. **Frustrations:** builders dumb down technical content; canvas tools break ATS; distrusts SaaS lock-in. **Workflow:** deep JD reading → heavy per-role tailoring → tracked follow-ups. **Needs most:** structured editor with real skill taxonomy, ATS-view, self-hosting/schema/JSON export, constrained AI that never invents seniority, diff-on-every-AI-edit.
### P3 — "Sofia," the career changer (secondary)
Moving between fields; same history must tell two different stories. **Goals:** reframe, not fabricate. **Frustrations:** single-CV tools force one narrative; AI rewrites drift into fiction. **Needs most:** multiple *free-standing* variants from one profile (the sharpest validation of profile/variant separation), transferable-skills surfacing, fact-constrained rewriting with visible diffs, cover letters that carry the reframing story.
### P4 — "Tom," the recent graduate (secondary)
First real CV, thin content, no existing document. **Goals:** credible one-pager fast. **Frustrations:** import-only tools (our current dead end — teardown §2) assume a CV exists; blank-page paralysis. **Needs most:** from-scratch guided creation, content prompts ("what did you build at university?"), one great simple theme, honest bullet suggestions. *Strategic note: Tom is the acquisition persona (students share tools; Kickresume's student program precedent) but must not drive architecture — his needs are a subset of P1P3's editor.*
### P5 — "Priya," the international applicant (secondary)
Cross-border applications; conventions differ (photo/no-photo, page norms, spelling, market-specific sections). **Needs most:** per-variant conventions (photo toggle exists; page mode exists), multilingual content support (rewrite `language` knob exists — rare head start), theme conventions per market, visa/eligibility custom sections. Mostly configuration breadth on top of the variant model, not new architecture.
### P6 — "Elena," the experienced executive (tertiary)
20+ years, 3-page history, selective applications. **Needs most:** selective inclusion per variant (long profile, curated projections — again the variant model), discreet public profile, quality typography. Buys or self-hosts on trust and polish. Serve architecturally, don't design for first.
**Pattern worth noticing:** P3, P5, P6 all reduce to "one deep profile, many selective projections." The personas independently re-derive the core architecture — good sign the architecture is right.
---
## 4. Core Product Model
```
CareerProfile ← the durable asset (one per user; N later)
│ everything you've ever done: jobs, bullets, skills, education, projects,
│ certs, languages; stable item IDs; normalized dates; provenance metadata
│ (confidence, source, review state); versioned; structure canonical
├──< CvVariant ← a persistent *lens* on the profile
│ │ "Backend-focused" / "Team-lead-focused" / "Career-change: data"
│ │ selections + overrides by item ID (inherits; profile edits flow in;
│ │ overrides tracked, revertible)
│ ├──< CvVersion ← history: every save/generation; diff/restore
│ └── ThemeRef → Theme ← SIBLING input: declarative manifest
│ (tokens, layout, section styles, page-break
│ rules, ATS rating). Never welded to content.
├──< TailoredApplication ← the *event*: variant × JobApplication
│ │ gap analysis (match score), per-application tweaks, its own versions
│ └── outcome feedback ← tracker status + Gmail intelligence
└──> Outputs (adapters; each = projection of profile/variant/application + theme)
PDF CV · DOCX CV (scoped) · ATS plain-text view · Cover letter
Public profile (live, not snapshot) · Portfolio/personal site
LinkedIn content · Interview prep (persisted; email-thread-aware)
```
**Relationships, precisely:**
- **Profile → Variant** is *inheritance with selection*: a variant names which profile items appear, in what order, with what per-item overrides. Edit a job title in the profile → every non-overridden variant reflects it. This single mechanism serves Marcus (volume), Sofia (two narratives), Elena (selective depth).
- **Variant → TailoredApplication** is *instantiation against a job*: the variant is durable and reusable; the tailored application is per-job, driven by the match-score gap list, and versioned so regeneration never destroys manual work (fixes the teardown's overwrite-anxiety root cause).
- **Theme** is orthogonal to all content. Any variant/application renders in any theme; switching is non-destructive by construction.
- **Outputs** are stateless projections through `IOutputAdapter<T>`. New output type ≈ one adapter + (sometimes) one theme. This is what makes Horizon-3 features (portfolio, LinkedIn, site) *cheap* instead of new products.
- **The loop closes** at TailoredApplication ← outcome: tracked status + email signals accumulate on the application, feeding analytics ("last 15 rejections wanted Kubernetes") and, eventually, tailoring suggestions.
Cover letters hang off TailoredApplication (they're per-job by nature), generated from profile + JD + (uniquely) the email thread context.
---
## 5. Feature Prioritisation
### MVP — "the loop works" (next major version)
| Feature | User value | Business value | Complexity | MoSCoW |
|---|---|---|---|---|
| Fix OAuth CV lockout (`isLocal` bug) | Unblocks all Google/MS users | Removes a dead-end for every new OAuth signup | Trivial | **Must (ship now, pre-MVP)** |
| CareerProfile + CvVariant + CvVersion migration (stable IDs, normalized dates) | Invisible now; everything later | The architecture bet | Large | **Must** |
| Structured profile editor (+ extraction review queue) | Career data becomes maintainable; provenance visible | Core differentiator vs. document tools | Large | **Must** |
| Theme engine port — best 34 of 6 templates as manifests | Non-destructive theme switching; quality floor | Unblocks marketplace/premium later | Large | **Must** |
| Tailoring workspace (JD gaps ↔ variant ↔ live preview ↔ rescore, full page) | The killer screen; ends modal editing | The demo that sells the product | Large | **Must** |
| Import (upload exists + paste-text) AND from-scratch creation | No user dead-ends at entry | Doubles addressable entry funnel (P4) | Medium | **Must** |
| Diff + accept/reject on every AI mutation | Trust; no silent fabrication | Anti-hallucination brand plank | SmallMed | **Must** |
| Fair-exit set: free PDF+JSON export, no watermark | Table stakes vs. OSS; trust wedge | Positioning proof | Small | **Must** |
| Live paginated preview (<300ms on 3-page CV) | FlowCV-bar editor feel | Retention; perceived quality | Medium | **Should** |
| Template gallery w/ visual previews + ATS rating per theme | Informed choice; ATS trust | Marketing surface | Small | **Should** |
| Persist interview prep / fit outputs | Work stops evaporating | Cheap retention | Small | **Should** |
| UI vocabulary cleanup (kill "reprocess/runs") | Comprehensibility | Polish | Trivial | **Should** |
| DOCX export (scoped single-column) | Loudest market complaint | Checklist parity | Medium | **Could** |
| Cover letter v1 (profile + JD) | Completes the application | Expected feature | Medium | **Could** |
| LinkedIn import | Onboarding speed | Funnel | Medium | **Not now** (parse fragility; paste-text covers 80%) |
### Version 2 — "the loop compounds"
| Feature | User value | Business value | Complexity | MoSCoW |
|---|---|---|---|---|
| Cover letters w/ email-thread context | Letters that reference the actual conversation | Unique-data moat begins | Medium | **Must (V2)** |
| ATS plain-text "what the parser sees" view | Anxiety-killer; provable claim | Marketing weapon vs. Canva/Enhancv | Small | **Must (V2)** |
| Skills-gap analytics across tracked JDs | "Your market wants X; you lack it" | Nobody has it; pure aggregation | SmallMed | **Must (V2)** |
| Email-aware interview prep (persisted, thread-fed) | Preps you for *this* interview | The post-application moat | Medium | **Should** |
| Public profile / share link (theme over live profile) | Always-current link for recruiters | Viral surface; premium candidate | Medium | **Should** |
| Fact-constraint validator (novel-entity flagging) on generation | Career integrity guarantee | Trust plank #2 | Medium | **Should** |
| Variant refresh/diff when profile changed (staleness UX on `CanonicalProfileVersion`) | Safe propagation | Completes inheritance story | Medium | **Should** |
| Published profile schema + JSON Resume import/export | Interop; technical-user trust | OSS goodwill (P2) | Small | **Should** |
| User-defined theme tokens (fonts, spacing) | Personalization | Premium candidate | Medium | **Could** |
| Multi-language variant support (leans on existing rewrite lang) | P5 unlock | Market breadth | Medium | **Could** |
### Future Vision — "the platform"
| Feature | User value | Business value | Complexity | MoSCoW |
|---|---|---|---|---|
| Portfolio / personal-site generation (adapters + themes) | Whole web presence from one profile | Category-defining | Large | **Should (V3)** |
| LinkedIn content generation (summary, about, posts) | Consistency across surfaces | Engagement between searches | SmallMed | **Should (V3)** |
| Career timeline & skills matrix visualizations | Self-knowledge; review prep | Differentiator; needs normalized dates (done in MVP) | Medium | **Could** |
| Theme marketplace (manifest sandbox enables it) | Choice explosion w/o our design time | Revenue share model | Large | **Could** |
| MCP/agent endpoint over profile | User's own AI agents read/write career data | Agent-native future (RR precedent) | Medium | **Could** |
| Multiple CareerProfiles per user | Portfolio careers, consultants | Niche but architecture-ready | Small (post-M1) | **Not now** |
| Coaching / marketplace of humans | — | Off-mission; different business | — | **Not now** |
| Auto-apply / one-click mass application | — | Reputation poison (research: spam arms race) | — | **Never** |
---
## 6. UX Strategy
### First-time user: "I need a CV" → professional PDF, one session
Target: **first rendered PDF < 10 minutes** (FlowCV bar), while quietly building a *profile*, not a document.
```
Sign up (OAuth, one click — bug fixed)
→ "How do you want to start?" [Upload CV] [Paste text] [Start fresh]
→ Import path: extraction runs live; user lands in REVIEW flow:
confidence-flagged cards ("We read this as… confirm/fix") —
provenance metadata finally earns its keep as visible trust
→ Fresh path: guided mini-wizard (contact → most recent job w/ bullet
prompts → education → skills) — enough for a one-pager, expandable later
→ Theme picker: 34 quality themes, visual gallery, ATS badge on each
→ Live preview appears WITH the user's real content immediately
→ Download PDF (free, no watermark) + nudge: "Track a job you're
applying to — we'll score this CV against it."
```
That final nudge is the workspace conversion moment: the PDF is the hook; the score-against-a-real-job is the "oh, this is different" beat. Teal's lesson (research §2.4): workspace-first onboarding with no artifact feels disorienting — so we produce the artifact first and reveal the system second.
### Returning user
- **Update profile:** Profile is a top-level destination (never again a settings card). Structured sections, inline edit, per-item provenance. Edits show a "3 variants use this item" ripple indicator.
- **New variant:** from profile or by cloning: pick items, name the lens ("Platform-eng focus"), pick theme. Variants list shows which jobs each has been used for.
- **Apply to a job:** job gets a Tailor action → tailoring workspace: JD + gap chips left, variant editor center, live preview right; one-click gap fixes (constrained rewrite + diff); export/attach; tracker updates.
- **Track progress:** existing dashboard/kanban continues; applications now show attached tailored version + match score at application time.
### Navigation (recommended)
```
Dashboard · Jobs (kanban/table) · Inbox (correspondence + review queue)
Career [NEW: Profile · Variants · (later) Public profile] · Settings
```
CV ceases to exist as a noun in the nav. **Career** is the pillar; documents are things you export from it. Tailoring workspace is a *route* (`/jobs/:id/tailor`), reached from a job — full page, never a modal (teardown's hardest UX finding).
### Dashboard design
Keep tracker widgets (pipeline, reminders, analytics). Add two career widgets: **Profile health** (completeness, unreviewed low-confidence fields, variants stale vs. profile) and **Match radar** (saved jobs ranked by score — Marcus's triage). Dashboard answers "what should I do next in my search?", not "here are your documents."
---
## 7. AI Strategy
Doctrine, from research §5's useful-vs-gimmick line: **AI operates on the user's real data (profile, JD, email thread), shows its work (diff, source), and never invents facts.** Anything that generates from nothing, or hides its edit, is out.
| Feature | User problem | AI solution | Complexity | Priority |
|---|---|---|---|---|
| Extraction + confidence review | Getting existing CV into structure is tedious | Parse → normalize → classify w/ per-field confidence; user confirms flagged fields (exists; needs the review UI) | UI only | **P0 (MVP)** |
| Gap-driven tailoring | Tailoring is an hour per job | Match gaps → one-click constrained rewrite per gap → live rescore (wires existing scorer + rewrite) | Medium (UI + orchestration) | **P0 (MVP) — the flagship** |
| Bullet improve w/ diff | Weak bullets; distrust of rewrites | Per-bullet improve, before/after diff, accept/reject; select-and-rephrase constraint | SmallMed | **P0 (MVP)** |
| Fact-constraint validator | Hallucinated seniority/numbers = career damage | Generation limited to profile facts; novel named-entity/number flagging | Medium | **P1 (V2)** |
| Cover letter from profile+JD+thread | Blank-page letters; generic AI letters | Grounded generation citing actual profile items and, in V2, the actual recruiter conversation | Medium | **P1 (V2)** |
| Skills-gap analytics | "Why am I being rejected?" | Aggregate JD demands across tracked jobs vs. profile skills | SmallMed | **P1 (V2)** |
| Email-aware interview prep | Prep is generic; the invite says what the panel covers | Prep generated from profile + JD + thread; persisted; STAR stories from user's own bullets | Medium | **P1 (V2) — the moat** |
| From-scratch content prompts | Blank-page paralysis (P4) | Section-aware questions ("what did you build?") → drafted bullets user edits | Small | **P2** |
| LinkedIn summary generation | Surface consistency | Another projection of the profile | Small | **P3** |
| Career advice chat | — | **Rejected as flagship**: unbounded scope, generic output, hallucination surface; revisit only as thin UI over the grounded features above | — | **Not now** |
| Resume "score out of 100" theater | — | **Rejected**: research shows these are engagement gimmicks; our match score is per-job and actionable instead | — | **Never** |
Operational: provider router stays (Gemini/Groq cloud default in prod, Ollama local fallback); **visible quota** in UI from day one of any metering (Kickresume's secret-cap backlash is the cautionary tale); all CV-content calls remain through the delimiter-fenced, injection-hardened sidecar.
---
## 8. Technical Direction
Confirms the Architecture Proposal with the teardown's amendments. Decision summary:
**Rebuild (replace):**
- Master-CV storage: `ApplicationUser.ProfileCvText/ProfileCvStructureJson` columns → `CareerProfile` table (+ `CareerProfileVersion`). Raw text demotes to derived artifact (search corpus, export). **Structure is canonical** — one truth, stated in code.
- `TailoredCvDraft``CvVariant` + `CvVersion` + `TailoredApplication`(job-linked variant use). Presentation leaves the content row: `ThemeRef` + per-variant render tokens.
- `CvTemplateRenderer`'s six C# string-builders → Scriban theme manifests (declarative: tokens, layout slots, section styles, explicit page-break rules, ATS rating). Port best 34; retire the rest.
- Tailored-CV UI out of `JobDetailsDialog` → dedicated routes.
**Keep (assets, per teardown §3):**
- Extraction pipeline (artifacts, versioned runs, provenance metadata) — becomes the review-queue engine.
- `JobCvMatchService` + `SkillTagger` — becomes the tailoring loop's engine (retarget corpus to read from structure, not raw text).
- Playwright PDF exporter — stays as the PDF adapter behind `IOutputAdapter<T>`; RR's Chromium-cost lesson says keep the boundary swap-clean, not swap now.
- FastAPI sidecar with fencing + provider router.
- All tracker/Gmail/analytics infrastructure — untouched; it's the moat.
**Migrate gradually:**
- Schema via the raw-SQL reconciler in additive steps: create new tables → backfill from user columns + drafts (assign stable item IDs, normalize dates *during* backfill — one-time cost, teardown amendment) → dual-read period → cut over → drop columns last.
- Endpoints: new `/career/*` API grows beside `/profile-cv/*`; old routes proxy then deprecate. Frontend adopts per-screen (profile editor first, tailoring workspace second).
- Versioning: append-only version rows (profile + variant), content-hash deduped; diff computed, not stored.
**Scalability posture:** current scale is single-server self-hosted; don't over-build. The two future-proofing investments that are cheap now and brutal later: stable item IDs and normalized dates (schema), and the adapter/theme boundaries (code). Everything else (queue-based rendering, multi-tenant sharding) is **Not now**.
---
## 9. Monetisation Opportunities
**Context decision first:** this is currently a self-hosted personal/OSS-style product; Stripe work (Wave 5) is deferred pending product decisions. Monetisation strategy is therefore designed now, implemented only if/when the product goes multi-user SaaS. Design it now anyway — pricing architecture shapes feature boundaries.
**The model, if/when SaaS: FlowCV's seam, our loop.** Free = full quality, singular. Paid = multiplicity + intelligence depth.
| | Free forever | Plus (~£46/mo — undercut Teal/Novoresume 35×) |
|---|---|---|
| Profile | Full, versioned, provenance | Same |
| Variants | 1 | Unlimited |
| Tailored applications | 3 active | Unlimited |
| Themes | All core themes | Same (+ future marketplace) |
| Export | PDF+JSON, unlimited, no watermark — **always** | + DOCX |
| AI | Metered monthly allowance, **visible quota** | High allowance, still visible |
| Tracker + Gmail | Full | Full |
| Public profile | — | Custom-slug live profile |
**Monetise:** variant multiplicity (the proven seam — value scales with search intensity, exactly when willingness-to-pay peaks, without degrading free quality), AI volume (real marginal cost; honest metering), public profile (ongoing hosted value), later marketplace themes (rev-share).
**Never monetise (the trust spec):** export of your own data, watermark removal (never watermark), the tracker (Teal proved free-tracker acquisition; ours feeds the loop), re-access to documents after cancellation (the anti-Novoresume guarantee — put it on the pricing page verbatim: *"Cancel and keep everything you made."*), secret AI caps (Kickresume's one-star engine).
**Not now:** coaching/human services (different business), auto-apply (never), premium template *tiers* before a marketplace exists (6 themes is too thin to split).
---
## 10. Final Recommendation
### Executive summary
**What it becomes:** the AI Career Workspace — one structured, versioned, provenance-aware career profile that generates every output of a job search (tailored CVs, cover letters, ATS views, public profile, interview prep) and learns from every outcome (tracking, email intelligence, match analytics). The CV builder is the visible front door; the loop is the product.
**Why:** the research shows a market that is huge (55k reviews on a single incumbent), broken on trust (F BBB ratings, hostage patterns), and architecturally stuck — design-led tools can't add career context without rebuilding their revenue model, and the one workspace player (Teal) has weak rendering and stops at the application. The teardown shows our codebase already owns the hardest, least-replicable half: tracking, Gmail intelligence, match scoring, hardened AI, extraction provenance. Every audited defect shares one root cause (documents-as-primary), fixable with one architecture (profile-as-source-of-truth) that four competitor architectures each validate a quarter of.
**How we win:** (1) close the loop nobody closes — profile → gap-driven tailoring → tracked outcome → interview prep from the actual recruiter thread; (2) make trust a spec, not a slogan — export-always-free, diff-on-every-AI-edit, visible quotas, ATS-view proof, published schema; (3) sequence ruthlessly — invisible data migration first, then the structured editor, then the tailoring workspace as the demo that defines the category.
### Recommended next steps — first 10 tasks
1. **Fix the OAuth `isLocal` CV lockout** (`ProfilePage.tsx:356`) — split identity-gates from feature-gates. Ship immediately; P0 bug independent of redesign.
2. **Freeze the CareerProfile schema v1** — profile/variant/version entities, stable item IDs, normalized dates (`YYYY-MM` + `isCurrent`), provenance carried over; publish as JSON Schema doc in `docs/`. (Design task; gates everything.)
3. **Write the migration + backfill** in the raw-SQL reconciler: new tables, backfill from `ProfileCvStructureJson` + `TailoredCvDraft` rows (IDs + date normalization during backfill), dual-read flag. Test against a prod DB copy.
4. **Retarget `JobCvMatchService` to read from structure** (not raw text) — kills the dual-truth divergence and validates the new model with an existing consumer.
5. **Author the Scriban theme-manifest schema** (tokens, layout, section styles, page-break rules, ATS rating) and **port ats-minimal** end-to-end through `IOutputAdapter<Pdf>` as the proving thread. Then port the next 23 best templates; retire the rest.
6. **Build the structured profile editor** (new `/career/profile` route): section forms, per-bullet rows w/ reorder, provenance-flagged review queue for low-confidence fields.
7. **Add from-scratch + paste-text entry paths** feeding the same editor/normalize pipeline; wire the onboarding flow of §6 (first PDF < 10 min).
8. **Ship diff/accept-reject on all AI mutations** (rewrite, improve, generation) — the trust primitive, small enough to land while 56 are in flight.
9. **Build the tailoring workspace route** (`/jobs/:id/tailor`): gap chips ↔ variant editor ↔ live themed preview ↔ rescore; retire the modal editor.
10. **Ship the fair-exit set + template gallery:** free PDF+JSON export everywhere, no-watermark guarantee stated in UI, visual theme gallery with ATS badges — the positioning made tangible in the product.
Tasks 14 are backend-quiet and parallelizable with 5; 69 are the visible product; 10 is polish that carries the strategy. This sequence matches the Architecture Proposal's phases with the teardown's amendments applied, and lands the MVP of §5 in full.
+327
View File
@@ -0,0 +1,327 @@
# CV Builder Competitor Deep Research
**Date:** 2026-07-12
**Purpose:** Product strategy input for the Jobbjakt CV Builder redesign and the long-term Career Workspace vision. Companion to the CV Builder Architecture Proposal (published artifact) — this document grounds those decisions in what the market actually does, where it fails, and where we can win.
**Audience:** Product/engineering discovery and planning (written to be consumable by AI planning agents and humans alike).
---
## 1. Executive Summary
The resume-builder market splits into four clusters:
| Cluster | Examples | Core bet | Core weakness |
|---|---|---|---|
| **Design-led builders** | Novoresume, Enhancv, Kickresume, Resume.io | Beautiful templates sell | Subscription-trap billing, resume-hostage lock-in, ATS-hostile layouts |
| **Free/OSS builders** | Reactive Resume, FlowCV | Trust + generosity win users | No career context; document-centric, not career-centric |
| **Workspace tools** | Teal | The job *search* is the product; resume is one artifact | Stops at the application; no interview/offer support; weak template design |
| **Generic design tools** | Canva | Design freedom | 72% of tested templates fail ATS parsing; wrong tool for the job |
**Five load-bearing findings:**
1. **Nobody owns the "career profile as source of truth" position.** Teal comes closest (structured fields → adapt per job) but its profile is shallow and its outputs stop at resume + cover letter. Every competitor is document-first: the resume file is the primary object, and career data is trapped inside it. Our planned architecture (CareerProfile → many outputs) is genuinely differentiated, and a crop of small 202526 entrants (Joberney's "one career profile," ResumeTrakr's "one source of truth") confirms the market has noticed the gap — but none has our integrated job-tracking + email-intelligence + match-scoring base.
2. **Billing malpractice is the #1 trust destroyer in this market.** Resume.io's $2.95-trial→$29.95/4-weeks auto-convert has produced an **F BBB rating** despite a 4.3 Trustpilot score; Novoresume blocks re-download of *already-paid-for* resumes when the subscription lapses; Kickresume cuts off "unlimited" AI mid-billing-cycle with undisclosed caps. The single cheapest differentiation available to us: **never hold the user's data hostage.** Free export, always, in every format we support.
3. **ATS anxiety is the dominant purchase driver.** Every top-ranked review criterion in 2026 is ATS pass rate. Canva's failure mode (floating text boxes → no guaranteed reading order) and Enhancv's (pretty multi-column layouts that parsers mangle) prove design and parseability pull in opposite directions unless the architecture enforces both. Because we render from structured data (not a canvas), **every theme we ship can be ATS-safe by construction** — a claim Canva/Enhancv architecturally cannot make.
4. **The winning editor model is structured-form + live preview** (FlowCV, Teal, Reactive Resume), not canvas editing (Canva) and not rigid wizard flows (Resume.io). Users tolerate — and actually prefer — forms when the preview updates instantly and section reorder is drag-and-drop. This validates our proposed editor architecture and warns against investing in inline/canvas editing.
5. **AI is table stakes but mostly shallow.** Every paid competitor has "AI bullet rewrite" and "cover letter generation." What reviewers consistently score as *genuinely useful* is narrower: job-description keyword gap analysis with actionable diffs (Teal, Rezi), and bullet-level improvement with before/after scoring (BeamJobs). What's scored as fluff: generic "improve my resume" rewrites and AI-generated content that needs heavy editing. We already have the hard part (job data + match scoring in the same system); competitors bolt keyword analysis onto a resume tool, we bolt a resume tool onto keyword analysis.
**Strategic posture in one line:** Don't compete as a resume builder. Compete as the only tool where the resume is *generated from* the same career data that tracks your applications, scores your matches, and reads your recruiter email — and be radically fair on pricing where incumbents are predatory.
---
## 2. Individual Product Teardowns
### 2.1 Novoresume (design-led incumbent)
**Positioning:** "The Best Online Resume Builder" — 18M+ users claimed, 4.3/5 aggregate rating, FAANG-name-dropping social proof, "94% of users get more interviews" (unverifiable marketing stat).
**First-time experience:** Landing page pushes two CTAs: "Create Your Resume" (→ template gallery first) and "Get Your Resume Analysis" (ATS checker as lead magnet). Template-first onboarding: pick a template, then fill content. "No credit card required" prominent. The ATS-checker funnel is smart — it meets anxious users at their anxiety, then upsells.
**CV creation workflow:** Template → guided form sections → live preview. Free tier is deliberately crippled: **1 document, 1-page max**, watermark on free output ($19.99/mo to remove per third-party reviews). Premium unlocks 10-page docs, 72 documents, AI assistant, custom layout, cover letters.
**Editor:** Structured-form with a strong live preview; customization is curated (8 templates, 30 color themes, 3 fonts on premium) rather than free-form. Reviewers praise template quality as "genuinely better than most alternatives"; AI described as "a guided assistant, not an autonomous builder."
**Template system:** Categories: Free / Modern / AI-Powered / Creative / Traditional / Simple / Executive. Curated design tokens (color themes, font sets) over free customization — content and styling separated enough to switch templates without re-entering data. This is the mainstream commercial pattern and matches our proposed theme model.
**AI:** Content suggestions, AI assistant on premium, ATS resume checker. Marketed as "AI-powered" but reviews consistently frame it as assistive-only.
**Pain points (from reviews):**
- **PDF-only export** — no DOCX; recurring dealbreaker complaint.
- **Resume hostage:** subscription lapses → can't re-download resumes you already paid to create. Re-download of paid resumes after minor edits triggered *new* payment demands.
- Support: slow, canned responses.
- ~$24/mo single-month price — steep for a DIY tool.
**Business model:** Freemium with hard free-tier limits + watermark. Interesting wrinkle: homepage now says "Pay once, no recurring billing" for some plans — a direct response to the market's subscription-trap backlash. Also upsells "Novocareer" courses and an "AI Coach" — evidence incumbents are also creeping toward the career-platform position.
**Lesson for us:** Template quality is their moat and it's real. But the monetization model actively destroys trust at the exact moment users are most stressed. Their pivot to "pay once" pricing confirms subscription fatigue is a competitive lever.
---
### 2.2 Reactive Resume (OSS benchmark — deepest technical relevance)
**Positioning:** "A one-of-a-kind resume builder that keeps your privacy in mind. Completely secure, customizable, portable, open-source and free forever." **39.5k GitHub stars, 4.5k forks, 5,622 commits.** MIT licensed. The trust-position king: no paywalls, no premium tiers, self-hostable, one-click permanent data deletion.
**First-time experience:** Sign up (passkey/2FA supported) → dashboard → create resume → structured editor with real-time preview. No onboarding wizard, no AI hand-holding for beginners — assumes a technical-ish, self-directed user. Import from JSON Resume format supported.
**Workflow:** Unlimited resumes, no limits anywhere. Create from scratch or import JSON Resume. Share via unique public link. Export PDF/JSON/DOCX.
**Editor:** Structured forms + Tiptap rich-text editor for content blocks, drag-and-drop section ordering, custom sections "for any content type," real-time preview. Dark mode, multi-language.
**Template system (most instructive for us):**
- ~1215 named templates (Pokémon names: Azurill, Bronzor, … Onyx, Pikachu, Rhyhorn), each a distinct layout.
- A4 + Letter support; customizable colors, fonts, spacing.
- **"Structured Style Rules for section and text styling"** — styling as data, not code. Same direction as our Scriban theme-manifest proposal.
- **The whole document is a JSON Schema** (draft-07, published at `rxresu.me/schema.json`): all sections (basics, summary, experience, education, projects, skills, languages, interests, awards, certifications, publications, volunteer, references) + custom sections + **metadata block carrying template, layout, typography, colors, page settings, custom CSS.** Content and presentation live in one document but in cleanly separated subtrees.
**Technical architecture (v5.x, 2026):**
- **Stack:** TanStack Start (React 19 + Vite), Node.js, TypeScript, PostgreSQL + Drizzle ORM, ORPC type-safe RPC, Better Auth, Tailwind + Base UI, Zustand + TanStack Query. Single Node process mounts auth, RPC, **MCP**, OpenAPI, uploads, schema JSON, SEO endpoints, and the built web app. Optional SeaweedFS for S3-compatible uploads.
- **Export pipeline — the headline lesson:** v5.1.0 moved PDF generation **entirely client-side via @react-pdf/renderer**, eliminating Browserless/Chromium as a server dependency. Explicit `@reactive-resume/pdf/browser` and `/server` adapters; PDF.js for preview/thumbnails. They ran the Chromium-print architecture (same family as our Playwright exporter) for years and *migrated away from it* for operational cost reasons.
- **AI:** BYO-key integration (OpenAI, Gemini, Claude) — AI as optional enhancement, never a dependency or upsell.
- Ships an **MCP endpoint** — resumes as agent-accessible data. Directionally aligned with our AI-workspace ambitions.
**Pain points:** Template variety is thin vs. commercial rivals (12 vs 40300+); design quality is clean-functional, not designer-grade; no job tracking, no tailoring, no career context at all; hosted-version reliability historically depends on one maintainer's donations.
**Business model:** None (donations). Exists as proof of what users get for free — which means **anything we charge for must exceed the Reactive Resume baseline**: unlimited resumes, all templates, PDF/DOCX/JSON export, share links, BYO-AI.
**Lessons for us:**
1. Their schema (content sections + metadata subtree) validates our content/presentation separation — but we go further by making the profile independent of any document.
2. Their Chromium→client-side PDF migration is a signal: keep our Playwright exporter (we already run it, and server-side gives typographic control), but keep the renderer behind an interface so a lighter backend can replace it without touching themes. Our proposed `IOutputAdapter` already does this.
3. Their "Structured Style Rules" is the same insight as our theme manifests: **themes as data**.
4. Free-forever + privacy is a durable trust position; we can't out-free them, but we can match the fairness (no hostage-taking) while offering what they structurally can't: career context.
---
### 2.3 FlowCV (free-first commercial — best-in-class first-run UX)
**Positioning:** "Your first resume is 100% free forever. Unlimited downloads. No hidden fees. Yes, really 🚀" — the "yes, really" acknowledges market-wide distrust. 5.3M users; 4.9/5 Trustpilot, 4.8 Google, 4.9 Product Hunt — **the highest satisfaction ratings in the category.**
**First-time experience:** Four-step promise on the landing page: choose template → add experience (guided; or import existing resume) → customize layout/design → download unlimited PDFs. Fast time-to-first-resume, no credit card, no watermark, auto-save. GDPR/privacy trust markers. Free plan includes **three imports** of an existing resume.
**Editor (their crown jewel per reviews):** Structured forms with an instant, lag-free live preview; drag-and-drop reordering of whole sections; full design control (spacing, colors, fonts) even on free. "Incredibly intuitive" is the recurring review phrase. This is the editor experience bar we should aim to meet.
**Template system:** 50+ templates, ATS-oriented, plus "design your own template and save it" — user-defined templates as a first-class feature (a marketplace seed we also plan via theme manifests).
**AI:** Present but lightly weighted — positioning is speed + control, not AI magic.
**Pain points:** The **1-free-resume limit** is the single recurring complaint: active seekers need 35 tailored variants, which forces Basic ($36/yr). Paid plans auto-renew (soft friction, but 14-day money-back and student discounts blunt it).
**Business model — the fairest in the category and the one to copy:**
- Free: 1 resume, ALL design features, unlimited watermark-free PDFs.
- Pro from **$5/mo**: unlimited resumes.
- The paywall sits exactly on **multiplicity (variants), not quality**. Free output is never sabotaged.
**Lesson for us:** The paywall placement is the masterstroke: free users are walking advertisements (their resume looks great), and the upgrade trigger (need a second tailored variant) arrives precisely when the user is most engaged. Our CvVariant concept maps 1:1 onto this monetization seam if we ever charge.
---
### 2.4 Teal (secondary — closest to our Career Workspace thesis)
**Positioning:** "Land interviews 6x faster" — a job *search* platform where the resume builder is one module. Free tier is a real product: unlimited job tracking, Chrome extension, basic resume builder.
**First-time experience (instructively different):** Sign-up required → you land in a **job-tracking dashboard, not a resume editor**. Onboarding asks you to import career history via three routes: resume file upload (parsed into structured fields), LinkedIn URL, or paste text. Reviewers note this "can feel disorienting — there's no 'build resume' moment; you're building a system." That's both their genius and their friction: high ceiling, slower first-win.
**Workflow:** Career data lives in structured fields (experience, skills, summaries) → resumes are assembled *from* it → per-job tailoring driven by a job-description match score that flags keyword gaps in real time and suggests concrete improvements. Chrome extension (4.9/5, ~200k users, Chrome Store "Favorites of 2023") bookmarks jobs from 40+ boards with salary and keyword breakdowns, saves LinkedIn contacts.
**Editor:** Structured fields + "design mode" for template/look-and-feel. Reviewers rate the tracker and match scoring far above the resume *design* output — templates "feel pricey and sometimes don't work well with common systems like Workday"; formatting inconsistencies reported.
**AI:** The match-score → keyword-gap → suggested-fix loop is the most consistently praised AI feature in the entire market. AI cover letters on paid.
**Pain points:** Premium pricing anxiety (~$9/wk, $29/mo, ~$179/yr — sources vary, weekly billing reads as churn-farming); template design quality below the design-led cluster; and the most strategically interesting gap — **"Teal stops at the application stage."** No interview prep, no STAR coaching, nothing after the tracker says "Interview."
**Business model:** Generous free core (the tracker) drives acquisition; paid unlocks unlimited AI + advanced analysis. 4.3 Trustpilot, 4.1/5 editorial consensus.
**Lessons for us:** (1) Teal validates the entire career-workspace thesis — structured profile → tailored outputs → tracking loop. (2) Their two weaknesses are exactly our strengths-in-waiting: template/rendering quality (our themed Playwright pipeline) and post-application support (our email intelligence already sees interview invitations arrive). (3) Their onboarding teaches the trade-off: workspace-first onboarding needs a fast first win bolted on, or users bounce before the system pays off.
---
### 2.5 Enhancv (secondary — design-differentiation ceiling)
**Positioning:** Visually distinctive, personality-forward resumes ("modern, human" templates). 4.6 Trustpilot (892 reviews).
**Workflow & editor:** Drag-and-drop section rearrangement, custom content blocks, AI content suggestions inline. Templates have fixed structure — "not a blank canvas," which frustrates users paying for uniqueness but protects ATS-sensible formatting. Content analyzer gives structure/wording feedback.
**AI:** Paste a JD → keyword suggestions, bullet rewrites, tailored summaries, cover letters, multilingual. Reviewers' caveat: it *suggests*, you still do the tailoring manually.
**Pain points:** No DOCX export; steep pricing (Pro Weekly **$24.99 with auto-renewing 7-day trial** — trap-adjacent; ~$20/mo monthly); the distinctive multi-column/graphic templates are exactly the ones **ATS parsers struggle with**; editor lags on long resumes.
**Lesson for us:** Enhancv is the cautionary tale on the design/ATS tension: their differentiation (visual flair) directly fights the market's #1 anxiety (parseability). Any theme system we ship should carry an explicit per-theme ATS-safety rating so users make that trade-off knowingly.
---
### 2.6 Canva Resume Builder (secondary — the anti-pattern)
**Positioning:** 300+ free designer templates inside a general design tool.
**Why it matters as a negative benchmark:** Independent testing found **72% of 50 popular Canva resume templates failed basic ATS parsing.** Root cause is architectural: Canva positions **floating text boxes on a canvas** — there is no semantic structure, so no guaranteed machine reading order (skills read before name, sidebar merged into headings). Multi-column layouts, icons-as-labels, text-in-shapes compound it. Canva's own answer is a 9-template "WissCreative" ATS-safe subset — an admission the core product is wrong for this job.
**Lesson for us:** This is the strongest possible argument for our structured-data → theme-render pipeline. Canvas freedom and ATS safety are architecturally incompatible. We should never build canvas editing; and "ATS-safe by construction — unlike Canva" is a marketable, *provable* claim (structured data → we can emit the parse order deterministically, and even offer a "what the ATS sees" plain-text preview).
---
### 2.7 Resume.io (secondary — dark-pattern monetization at scale)
**Positioning:** High-volume mainstream builder; ~30 templates in Professional/Modern/Simple/Creative/ATS collections; 4.3 Trustpilot from **55,000+ reviews**.
**Product:** Genuinely competent — templates are well-designed for ATS (single-column, standard headers, clean formatting); wizard-style guided flow with prewritten phrase suggestions; fast time-to-first-resume.
**The dark pattern:** "$2.95" download headline → auto-converts to **$29.95/4-weeks after 7 days** (~$389/yr). Complaint mass: unexpected charges, hard-to-complete cancellations, billing after cancellation, refused refunds. Parent company (Talent Worldwide) holds an **F BBB rating** for ignoring complaints. Product Hunt reviews are "almost entirely about billing." Also accused of presenting AI-generated "expert reviews" as human.
**Lesson for us:** Proof that a good product with predatory billing still prints money at scale — but leaves a giant trust vacuum. Their 55k reviews show the market size; their F rating shows the opening. Any pricing we ever ship must be the exact inverse: visible price, easy cancel, export always free.
---
### 2.8 Kickresume (secondary — AI-writing leader)
**Positioning:** "AI resume builder" — GPT-4.1-backed writer, 40+ designer templates organized by industry, 4.6 Trustpilot. Best-rated AI *writing quality* in the category per editorial roundups.
**Product:** Free tier: 4 templates, no AI. Premium $8/mo annual ($96/yr), $24/mo monthly. Students/teachers get 6 months free. AI resume writer, cover letter generator, ATS checker on paid.
**Pain points:** The most consistent 1-star theme: **undisclosed AI usage caps on "unlimited" plans** — users blocked mid-billing-cycle ("I only made 16 resumes and they turned off my AI"). Support slow, refund guarantee restricted in ways not disclosed at purchase. AI output inconsistent for niche roles.
**Lesson for us:** If we meter AI (we must — cloud LLM costs are real), **publish the limits.** "Unlimited*" with a secret cap is a review-score time bomb. Our Gemini/Groq router already gives us cost control; surface remaining quota in the UI instead of hiding it.
---
## 3. Feature Comparison Matrix
| Capability | Novoresume | Reactive Resume | FlowCV | Teal | Enhancv | Canva | Resume.io | Kickresume | **Jobbjakt (planned)** |
|---|---|---|---|---|---|---|---|---|---|
| Structured career profile (doc-independent) | ✗ | ✗ (per-doc JSON) | ✗ | ◐ (shallow) | ✗ | ✗ | ✗ | ✗ | **✓ core** |
| Multiple CV variants | Paid (72 docs) | ✓ unlimited | Paid | ✓ | Paid | ✓ | Paid | Paid | **✓ (CvVariant)** |
| Per-job tailoring w/ match score | ✗ | ✗ | ✗ | ✓ best-in-class | ◐ manual | ✗ | ✗ | ◐ checker | **✓ (already live)** |
| Job application tracking | ✗ | ✗ | ✗ | ✓ best-in-class | ✗ | ✗ | ✗ | ✗ | **✓ (already live)** |
| Email/recruiter intelligence | ✗ | ✗ | ✗ | ◐ (contacts ext.) | ✗ | ✗ | ✗ | ✗ | **✓ unique (Gmail sync)** |
| Import existing CV (parse to structure) | ◐ | ◐ (JSON only) | ✓ (3 free) | ✓ (file/LinkedIn/paste) | ✓ | ✗ | ✓ | ✓ | **✓ (extraction runs live)** |
| Template count / quality | 816 / high | ~15 / clean | 50+ / high | modest / mid | 20+ / distinctive | 300+ / ATS-broken | ~30 / high | 40+ / high | 6 / mid → theme engine |
| User-defined templates | ✗ | ◐ (style rules, CSS) | ✓ | ✗ | ✗ | ✓ (canvas) | ✗ | ✗ | **✓ planned (manifests)** |
| ATS-safe by construction | ◐ | ✓ | ✓ | ✓ | ✗ (flagship themes) | ✗✗ | ✓ | ◐ | **✓ provable** |
| DOCX export | ✗ | ✓ | ✓ | ✓ | ✗ | ✓ | ✓ | ✓ | planned (scoped) |
| Public share link / web CV | ✗ | ✓ | ✓ | ✗ | ◐ | ✗ | ✗ | ◐ | planned (Career Workspace) |
| Cover letters | Paid | ✗ | ✓ | Paid | Paid | ✓ | Paid | Paid | **✓ planned (same profile)** |
| Interview prep | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | ◐ | **planned — open market gap** |
| Self-host / data ownership | ✗ | ✓ MIT | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | ✓ (self-hosted by design) |
| Fair billing (no hostage/trap) | ✗ | ✓ (free) | ✓ | ◐ | ✗ | ✓ | ✗✗ | ✗ (AI caps) | **✓ non-negotiable** |
◐ = partial. **Bold** = our differentiation.
Read across the bottom three rows of the left cluster: no commercial competitor combines fair billing + data ownership + career context. Read the "already live" cells: our moat is that the hardest parts (tracking, match scoring, email intelligence, CV extraction) are built — competitors would have to build *toward* our position while we only have to build a better renderer.
---
## 4. UX Patterns Worth Copying
1. **FlowCV's 4-step first-run promise** (template → content → design → download) with time-to-first-PDF under ~10 minutes. Guided but skippable. Our onboarding should deliver a rendered PDF in the first session, even from a thin profile.
2. **Teal's three-way import** (upload file / LinkedIn URL / paste text) as the *first* onboarding question. We already have upload+extraction; add paste-text (cheap — same pipeline) and evaluate LinkedIn later.
3. **Match-score-driven tailoring loop** (Teal): JD on one side, resume on the other, live keyword-gap chips with one-click "address this." We have the scoring service; the missing piece is the side-by-side tailoring UI.
4. **Instant live preview with zero lag** (FlowCV, Reactive Resume). Debounced re-render of the themed preview is a hard UX requirement, not a nice-to-have. Client-side preview render (even if final PDF stays server-side) is worth the investment.
5. **Drag-and-drop section reorder** (everyone good has it). Already partially in our RenderOptions.SectionOrder — expose it as direct manipulation on the preview, not a settings list.
6. **ATS checker as lead magnet** (Novoresume): a free "paste your CV, see what the ATS sees" tool is both acquisition funnel and an honest product feature. We can render our parse of *their* uploaded CV — we already run extraction.
7. **Curated design tokens over free-form styling** (Novoresume's 30 color themes / font sets): protects output quality, keeps themes swappable. Matches our accent-palette approach.
8. **"What you'd normally need five apps for" messaging** (RoleReady, ResumeTrakr): the fragmentation pain is now recognized enough that small entrants lead with it. When we market the workspace, lead with the *one profile, many outputs* story, not feature lists.
9. **Reactive Resume's published JSON schema + export-everything.** Publishing our profile schema (and JSON Resume import/export compatibility) buys trust with technical users at near-zero cost and future-proofs migrations.
10. **Kickresume's student program** (6 months free): cheap goodwill in the highest-need, lowest-income segment; worth copying if we ever monetize.
## 5. UX Patterns To Avoid
1. **Subscription traps** — $2.95-that-becomes-$29.95 (Resume.io), auto-renewing weekly trials (Enhancv). Category-defining trust destroyers.
2. **Resume hostage-taking** (Novoresume): blocking download of already-created documents. Export is free, forever, full stop.
3. **Secret AI caps on "unlimited" plans** (Kickresume). Meter openly with a visible quota.
4. **Watermarks on free output** (Novoresume): turns your free users into anti-advertisements.
5. **Canvas/floating-text editing** (Canva): architecturally incompatible with ATS parseability.
6. **Design flair that breaks parsing without warning** (Enhancv): if a theme is ATS-risky, label it.
7. **Workspace-first onboarding with no quick win** (Teal's "disorienting" first-run): our onboarding must produce a tangible artifact in session one even though we're profile-first underneath.
8. **Wizard rigidity** (Resume.io): a linear wizard that's hard to exit frustrates returning users; guided flow must degrade gracefully into free editing.
9. **Editor lag on long documents** (Enhancv): performance is a UX feature; test with 3-page, 10-year CVs.
10. **PDF-only export** (Novoresume, Enhancv): DOCX absence is a top-3 complaint across the design-led cluster. Even a scoped, simplified DOCX beats none.
---
## 6. Technical Architecture Insights
**From Reactive Resume (primary technical reference):**
- **Document = content sections + metadata subtree** (template, layout, typography, colors, page, custom CSS) in one JSON Schema. Clean separation *within* a document — but content is still trapped per-document. Our CareerProfile → CvVariant split is one abstraction better: their model can't answer "update my job title everywhere."
- **Styling as data** ("Structured Style Rules") — converges with our Scriban theme-manifest plan. Themes as declarative artifacts enable user themes and a marketplace without code execution. Keep manifests declarative (tokens, layout slots, section styles) and let Scriban only fill content into layout — don't let themes run logic.
- **PDF pipeline migration** (Chromium/Browserless → client-side @react-pdf/renderer in v5.1): server-side browser rendering is operationally heavy at scale. We keep Playwright (already built, gives full CSS typography, and our scale is modest) but the `IOutputAdapter` boundary must stay clean enough that swapping the PDF backend never touches themes. Their history proves this boundary earns its keep.
- **Type-safe RPC + published schema + MCP endpoint**: resumes as machine-readable, agent-accessible data. Cheap for us to mirror later (we already expose OpenAPI): a `/career-profile` schema endpoint and eventually MCP makes the workspace AI-agent-native.
**From JSON Resume (jsonresume.org):** a de-facto community interchange schema with an ecosystem of themes/tools. Supporting JSON Resume import/export makes us interoperable with the OSS world for roughly one mapping function per direction.
**From Canva (negative):** semantic structure must be the source of truth; presentation must be a *projection*. Never store positioned boxes.
**From Teal:** structured fields → per-job adaptation works, but their weak rendering shows the two halves (data model vs. render quality) are separate competencies — most competitors are good at exactly one. We have a real shot at both: structured data is our heritage (tracker, extraction), and the theme engine is a bounded, well-understood build.
**Export formats observed:** PDF universal; DOCX in ~60% (and its absence loudly punished); JSON only in OSS; public web link in OSS + FlowCV. Confirms roadmap priority: PDF (done) → DOCX (scoped) → public link (Career Workspace phase) → JSON (cheap, do early for trust).
---
## 7. Market Gaps
1. **Career profile as single source of truth, with outputs as projections.** Everyone stores documents; nobody (at real quality) stores a career. Confirmed by the wave of tiny 202526 entrants marketing exactly this ("one career profile powering every tool" — Joberney; "one source of truth" — ResumeTrakr). The position is being discovered but is not yet occupied by anyone with distribution.
2. **Post-application support.** Teal — the best workspace — "stops at the application button." Interview prep from *your actual profile + the actual JD + the actual recruiter email thread* is a genuinely empty space. Our Gmail intelligence sees the interview invitation arrive; no competitor even has that signal.
3. **Trustworthy billing as a product feature.** The gap between Resume.io's product quality and its F BBB rating is the market's largest arbitrage. "We never hold your CV hostage" is a positioning statement no design-led incumbent can copy without abandoning their revenue model.
4. **ATS transparency.** Everyone claims "ATS-friendly"; nobody *shows* it. A "view as ATS" plain-text projection with per-theme safety ratings is cheap for us (structured data) and impossible for canvas tools.
5. **Email-aware career automation.** Auto-updating application status from recruiter email, linking threads to applications, suggesting follow-ups — we already do this; no resume-builder competitor does. It's our most defensible moat because it requires infrastructure none of them have.
6. **Fair variant pricing.** FlowCV monetizes variants at $5/mo and is beloved; design-led rivals charge $2030/mo and are resented. If we ever monetize, the variant/tailoring axis at FlowCV-like pricing is the proven seam.
**Where competitors are strong (respect these):** template design quality (Novoresume/Kickresume/Resume.io — years of design investment); onboarding speed (FlowCV); tracker + extension distribution (Teal); free-forever trust + OSS community (Reactive Resume). We should not expect to beat template quality on day one — 6 excellent themes beat 40 mediocre ones.
---
## 8. Recommended Product Strategy
1. **Position as the Career Workspace, not a resume builder.** The resume builder is the *demo* of the workspace: one profile → tailored CVs, cover letters, interview prep, and eventually public profile/portfolio. Sequence per the existing 9-phase roadmap: profile/variant data model first (invisible), theme engine second, then the tailoring loop UI, then post-application features.
2. **Editor model: structured forms + instant themed preview + drag-and-drop section order.** No canvas. No inline-editing-on-the-document as primary model (nice later as sugar over the same fields). This is both the market-validated pattern and the one our data model wants.
3. **Make ATS safety a visible, provable feature:** per-theme ATS rating + "what the ATS sees" plain-text view + (later) free public ATS-check tool as acquisition funnel.
4. **AI: depth over breadth.** Ship the tailoring loop (JD ↔ CV keyword gaps with one-click fixes — we have the scorer) and bullet-level improve-with-diff (we have rewrite-section). Skip: generic "AI writes your whole resume," AI "review scores" theater. Metered AI with a *visible* quota.
5. **Trust as strategy:** free export always (PDF/JSON at minimum), no watermarks, published profile schema, JSON Resume interop, self-hosted data ownership story. Every one of these is cheap for us and structurally expensive for incumbents.
6. **Theme system:** curated first-party themes (quality bar: Novoresume) on declarative manifests; user-customizable tokens (accent, fonts, spacing, section order); user-defined themes later — the manifest architecture is the marketplace seed, but the marketplace itself is deferred.
7. **DOCX: scoped, not parity** (as proposed): a clean single-column DOCX rendition of any variant answers the actual complaint ("an employer asked for Word") without chasing pixel parity across six themes.
## 9. MVP Recommendation
Aligned with the architecture proposal's phases; competitor evidence adjusts emphasis:
**MVP = the tailoring loop on a solid profile, with 34 excellent themes.**
1. **CareerProfile + CvVariant data model** (invisible migration) — the foundation nothing else works without.
2. **Theme engine port** of existing 6 templates to manifests; cull to the best 34 if any are weak (quality over count — FlowCV won on 50 good, not 300 broken).
3. **Editor v1:** structured forms + live themed preview + drag-drop section order + accent/font tokens. Performance budget: preview update < 300ms on a 3-page CV.
4. **Import-first onboarding:** upload (exists) + paste-text, straight into a rendered first PDF in session one.
5. **Tailoring view:** JD keyword gaps ↔ variant side-by-side with one-click apply (wires existing match scorer + rewrite endpoints into one screen). This is the screen no design-led competitor has and Teal has without our rendering quality.
6. **Fair-exit guarantees from day one:** PDF + JSON export free, no watermark.
Defer from MVP: DOCX (fast follow), public share links, cover-letter generation UI, user-defined themes, interview prep. Each is validated, none is needed to prove the core loop.
**MVP success metric candidates:** time-to-first-rendered-PDF (< 10 min from signup); % of applications with a tailored variant attached; match-score delta before/after tailoring session.
## 10. Long-Term Career Workspace Vision
The end state (per the user's stated vision, now market-validated): **one structured career profile powering every artifact of a career.**
- **Phase horizon 1 — the loop closes:** profile → variants → tailored CV per application → application tracked → recruiter email auto-linked → status auto-suggested. (Mostly exists; CV side lands with MVP.)
- **Horizon 2 — outputs multiply:** cover letters from the same profile + JD + thread context; scoped DOCX; public profile page (Reactive-Resume-style share link, but backed by the live profile, not a document snapshot); JSON Resume interop.
- **Horizon 3 — the gap nobody fills:** interview prep generated from profile + JD + the actual email thread ("they said the panel covers system design — here are your relevant stories, in STAR form, from your own experience entries"); skills matrix and gap analysis across all tracked JDs ("last 20 rejections wanted Kubernetes; you don't have it"); LinkedIn summary generation; portfolio/personal-site generation as another theme over the same profile.
- **Horizon 4 — agent-native:** published schema + MCP endpoint so the user's own AI agents read/write the profile (Reactive Resume already ships MCP — the pattern is proven).
The strategic through-line: every horizon reuses the same profile and the same theme/output architecture. Competitors adding "career platform" features (Novoresume's courses, Teal's extension) are bolting products together; our architecture makes each new output roughly *one adapter + one theme*, which is the compounding advantage the data model buys.
---
## Appendix: Sources
**Novoresume:** [soundcv.com review](https://www.soundcv.com/blog/novoresume-review-2026) · [resumejudge 14-day test](https://resumejudge.com/blog/novoresume-review/) · [resumegenius review](https://resumegenius.com/reviews/novoresume-reviews) · [stylingcv review](https://stylingcv.com/blog/novoresume-review-2026-features-pricing-pros-cons-worth-it/) · [novoresume.com](https://novoresume.com/) · [Trustpilot](https://www.trustpilot.com/review/novoresume.com)
**Reactive Resume:** [GitHub repo](https://github.com/amruthpillai/reactive-resume) · [docs.rxresu.me](https://docs.rxresu.me/) · [JSON schema guide](https://docs.rxresu.me/guides/json-resume-schema) · [v5.1.5 release notes](https://github.com/amruthpillai/reactive-resume/releases/tag/v5.1.5) · [rxresu.me](https://rxresu.me/) · [JSON Resume](https://jsonresume.org/)
**FlowCV:** [flowcv.com](https://flowcv.com/) · [resumehog review](https://resumehog.com/blog/posts/flowcv-review-2026-is-it-still-the-best-free-resume-builder.html) · [resumearena](https://resumearena.com/tool/flowcv/) · [resufit review](https://resufit.com/blog/flowcv-review-free-resume-builder-worth-trying/) · [jobsolv](https://jobsolv.com/directory/flowcv)
**Teal:** [tealhq.com](https://www.tealhq.com/) · [pricing](https://www.tealhq.com/pricing) · [import help doc](https://help.tealhq.com/en/articles/9457699-import-existing-resume-or-linkedin-profile) · [resume builder help](https://help.tealhq.com/en/articles/9508933-getting-started-resume-builder) · [loopcv review](https://blog.loopcv.pro/teal-hq-review/) · [resumehog verdict](https://resumehog.com/blog/posts/teal-hq-review-2026-is-this-job-search-tool-worth-it.html) · [remotejobassistant](https://www.remotejobassistant.com/blog/teal-resume-review) · [enhancv's Teal review](https://enhancv.com/blog/teal-review/)
**Enhancv:** [resumegenius review](https://resumegenius.com/reviews/enhancv-reviews) · [pitchmeai full review](https://pitchmeai.com/blog/enhancv-review-pros-cons) · [pricing analysis](https://pitchmeai.com/blog/enhancv-worth-it-review-features-pricing) · [G2](https://www.g2.com/products/enhancv/reviews)
**Canva:** [resufit ATS analysis](https://resufit.com/blog/canva-resume-builder-templates-ats-compatibility/) · [rezi.ai review](https://www.rezi.ai/posts/canva-ai-resume-builder-review) · [resumegencv ATS failures](https://resumegencv.com/blog/why-canva-resumes-fail-ats-scans) · [jobscoutly 2026 test](https://jobscoutly.com/canva-resume-builder)
**Resume.io:** [resufit review](https://resufit.com/blog/resumeio-review-pricing-templates-worth-it/) · [Product Hunt reviews](https://www.producthunt.com/products/resume-io/reviews) · [boxresume](https://boxresume.com/comparison/resume-io-review-the-good-the-bad/) · [Trustpilot](https://www.trustpilot.com/review/resume.io) · [pitchmeai](https://pitchmeai.com/blog/resume-io-review)
**Kickresume:** [remotejobassistant review](https://www.remotejobassistant.com/blog/kickresume-review) · [firebear review](https://firebearstudio.com/blog/kickresume-review.html) · [resumegenius](https://resumegenius.com/reviews/kickresume-review) · [resumeoptimizerpro](https://resumeoptimizerpro.com/blog/kickresume-review)
**AI features / market:** [jobscan best AI builders](https://www.jobscan.co/blog/best-ai-resume-builders/) · [medium 8-builder test](https://medium.com/@vemasanihareesh/i-tested-8-ai-resume-builders-in-2026-heres-what-actually-works-334f07619431) · [resumeoptimizerpro ranking](https://resumeoptimizerpro.com/blog/best-ai-powered-resume-builders-2026)
**Workspace entrants (gap validation):** [GigForge](https://gigforge.io/) · [ResumeTrakr](https://resumetrakr.com/) · [Joberney](https://joberney.com/career) · [RoleReady](https://www.roleready.me/) · [Seekario](https://seekario.ai/)
*Method note: primary sites fetched directly (FlowCV, Novoresume, Reactive Resume repo + docs); Teal's site blocks fetching (403), covered via its help-center docs and multiple independent reviews. Review claims cross-checked across ≥2 independent sources where possible; single-source claims (e.g., Canva 72% ATS failure figure) attributed to their origin.*
+321
View File
@@ -0,0 +1,321 @@
# CV Builder Product Teardown — Jobbjakt Internal Review
**Date:** 2026-07-12
**Scope:** Critical product, UX, and technical teardown of the current CV builder and related career features, ahead of the Career Workspace redesign.
**Companions:** `docs/cv-builder-competitor-deep-research.md` (market context) and the CV Builder Architecture Proposal artifact (target design). This document is the honest "as-is" audit both of those build on.
**Stance:** Written as a senior PM + UX designer + architect reviewing before a major redesign. Existing code is treated as evidence, not as precedent. Where something is wrong, it is called wrong.
---
## 1. Current Product Overview
### What the application is today
Jobbjakt is a self-hosted, single-tenant-ish (multi-user with admin) **job application tracker** with strong email intelligence, to which a CV builder has been progressively bolted on. Live at `https://jobs.cesnimda.uk`. Stack: ASP.NET Core 9 + EF Core (SQLite dev / MariaDB prod), Next.js 16 static-export frontend (MUI), FastAPI AI sidecar (Ollama/Gemini/Groq router), Playwright PDF rendering.
**Feature inventory (career-relevant):**
| Area | What exists | Where |
|---|---|---|
| Job tracking | CRUD, kanban pipeline, drag-drop, statuses, reminders, analytics/funnel | `JobApplicationsController` (~2,900 lines), `KanbanBoard`, `DashboardView` |
| Email intelligence | Gmail sync, thread↔job linking, review queue, suggested jobs, status suggestions | `GmailController`, `GmailReviewPage`, `CorrespondenceInboxPage` |
| Master CV | Upload (PDF/text) → parse → normalize → structured profile; rebuild/improve/reprocess; section rewrite | `ProfileCvController` (2,265 lines), `ProfilePage.tsx` |
| Tailored CV | Per-job draft: generate from master + job, edit, preview, PDF export | `JobApplicationsController:24652614`, `JobDetailsDialog.tsx` |
| Templates | 6 hardcoded templates (ats-minimal, harvard, auckland, edinburgh, monarch, fjord), curated accent palette, section order, page mode, bullet density | `CvTemplateRenderer` (448 lines), `TailoredCvRenderOptions` |
| Match scoring | CV↔JD keyword match with curated skill-tag synonyms | `JobCvMatchService`, `SkillTagger`, `match-score` endpoint |
| Per-job AI | Interview prep, candidate fit, focus plan, follow-up drafts, application package, readiness | `JobDetailsDialog.tsx` (all crammed into one dialog) |
| AI service | `/summarize`, `/cv/normalize`, `/cv/classify-block`, `/cv/rewrite` with prompt-injection hardening + provider router | `tools/summarizer/app.py` |
### Target user
Implicitly: **the developer himself** — a technically fluent, self-hosting job seeker running an active search. Nothing in the product contradicts this: no onboarding for novices, AI knobs exposed raw (tone/language/target-role fields), extraction "runs" and "reprocess" surfaced in end-user UI, admin pages in the same nav. The recent additions (onboarding checklist, empty states, OAuth signup) are the first genuine gestures toward a second user.
**This is the central product tension:** the backend is built like a multi-user SaaS (Identity, roles, registration, OAuth, per-owner scoping), but the UX is built like a personal tool. The redesign must pick: the Career Workspace vision implies real second users, which means the "developer-as-user" assumptions have to go.
### Current user journey
1. Land → sign in (email/Google/Microsoft) → dashboard.
2. Add jobs manually, via bookmarklet, or via Gmail suggested-jobs.
3. Track through kanban; Gmail sync auto-links correspondence; reminders fire.
4. Separately, on Profile page: upload a CV → extraction pipeline produces structured profile.
5. Inside a job's details dialog (a modal!): generate tailored CV draft → edit in text fields → preview → export PDF.
6. AI extras (interview prep, fit, follow-ups) live as tabs/sections in that same modal.
### Current relationships (the actual object model in practice)
```
ApplicationUser
├─ ProfileCvText (raw CV text — a column on the Identity user row)
├─ ProfileCvStructureJson (StructuredCvProfile serialized — also a user column)
├─ CvUploadArtifact ──< CvExtractionRun (parser/normalizer/prompt versions, run history)
└─ JobApplication ──1:1── TailoredCvDraft
├─ TemplateId + RenderOptionsJson (presentation welded to content)
├─ SummaryJson / SelectedSkillsJson / ExperienceJson / ... (per-section JSON blobs)
├─ CanonicalProfileVersion (staleness pointer to master)
└─ Status ("generated" / edited)
Cover letters: DO NOT EXIST as entities. Interview prep etc.: transient AI responses, not persisted as documents.
```
### Current product philosophy (inferred, since none is written down)
- **Tracker-first:** the CV is an attachment to a job application, not a first-class product. The tailored CV lives *inside the job details modal* — the clearest possible statement of the current hierarchy.
- **One master CV, ephemeral derivatives:** exactly one profile per user; tailored drafts are per-job satellites; nothing else is durable.
- **AI as pipeline, not as assistant:** AI does batch transforms (parse this, rewrite that) with exposed machinery, rather than conversational or inline assistance.
- **Provenance-conscious:** field-level confidence/review-state metadata shows real care about "where did this claim come from" — unusually mature for this product stage.
The Career Workspace vision inverts the first two tenets. The last two are worth keeping.
---
## 2. User Experience Review
### New user
**How does a user create their first CV?** They can't, in any meaningful sense — they can only *import* one. `ProfilePage` offers "Upload CV" (parse an existing document). There is no from-scratch path: no guided form, no "add your first job" flow for the profile. A user without an existing CV document is stuck. Every competitor in the research offers from-scratch creation; we are import-only.
**Is onboarding clear?** The new onboarding checklist (add CV / import job / check match) is a good spine, but step one drops the user on a Profile page where the CV feature is a card among password/avatar/email-connection cards. CV building is presented as an *account setting*. That framing is wrong for what is supposed to become the product's centerpiece.
**Critical bug, found during this audit:** [`ProfilePage.tsx:356`](../job-tracker-ui/src/views/ProfilePage.tsx) computes `isLocal = me?.provider === "local"` and disables CV upload/rebuild/improve (among other controls) for OAuth users. The gate was presumably meant for identity fields (can't change password on a Google account) and was blanket-applied to the CV card. **Google/Microsoft users — the exact accounts we just built auto-signup for — get a disabled CV builder.** A Google-first new user's journey dead-ends at step one of the checklist. Must-fix regardless of redesign timing.
**Where do new users get confused?**
- "Reprocess," "Rebuild," "Improve," "Runs" — four adjacent buttons whose distinction (re-run extraction vs. regenerate structure vs. AI-rewrite text vs. view pipeline history) is developer vocabulary. No user knows which to press.
- The structured profile (the actual output of extraction) has no real editing UI — the raw text and the structure are shown, but correcting a mis-parsed date means fighting JSON or re-uploading.
- Nothing explains that the master CV feeds match scores and tailored drafts; the causal chain that makes the product coherent is invisible.
### Existing user
**Editing the CV:** the master CV is edited as *raw text* (`ProfileCvText` in a textarea) with AI rewrite assistance per-section. The structured profile is a *derived artifact* the user can't directly maintain. This is backwards relative to both competitors (structured forms are the primary surface everywhere) and our own architecture proposal. Consequence: every text edit desynchronizes text from structure until a rebuild; the "which is the truth?" question has no good answer today (see §4).
**Creating tailored CVs:** open a job → details modal → tailored CV section → "Generate" → edit. Real problems:
- It's in a **modal**. A document editor competing for space with interview prep, fit analysis, follow-ups, readiness, notes — inside a dialog over the jobs table. No room for the side-by-side JD↔CV tailoring view that the competitor research identified as the killer screen (Teal's core loop).
- Bullets are edited as newline-joined blobs (`splitLines`/`joinLines` in `tailoredCvDraft.ts`) — plain textareas, no per-bullet operations, no drag-reorder, no AI-improve-this-bullet affordance at the point of editing.
- **One draft per job, no variants, no history.** Regenerate overwrites; a good manual edit lost to a regenerate is unrecoverable. `Status` ("generated"/edited) and `GenerationContextHash` exist precisely because overwrite-anxiety is real — they mitigate instead of solving.
**Reusing information:** the master→tailored generation is the only reuse mechanism. No way to reuse a great tailored summary across jobs, no library of alternative bullets, no second master for a different career track. `CanonicalProfileVersion` at least detects when a draft is stale relative to the master — good instinct, minimal payoff without a refresh/diff flow.
**Maintaining career information over time:** effectively unsupported. Adding a new job to your history = edit raw text + rebuild, or re-upload a new document. For a product whose vision is "the structured career profile is the single source of truth," today's truth is a text blob on the user table.
**Friction inventory (ranked):**
1. OAuth users locked out of CV features (bug).
2. No from-scratch creation path.
3. No structured-profile editor — raw text is the editing surface.
4. Tailored CV editor trapped in a modal.
5. Single draft, overwrite-on-regenerate, no history.
6. Pipeline vocabulary (runs/reprocess/rebuild) in end-user UI.
7. Preview is HTML-in-a-box, not a paginated document preview; template switching is a dropdown with no visual gallery.
8. CV features split across two distant locations (Profile page ↔ job modal) with no navigational thread connecting them.
---
## 3. CV Builder Analysis (vs. competitor research)
### Editing model
Current: **raw-text-primary with derived structure** (master) and **form-ish JSON blob editing** (tailored). The market-winning model per the research: **structured forms + instant themed preview + drag-drop sections** (FlowCV, Reactive Resume, Teal). We have the *data model* for that (StructuredCvProfile is section-granular) but not the UI. Verdict: the editing surface must be rebuilt around structure; the raw text demotes to an import artifact and export view.
### Preview experience
Rendered HTML returned by the server per-request, displayed inline. No client-side re-render on keystroke, no pagination fidelity, no zoom, no "what the ATS sees" view (which our structured pipeline could produce almost for free — competitive claim identified in research §7.4). Against FlowCV's lag-free live preview this is a clear generation behind.
### Template switching & customization
Genuinely decent bones: `TemplateId` swaps freely over the same content (content/presentation separation *within* the draft works); render options offer curated accent palette, section order, page mode, bullet density, photo toggle. This matches the curated-token pattern Novoresume uses (research §4.7) — the right instinct. Falls short on: no visual template gallery, no font choice, no spacing control, six templates whose design quality is mid-tier vs. the design-led cluster, and per-CV theme settings can't be saved/reused as a named style.
### What we already do well (protect these in the redesign)
1. **Extraction pipeline with provenance** — versioned runs (parser/normalizer/prompt versions), field-level confidence + review-state + source snippet. No competitor surfaces provenance at all. This is a differentiating asset the moment a review UI exposes it ("we're 60% sure about this date — confirm?").
2. **Match scoring with curated skill synonyms** in the same system as tracking — Teal's premium feature, already ours.
3. **Prompt-injection-hardened AI pipeline** with delimiter fencing and instruction-ignoring rules; provider router with local fallback. More mature than the market's bolt-on AI.
4. **Server-side Playwright rendering** — full CSS typography control; matches the print quality of the design-led cluster.
5. **Staleness detection** (`CanonicalProfileVersion`, `GenerationContextHash`) — the primitive that version-aware tailoring needs.
### Where we fall behind
| Dimension | Market bar | Us |
|---|---|---|
| From-scratch creation | Universal | Absent |
| Structured editing UI | FlowCV/RR/Teal forms + preview | Raw text + JSON blobs |
| Live preview | Instant, paginated | Server round-trip HTML |
| Variants | Unlimited (RR) / paid tiers | One per job, zero free-standing |
| Version history | Rare in market (opportunity) | None (also our gap) |
| Template count/quality | 3050 good | 6 mid |
| DOCX export | ~60% of market | None |
| Public share link | RR, FlowCV | None |
| Onboarding to first PDF | <10 min (FlowCV) | Not achievable without an existing CV document |
---
## 4. Current Data Model Review
### Entities and storage
- **`ApplicationUser.ProfileCvText` + `ProfileCvStructureJson`** — the master CV as two nullable string columns *on the Identity user row*. Sins: (a) fat blobs on the most-fetched row in the system (auth reads drag CV bytes along unless carefully projected); (b) exactly-one-profile hard-coded into the schema — the CvVariant/second-career-track future requires a migration by definition; (c) no versioning — every rebuild silently destroys the previous structure (extraction *runs* are versioned; the *applied profile* is not); (d) dual representation with no single source of truth — text and structure coexist, edits touch one, rebuilds overwrite the other, and different features read different ones (match scoring builds a corpus from raw text; tailoring generates from structure).
- **`StructuredCvProfile`** (JSON shape) — good: section-granular, extensible (`OtherSections`, generic `Sections`), already covers certifications/projects/languages/interests, and `Metadata.Fields` carries per-field provenance. Weak: date fields are free-strings (`Start`/`End`) so no reliable timeline math (career-timeline feature will choke); `Skills` is `List<string>` with no proficiency/category/years; no stable IDs on items, so "this bullet in the tailored draft came from job #2 bullet #3" is unexpressible — lineage between master and tailored content is lost at generation time.
- **`TailoredCvDraft`** — one row per job (`JobApplicationId` FK, effectively 1:1), section JSON blobs, `TemplateId` + `RenderOptionsJson` inline. Good: `CanonicalProfileVersion` + `GenerationContextHash` staleness primitives; JSON-blob sections are pragmatic for a document-shaped payload. Bad: **presentation welded to content** (can't render one draft in two themes without mutating it — the architecture proposal's core criticism, confirmed); no variant concept; no history; `Status` is a two-state string doing lifecycle work.
- **`CvUploadArtifact` / `CvExtractionRun`** — the best-designed corner: artifacts retained, runs versioned by parser/normalizer/prompt versions, `StructuredProfileJson` snapshot per run. This IS a version history — but only for imports, and nothing lets a user diff or restore from it.
- **Cover letters, interview prep, portfolios, public profiles** — no entities. Interview prep/fit/focus outputs are transient API responses; a user's best interview-prep notes evaporate.
### Verdict against the Career Workspace target
The proposal's target model (CareerProfile → CvVariant → CvVersion, Theme as sibling reference, TailoredCvVersion, output entities) is confirmed necessary by this audit, and the migration is *tractable*: `ProfileCvStructureJson` lifts into a `CareerProfile` table nearly verbatim; each `TailoredCvDraft` becomes a job-linked CvVariant with its render options extracted to a theme reference. Two additions this audit forces onto the proposal:
1. **Stable item IDs** in the profile schema (jobs, bullets, skills) — without them, variant/tailoring lineage, "update everywhere," and inheritance-with-overrides are all unimplementable.
2. **Normalize dates** (`YYYY-MM` + `isCurrent`) at migration time — timeline, tenure math, and skills-recency all depend on it; migrating free-strings later means re-parsing every profile again.
Also settle the source-of-truth rule explicitly: **structure is canonical; text is derived** (an export format and search corpus, regenerated on change) — and make match scoring read from structure so the two consumers stop diverging.
---
## 5. AI Workflow Review
### What exists
| Feature | Flow | Persistence |
|---|---|---|
| CV parse/normalize | upload → PDF text → `/cv/normalize` + `/cv/classify-block` (delimiter-fenced) → StructuredCvProfile | Run snapshots ✓ |
| Section rewrite / improve | section text + tone/language/target-role knobs → `/cv/rewrite` → replace text | Overwrites |
| Tailored generation | master profile + job context → draft sections | Overwrites draft |
| Match score | curated skill tags (synonym regex) + keyword corpus | Computed |
| Job-ad summary | local distilbart `/summarize` | Stored on job |
| Interview prep / fit / focus / follow-ups / package / readiness | per-job LLM calls from modal | **Transient** |
### What's genuinely valuable
- The **hardened pipeline** (fencing, ignore-embedded-instructions, provider router with graceful local fallback) — infrastructure competitors lack.
- **Match scoring** — the research's verdict was that JD-gap analysis is the one universally-praised AI feature; ours is real (curated synonyms beat naive keyword matching) and already wired to job data.
- **Structured extraction with confidence** — the input side of every future feature.
### What's limited
- **Rewrite is fire-and-forget:** no diff view, no accept/reject, no before/after. The research flagged "generic rewrite" as the gimmick tier and "improvement with visible diff/scoring" as the useful tier (BeamJobs pattern). We're on the wrong side of that line purely for lack of UI.
- **Tailoring is disconnected from scoring:** generation doesn't take the match-score gaps as input, and the score doesn't update live as the user edits the draft. The two halves of the killer loop exist and don't talk.
- **AI knobs are raw:** tone/language/target-role as form fields instead of intent-level actions ("make this more senior," "address this missing keyword").
- **Transient outputs:** interview prep and fit analyses regenerate (cost + latency + inconsistency) instead of persisting as reviewable documents.
### Hallucination / factual-accuracy risk — currently the biggest unmanaged AI risk
Rewrites and tailored generation can fabricate: a rewrite that upgrades "assisted with migration" to "led migration" is a *career integrity* failure, invisible today because nothing constrains generation to source facts or shows the user a diff. The provenance metadata (source snippets, confidence) exists on extraction but is **not enforced on generation**. Recommendation, in priority order:
1. Every AI mutation renders as a **diff with accept/reject** (also solves the limited-rewrite problem).
2. Generation prompts constrained to *select/rephrase* profile content, never invent quantities, employers, titles, or dates; validator pass flags novel named entities/numbers that don't appear in the source profile.
3. Tailored content carries source-item references (needs the stable IDs from §4) so "where did this claim come from" is answerable per bullet.
### Missing AI opportunities (ranked by leverage of existing assets)
1. **Tailoring loop screen:** match gaps ↔ draft side-by-side, one-click "address this gap" → constrained rewrite → live rescore. Wires three existing services into the screen no competitor has with our rendering quality.
2. **Extraction review queue:** low-confidence fields surfaced as confirm/fix cards — turns existing provenance metadata into visible trust.
3. **Email-aware prep:** interview prep that reads the actual recruiter thread (unique data no competitor holds).
4. **Skills-gap analytics across tracked JDs:** "your last 15 rejections wanted X" — pure aggregation over data already stored.
---
## 6. Template & Rendering Review
### Pipeline
`TailoredCvDocument``CvTemplateRenderer.Render(templateId, …)` → C# switch over 6 template methods building HTML strings (~450 lines total) → `PlaywrightCvPdfExporter` (headless Chromium print) → PDF. Accent resolved via `ResolveAccent` (slate/blue/emerald/plum/brick → hex). Same renderer drives HTML preview and PDF (single source of visual truth — good).
### Evaluation
- **Add a new template:** write a new C# method, recompile, redeploy. Designer-inaccessible, review-heavy, untestable in isolation. Cost is why there are six.
- **User customization:** limited to the curated render options; anything more means more C# branches.
- **Premium/marketplace templates:** impossible — templates are compiled code; third-party code in the renderer is a non-starter (research §6 confirmed themes-as-declarative-data is how RR solves this: "Structured Style Rules").
- **Career Workspace outputs:** each new output type (public profile page, portfolio, DOCX) would today mean another hardcoded renderer. The proposal's `IOutputAdapter` + Scriban theme manifests directly answers this; this audit adds one guardrail from RR's history (they abandoned server-Chromium for cost): **keep the PDF backend swappable behind the adapter interface** — Playwright is right for us now (already built, small scale, full CSS), but the boundary must let a lighter renderer replace it without touching themes.
- **Print fidelity risks present today:** page-break control is CSS-implicit (no explicit widow/orphan handling per section); "one-page" mode is a squeeze heuristic rather than a layout contract. Fine at 6 templates; codify break rules in the theme manifest schema when porting.
---
## 7. Feature Gap Analysis
### Missing entirely (vs. competitor research + vision)
| Feature | Competitor bar | Vision need | Cost given our architecture |
|---|---|---|---|
| From-scratch CV creation | Universal | Yes | Medium (structured editor is the prerequisite) |
| Multiple CV variants | RR unlimited; FlowCV's paywall seam | Core | Schema migration (planned) |
| CV version history | Market gap — differentiator | Core | Medium (CvVersion planned) |
| Structured profile editor + review UI | Universal (forms) | Core | Large — the main UI build |
| Live paginated preview | FlowCV bar | Yes | Medium |
| DOCX export | ~60% of market, loud complaints | Yes | Medium, scoped (per proposal) |
| Public profile / share link | RR, FlowCV | Core (Horizon 2) | Medium (adapter + theme) |
| Cover letter entity + generation | All paid competitors | Core | Small once profile model lands |
| Career timeline / skills matrix | Nobody good | Differentiator | Small *after* date normalization |
| Persisted interview prep | Nobody (market gap) | Differentiator | Small (persist what exists) |
| ATS-view ("what the parser sees") | Nobody shows it | Trust play | Small (we have structure) |
| JSON Resume interop / published schema | RR | Trust play | Small |
### Exists but needs improvement
1. **OAuth CV lockout bug** — fix now (one-line frontend condition).
2. Tailored editor out of the modal into a full-page workspace route.
3. Rewrite → diff/accept/reject.
4. Match score ↔ tailoring connection (the loop).
5. Template gallery with visual previews (data exists in `templates` endpoint descriptors).
6. Pipeline vocabulary → user vocabulary ("Update from new CV," not "Reprocess run").
7. Section editing: per-bullet rows with reorder + inline AI, not newline blobs.
8. Extraction confidence → visible review flow instead of buried metadata.
---
## 8. Product Recommendations
### Immediate (low effort / high impact — do before or alongside Phase 1)
| # | Recommendation | Problem | User value | Technical impact | Complexity | Priority |
|---|---|---|---|---|---|---|
| I1 | Fix OAuth `isLocal` CV lockout | Google/MS users can't use CV features | Unblocks all OAuth users | One condition split (identity-gates vs. feature-gates) | Trivial | **P0 — bug** |
| I2 | Diff + accept/reject on every AI rewrite | Silent overwrites; hallucination invisible | Trust in AI edits; recoverability | Frontend diff view; keep previous text | Small | P1 |
| I3 | Persist interview prep / fit outputs | Regeneration cost; lost work | Notes survive; consistent prep | One table or JSON column per job | Small | P1 |
| I4 | Rename pipeline vocabulary in UI | Developer jargon confuses | Comprehensible actions | i18n strings only | Trivial | P1 |
| I5 | Template gallery with thumbnails | Blind dropdown | Informed template choice | Render 6 previews once; static images | Small | P2 |
| I6 | Export JSON of profile + drafts | No data-ownership story | Trust (research: fair-exit is strategy) | One endpoint, serializers exist | Small | P2 |
### Medium-term (requires the architectural work — Phases 14 of proposal)
| # | Recommendation | Problem | User value | Technical impact | Complexity | Priority |
|---|---|---|---|---|---|---|
| M1 | CareerProfile + CvVariant + CvVersion migration, **with stable item IDs and normalized dates** | One profile, no variants, no history, blobs on user row | Multiple CVs, safe regeneration, update-everywhere | The Phase-1 schema migration; raw-SQL reconciler steps | Large | P0 of redesign |
| M2 | Structured profile editor + extraction review queue | Raw text is the editing surface | Maintainable career data; visible trust | New primary UI; confidence metadata already present | Large | P0 of redesign |
| M3 | Theme engine port (Scriban manifests, 6→best 34 templates) | Templates are compiled code | Theme switching, future marketplace | Per proposal; add explicit page-break rules to manifest schema | Large | P1 |
| M4 | Tailoring workspace route (JD gaps ↔ draft ↔ live preview + rescore) | Modal editor; disconnected scoring | The killer screen (research MVP §9.5) | New route; wires existing services | Large | P1 |
| M5 | Fact-constrained generation + novel-entity validator | Hallucinated seniority/numbers | Career integrity | Prompt + validator in FastAPI sidecar | Medium | P1 |
| M6 | From-scratch creation + paste-text import | Import-only onboarding | New-grad / no-CV users can start | Falls out of M2 + existing normalize path | Medium | P2 |
| M7 | Scoped DOCX adapter | Loudest export complaint in market | "Employer wants Word" solved | OpenXML `IOutputAdapter` (per proposal: scoped, not parity) | Medium | P2 |
### Long-term (Career Workspace horizons)
| # | Recommendation | Value | Complexity | Priority |
|---|---|---|---|---|
| L1 | Cover letters as profile-derived entities | Completes application package | Small post-M1 | P1 of Horizon 2 |
| L2 | Public profile / share link (theme over live profile, not doc snapshot) | RR/FlowCV parity + our live-data twist | Medium | P2 |
| L3 | Email-aware interview prep (thread context) | Unique-data moat; market's empty space | Medium | P1 of Horizon 3 |
| L4 | Skills matrix + gap analytics across tracked JDs | Nobody has it; pure aggregation for us | Small post-M1 | P2 |
| L5 | Portfolio / personal site as output adapters | Vision endgame; each ≈ adapter + theme | Medium each | P3 |
| L6 | Published profile schema + JSON Resume interop (+ MCP later) | Trust + agent-native future (RR precedent) | Small | P3 |
---
## 9. Proposed Future Architecture
Confirms the architecture proposal, with this audit's amendments folded in:
```
CareerProfile (one per user now, N later; stable item IDs; normalized dates;
│ provenance metadata retained; STRUCTURE canonical, text derived)
│ sources: upload/extraction runs (kept), from-scratch editor, paste-text
├──< CvVariant (free-standing OR job-linked; inherits profile, holds
│ │ selections/overrides by item ID — lineage preserved)
│ ├──< CvVersion (history: every generation & manual save; diff/restore)
│ └── ThemeRef ────→ Theme (SIBLING input, never welded into content;
│ declarative Scriban manifest: tokens, layout,
│ section styles, page-break rules; ATS rating)
├── Tailoring loop (JobApplication + MatchScore gaps ↔ variant edits ↔
│ constrained AI rewrites w/ diff ↔ live rescore)
└──> IOutputAdapter<T> (renderer boundary; PDF backend swappable — RR lesson)
├─ PDF CV (Playwright, today)
├─ DOCX CV (OpenXML, scoped)
├─ ATS plain-text (trust view — near-free)
├─ Cover letter (profile + job + thread context)
├─ Public profile (theme over live profile)
├─ Portfolio / site (Horizon 3+)
├─ LinkedIn content (Horizon 3)
└─ Interview prep (persisted; email-thread-aware — unique moat)
```
**Why this redesign is right (one paragraph):** every weakness this teardown found — single profile as user-row blobs, text/structure truth conflict, presentation welded to drafts, overwrite-anxiety mitigations, compiled templates, modal-trapped editing, transient AI outputs — is a symptom of the same root cause: *documents are the primary objects and career data is trapped inside them*. The competitor research shows the market leader in each dimension solved exactly one symptom (RR: schema; FlowCV: editor; Teal: career data; Novoresume: themes) and none solved the root. Inverting the model — profile as source of truth, every artifact a themed projection — fixes all symptoms with one architecture, reuses our real assets (extraction provenance, match scoring, email intelligence, hardened AI pipeline, Playwright rendering), and each subsequent output costs one adapter + one theme instead of one product.
**Sequencing note:** I1 (OAuth bug) ships now. M1+M2 before any visible redesign — the migration is invisible and everything depends on it. M3/M4 are the visible payoff. The proposal's phase plan stands; this audit adds stable IDs + date normalization as Phase-1 requirements and the diff-everywhere rule as a design principle from day one.
+4
View File
@@ -10,6 +10,9 @@
# production
/build
/out
/.next
next-env.d.ts
# misc
.DS_Store
@@ -21,3 +24,4 @@
npm-debug.log*
yarn-debug.log*
yarn-error.log*
*.tsbuildinfo
+4
View File
@@ -0,0 +1,4 @@
# react-scripts (kept only as the Jest test runner, see package.json) still declares a
# typescript ^3.2.1||^4 peer constraint that's stale for our actual (Next.js-driven) TS 5.x --
# it doesn't type-check via that peer path, so the conflict is safe to relax.
legacy-peer-deps=true
+8 -6
View File
@@ -2,13 +2,15 @@ FROM node:20-alpine AS build
WORKDIR /app
ARG REACT_APP_GOOGLE_CLIENT_ID
ARG REACT_APP_API_BASE_URL
ARG NEXT_PUBLIC_GOOGLE_CLIENT_ID
ARG NEXT_PUBLIC_MICROSOFT_CLIENT_ID
ARG NEXT_PUBLIC_API_BASE_URL
ENV REACT_APP_GOOGLE_CLIENT_ID=$REACT_APP_GOOGLE_CLIENT_ID
ENV REACT_APP_API_BASE_URL=$REACT_APP_API_BASE_URL
ENV NEXT_PUBLIC_GOOGLE_CLIENT_ID=$NEXT_PUBLIC_GOOGLE_CLIENT_ID
ENV NEXT_PUBLIC_MICROSOFT_CLIENT_ID=$NEXT_PUBLIC_MICROSOFT_CLIENT_ID
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
COPY package*.json ./
COPY package*.json .npmrc ./
RUN npm ci
COPY . .
@@ -17,7 +19,7 @@ RUN npm run build
FROM nginx:1.29.8-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/build /usr/share/nginx/html
COPY --from=build /app/out /usr/share/nginx/html
EXPOSE 80
+38
View File
@@ -0,0 +1,38 @@
import type { Metadata, Viewport } from "next";
import "../src/index.css";
export const metadata: Metadata = {
title: "Jobbjakt",
description: "Jobbjakt — track and manage job applications",
manifest: "/manifest.json",
icons: {
icon: [
{ url: "/favicon.svg", type: "image/svg+xml" },
{ url: "/favicon.ico" },
],
apple: "/logo192.png",
},
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
themeColor: "#15803d",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<link href="https://fonts.googleapis.com/css2?family=Archivo:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root">{children}</div>
</body>
</html>
);
}
+12
View File
@@ -0,0 +1,12 @@
"use client";
import dynamic from "next/dynamic";
// The whole app is a client-side React Router SPA whose providers read window/localStorage
// during their initial render -- ssr:false keeps Next's static prerender from ever executing
// any of it on the server.
const ClientApp = dynamic(() => import("../src/ClientApp"), { ssr: false });
export default function Page() {
return <ClientApp />;
}
+9
View File
@@ -0,0 +1,9 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
// The whole app is client-rendered React Router behind auth (see app/page.tsx) -- static
// export keeps the same "one index.html + JS bundle, served by nginx" deploy as CRA had.
output: "export",
reactStrictMode: true,
};
module.exports = nextConfig;
+822 -22
View File
@@ -8,6 +8,7 @@
"name": "job-tracker-ui",
"version": "0.1.0",
"dependencies": {
"@azure/msal-browser": "^5.17.0",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.3.9",
@@ -26,11 +27,13 @@
"@types/react-dom": "^19.2.3",
"axios": "^1.15.0",
"date-fns": "^4.1.0",
"diff": "^9.0.0",
"next": "^16.2.10",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^6.30.3",
"react-scripts": "5.0.1",
"typescript": "^4.9.5",
"typescript": "^5.9.3",
"web-vitals": "^2.1.4"
}
},
@@ -52,6 +55,27 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@azure/msal-browser": {
"version": "5.17.0",
"resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.17.0.tgz",
"integrity": "sha512-/yTnW2TCk9Mh+2b/NOaHAN+MryUNxzRTaJD/YtrqOA9bpBWfTXn/iyReRbaLrK/btBo3stEzLyEvuWp2NZ5DuA==",
"license": "MIT",
"dependencies": {
"@azure/msal-common": "16.11.1"
},
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/@azure/msal-common": {
"version": "16.11.1",
"resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.11.1.tgz",
"integrity": "sha512-yPohvMwWLv1XnaWnIUyKUh8CvcVChCGqG/VluGwfGmaAfrZTNt5yQ+sIs462Sgw6+e2K83KGmMJ860p73ZSCrw==",
"license": "MIT",
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
@@ -2401,6 +2425,16 @@
"postcss-selector-parser": "^6.0.10"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.2",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz",
"integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emotion/babel-plugin": {
"version": "11.13.5",
"resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz",
@@ -2659,6 +2693,472 @@
"deprecated": "Use @eslint/object-schema instead",
"license": "BSD-3-Clause"
},
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.2.4"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
"cpu": [
"arm"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
"cpu": [
"ppc64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
"cpu": [
"riscv64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
"cpu": [
"s390x"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
"cpu": [
"arm"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.2.4"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
"cpu": [
"ppc64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-riscv64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
"cpu": [
"riscv64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
"cpu": [
"s390x"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.2.4"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.7.0"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
"cpu": [
"ia32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@istanbuljs/load-nyc-config": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz",
@@ -3467,6 +3967,140 @@
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@next/env": {
"version": "16.2.10",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.10.tgz",
"integrity": "sha512-zLPxg9M0MEHmygpj5OuxjQ+vHMiy/K7cSp74G8ecYolmgUWw0RwN02tF56npup/+qaI8JB97hQgS/r2Hb6QwVA==",
"license": "MIT"
},
"node_modules/@next/swc-darwin-arm64": {
"version": "16.2.10",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.10.tgz",
"integrity": "sha512-v9IdJCa0H0mbo+8z5zwUpOk1Vj7RjkcI5uNYf5Ws1y6szf/p3Mzl9hLaST8SCt6L9h8NGnruZcd2+o0NTNwDhA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "16.2.10",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.10.tgz",
"integrity": "sha512-17IS0jJRViROGmA9uGdNR8VPJpfbnaVG7E9qhso5jDLkmyd0lSDORWxbcKINzcFqzZqGwGtMSnrFRxBpuUYjLQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "16.2.10",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.10.tgz",
"integrity": "sha512-GRQRsRtuciNJvB54AvvuQTiq0oZtFwa1owQqtZD8wwnGpM2L39MV22kpI72YSXLKIyY40LC66EiLFv4PiicXxg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "16.2.10",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.10.tgz",
"integrity": "sha512-zkN9MQYS7UQBro+FnISUq1itaQjXI9xqISzuQ+2bc921NcJ1x4yPCqrn77tVN6/dOOXaaWVX3k6/bR07pPwK+A==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "16.2.10",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.10.tgz",
"integrity": "sha512-iCVJnwvrPYECvA6WM/7+oo+OiTvedIKLxtCLAZP4xZR3nXa1zmzZyLPbYCmWvpd4CvMYF1EMTafd0ii3DygLvA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "16.2.10",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.10.tgz",
"integrity": "sha512-ov2g4H0dHY9bPoOU83m91hWT7Iq5qy13bUnyyshLU3HGR1Ownn0X9QpmDPc5iIUaahTp7f7LeGAhV4DSFtackw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "16.2.10",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.10.tgz",
"integrity": "sha512-DwAnhLX76HQiFFQNgWlcK+JzlnD1rZ+UK/WY0ZMI/deXpvgnesjNYrqcfo1JzBuz4Kf7o3brIBL0glI1junatA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "16.2.10",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.10.tgz",
"integrity": "sha512-0JXq3b85Jk9Jg4ntLUbXSPvoDw3gpZou7twuKdoFG2jOw635v7+IiXfTaa0TxVMyx78pUjnrVYwLgjKfX4e6/A==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@nicolo-ribaudo/eslint-scope-5-internals": {
"version": "5.1.1-v1",
"resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz",
@@ -3957,6 +4591,15 @@
"url": "https://github.com/sponsors/gregberge"
}
},
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
"integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/@tanstack/react-table": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
@@ -6242,6 +6885,12 @@
"node": ">=0.10.0"
}
},
"node_modules/client-only": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT"
},
"node_modules/cliui": {
"version": "7.0.4",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz",
@@ -7215,6 +7864,16 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"optional": true,
"engines": {
"node": ">=8"
}
},
"node_modules/detect-newline": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz",
@@ -7268,6 +7927,15 @@
"integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
"license": "Apache-2.0"
},
"node_modules/diff": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz",
"integrity": "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.3.1"
}
},
"node_modules/diff-sequences": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-27.5.1.tgz",
@@ -12198,6 +12866,87 @@
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
"license": "MIT"
},
"node_modules/next": {
"version": "16.2.10",
"resolved": "https://registry.npmjs.org/next/-/next-16.2.10.tgz",
"integrity": "sha512-2som5AVXb3kE6Yjine3/mNbBayYF58eguBWIVVUdr1y/L426xyVEgYxgBG+1QC34P2x5E+tcDup6XkuOAX3dCA==",
"license": "MIT",
"dependencies": {
"@next/env": "16.2.10",
"@swc/helpers": "0.5.15",
"baseline-browser-mapping": "^2.9.19",
"caniuse-lite": "^1.0.30001579",
"postcss": "8.4.31",
"styled-jsx": "5.1.6"
},
"bin": {
"next": "dist/bin/next"
},
"engines": {
"node": ">=20.9.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "16.2.10",
"@next/swc-darwin-x64": "16.2.10",
"@next/swc-linux-arm64-gnu": "16.2.10",
"@next/swc-linux-arm64-musl": "16.2.10",
"@next/swc-linux-x64-gnu": "16.2.10",
"@next/swc-linux-x64-musl": "16.2.10",
"@next/swc-win32-arm64-msvc": "16.2.10",
"@next/swc-win32-x64-msvc": "16.2.10",
"sharp": "^0.34.5"
},
"peerDependencies": {
"@opentelemetry/api": "^1.1.0",
"@playwright/test": "^1.51.1",
"babel-plugin-react-compiler": "*",
"react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
"react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
"sass": "^1.3.0"
},
"peerDependenciesMeta": {
"@opentelemetry/api": {
"optional": true
},
"@playwright/test": {
"optional": true
},
"babel-plugin-react-compiler": {
"optional": true
},
"sass": {
"optional": true
}
}
},
"node_modules/next/node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
"integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.6",
"picocolors": "^1.0.0",
"source-map-js": "^1.0.2"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/no-case": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz",
@@ -15519,6 +16268,51 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/sharp": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
"hasInstallScript": true,
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@img/colour": "^1.0.0",
"detect-libc": "^2.1.2",
"semver": "^7.7.3"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.34.5",
"@img/sharp-darwin-x64": "0.34.5",
"@img/sharp-libvips-darwin-arm64": "1.2.4",
"@img/sharp-libvips-darwin-x64": "1.2.4",
"@img/sharp-libvips-linux-arm": "1.2.4",
"@img/sharp-libvips-linux-arm64": "1.2.4",
"@img/sharp-libvips-linux-ppc64": "1.2.4",
"@img/sharp-libvips-linux-riscv64": "1.2.4",
"@img/sharp-libvips-linux-s390x": "1.2.4",
"@img/sharp-libvips-linux-x64": "1.2.4",
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
"@img/sharp-linux-arm": "0.34.5",
"@img/sharp-linux-arm64": "0.34.5",
"@img/sharp-linux-ppc64": "0.34.5",
"@img/sharp-linux-riscv64": "0.34.5",
"@img/sharp-linux-s390x": "0.34.5",
"@img/sharp-linux-x64": "0.34.5",
"@img/sharp-linuxmusl-arm64": "0.34.5",
"@img/sharp-linuxmusl-x64": "0.34.5",
"@img/sharp-wasm32": "0.34.5",
"@img/sharp-win32-arm64": "0.34.5",
"@img/sharp-win32-ia32": "0.34.5",
"@img/sharp-win32-x64": "0.34.5"
}
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -16076,6 +16870,29 @@
"webpack": "^5.0.0"
}
},
"node_modules/styled-jsx": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
"integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==",
"license": "MIT",
"dependencies": {
"client-only": "0.0.1"
},
"engines": {
"node": ">= 12.0.0"
},
"peerDependencies": {
"react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0"
},
"peerDependenciesMeta": {
"@babel/core": {
"optional": true
},
"babel-plugin-macros": {
"optional": true
}
}
},
"node_modules/stylehacks": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-5.1.1.tgz",
@@ -16427,23 +17244,6 @@
}
}
},
"node_modules/tailwindcss/node_modules/yaml": {
"version": "2.8.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
"license": "ISC",
"optional": true,
"peer": true,
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/tapable": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz",
@@ -16932,16 +17732,16 @@
}
},
"node_modules/typescript": {
"version": "4.9.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=4.2.0"
"node": ">=14.17"
}
},
"node_modules/unbox-primitive": {
+8 -5
View File
@@ -3,6 +3,7 @@
"version": "0.1.0",
"private": true,
"dependencies": {
"@azure/msal-browser": "^5.17.0",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.3.9",
@@ -21,18 +22,20 @@
"@types/react-dom": "^19.2.3",
"axios": "^1.15.0",
"date-fns": "^4.1.0",
"diff": "^9.0.0",
"next": "^16.2.10",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^6.30.3",
"react-scripts": "5.0.1",
"typescript": "^4.9.5",
"typescript": "^5.9.3",
"web-vitals": "^2.1.4"
},
"scripts": {
"start": "react-scripts start",
"build": "node --max-old-space-size=4096 ./node_modules/react-scripts/bin/react-scripts.js build",
"test": "react-scripts test",
"eject": "react-scripts eject"
"dev": "next dev",
"start": "next dev",
"build": "node --max-old-space-size=4096 ./node_modules/next/dist/bin/next build",
"test": "react-scripts test"
},
"eslintConfig": {
"extends": [
-21
View File
@@ -1,21 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="%PUBLIC_URL%/favicon.svg" type="image/svg+xml" />
<link rel="alternate icon" href="%PUBLIC_URL%/favicon.ico" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Archivo:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<meta name="theme-color" content="#15803d" />
<meta name="description" content="Jobbjakt — track and manage job applications" />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<title>Jobbjakt</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>
+43 -26
View File
@@ -27,16 +27,16 @@ import { PromptProvider } from "./prompt";
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 LoginPage from "./views/LoginPage";
import LandingPage from "./views/LandingPage";
import ForgotPasswordPage from "./views/ForgotPasswordPage";
import ResetPasswordPage from "./views/ResetPasswordPage";
import RouteErrorPage from "./views/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";
import { getThemeModePref, setThemeModePref, ThemeModePref } from "./themePrefs";
const AddJobModal = lazy(() => import("./components/AddJobModal"));
const KanbanBoard = lazy(() => import("./components/KanbanBoard"));
@@ -45,13 +45,13 @@ const CompaniesTable = lazy(() => import("./components/CompaniesTable"));
const SettingsView = lazy(() => import("./components/SettingsView"));
const RemindersView = lazy(() => import("./components/RemindersView"));
const QuickCommandDialog = lazy(() => import("./components/QuickCommandDialog"));
const ProfilePage = lazy(() => import("./pages/ProfilePage"));
const AdminAuditPage = lazy(() => import("./pages/AdminAuditPage"));
const AdminUsersPage = lazy(() => import("./pages/AdminUsersPage"));
const AdminSystemPage = lazy(() => import("./pages/AdminSystemPage"));
const CorrespondenceInboxPage = lazy(() => import("./pages/CorrespondenceInboxPage"));
const GmailReviewPage = lazy(() => import("./pages/GmailReviewPage"));
const NotFoundPage = lazy(() => import("./pages/NotFoundPage"));
const ProfilePage = lazy(() => import("./views/ProfilePage"));
const AdminAuditPage = lazy(() => import("./views/AdminAuditPage"));
const AdminUsersPage = lazy(() => import("./views/AdminUsersPage"));
const AdminSystemPage = lazy(() => import("./views/AdminSystemPage"));
const CorrespondenceInboxPage = lazy(() => import("./views/CorrespondenceInboxPage"));
const GmailReviewPage = lazy(() => import("./views/GmailReviewPage"));
const NotFoundPage = lazy(() => import("./views/NotFoundPage"));
type AuthConfig = { requireAuth: boolean };
type MeResponse = {
@@ -100,11 +100,21 @@ function titleFor(path: string, t: (k: any) => string): string {
return t("appTitle");
}
function subtitleFor(path: string, t: (k: any) => string): string | undefined {
if (path === "/dashboard") return t("dashboardPageSubtitle");
if (path.startsWith("/jobs")) return t("jobsPageSubtitle");
if (path.startsWith("/kanban")) return t("kanbanPageSubtitle");
if (path.startsWith("/reminders")) return t("remindersPageSubtitle");
if (path.startsWith("/correspondence/review")) return t("gmailReviewPageSubtitle");
if (path.startsWith("/correspondence")) return t("correspondencePageSubtitle");
return undefined;
}
function PageLoader() {
return <Box sx={{ p: 4 }}><Typography variant="h6">Loading...</Typography></Box>;
}
function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMode, onThemeModeChange, accentColor, onAccentColorChange, onResetAccentColor }: { jobPageSize: 15 | 20 | 25; setJobPageSize: (n: 15 | 20 | 25) => void; jobColumns: JobTableColumns; setJobColumns: (c: JobTableColumns) => void; themeMode: ThemeModePref; onThemeModeChange: (v: ThemeModePref) => void; accentColor: string; onAccentColorChange: (v: string) => void; onResetAccentColor: () => void; }) {
function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMode, onThemeModeChange }: { jobPageSize: 15 | 20 | 25; setJobPageSize: (n: 15 | 20 | 25) => void; jobColumns: JobTableColumns; setJobColumns: (c: JobTableColumns) => void; themeMode: ThemeModePref; onThemeModeChange: (v: ThemeModePref) => void; }) {
const location = useLocation();
const navigate = useNavigate();
const { t } = useI18n();
@@ -123,6 +133,9 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
const path = location.pathname;
const isJobs = path.startsWith("/jobs");
const shortcutHint = useMemo(() => (
typeof navigator !== "undefined" && /Mac|iPhone|iPod|iPad/.test(navigator.platform) ? "⌘K" : "Ctrl+K"
), []);
useEffect(() => {
api.get<AuthConfig>("/auth/config").then((r) => setRequireAuth(Boolean(r.data?.requireAuth))).catch(() => setRequireAuth(false));
@@ -206,6 +219,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
if (requireAuth && !me) return <Navigate to="/" replace state={{ from: path }} />;
const pageTitle = titleFor(path, t);
const pageSubtitle = subtitleFor(path, t);
const breadcrumbs = breadcrumbsFor(path, t);
const setAndPersistPageSize = (n: 15 | 20 | 25) => { setJobPageSize(n); window.localStorage.setItem("jobPageSize", String(n)); };
const setAndPersistColumns = (next: JobTableColumns) => { setJobColumns(next); window.localStorage.setItem("jobColumns", JSON.stringify(next)); };
@@ -246,14 +260,19 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
<IconButton
color="secondary"
size="small"
title={t("quickSearch")}
title={`${t("quickSearch")} (${shortcutHint})`}
onClick={() => setQuickOpen(true)}
sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2.5, width: 42, height: 42, flex: "0 0 auto" }}
>
<SearchIcon fontSize="small" />
</IconButton>
) : (
<Button variant="outlined" startIcon={<SearchIcon />} onClick={() => setQuickOpen(true)}>{t("quickSearch")}</Button>
<Button variant="outlined" startIcon={<SearchIcon />} onClick={() => setQuickOpen(true)} sx={{ gap: 0.5 }}>
{t("quickSearch")}
<Box component="span" sx={{ ml: 0.75, px: 0.75, py: 0.125, borderRadius: 1, border: "1px solid", borderColor: "divider", fontSize: 11, fontWeight: 700, color: "text.secondary", lineHeight: 1.6 }}>
{shortcutHint}
</Box>
</Button>
)}
{isJobs ? (
<Button variant="contained" onClick={() => setAddOpen(true)} sx={{ flex: { xs: 1, sm: "0 0 auto" }, minHeight: 42 }}>
@@ -267,6 +286,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
<>
<AppShell
pageTitle={pageTitle}
pageSubtitle={pageSubtitle}
breadcrumbs={breadcrumbs}
pathname={path}
nav={nav}
@@ -284,7 +304,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
>
<Suspense fallback={<PageLoader />}>
<Routes>
<Route path="/" element={<Navigate to="/jobs" replace />} />
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/dashboard" element={<DashboardView />} />
<Route path="/jobs" element={<JobTable refreshToken={refreshToken} pageSize={jobPageSize} onPageSizeChange={setAndPersistPageSize} columns={jobColumns} onColumnsChange={setAndPersistColumns} mode="jobs" />} />
<Route path="/reminders" element={<RemindersView />} />
@@ -297,7 +317,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
<Route path="/admin/users" element={<AdminUsersPage />} />
<Route path="/admin/system" element={<AdminSystemPage />} />
<Route path="/trash" element={<JobTable refreshToken={refreshToken} pageSize={jobPageSize} onPageSizeChange={setAndPersistPageSize} columns={jobColumns} onColumnsChange={setAndPersistColumns} mode="trash" />} />
<Route path="/settings" element={<SettingsView pageSize={jobPageSize} onPageSizeChange={setAndPersistPageSize} columns={jobColumns} onColumnsChange={setAndPersistColumns} themeMode={themeMode} onThemeModeChange={onThemeModeChange} accentColor={accentColor} onAccentColorChange={onAccentColorChange} onResetAccentColor={onResetAccentColor} />} />
<Route path="/settings" element={<SettingsView pageSize={jobPageSize} onPageSizeChange={setAndPersistPageSize} columns={jobColumns} onColumnsChange={setAndPersistColumns} themeMode={themeMode} onThemeModeChange={onThemeModeChange} />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</Suspense>
@@ -314,19 +334,16 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
export default function App() {
const systemPrefersDark = useMediaQuery("(prefers-color-scheme: dark)", { defaultMatches: true, noSsr: true });
const [themeMode, setThemeMode] = useState<ThemeModePref>(() => getThemeModePref());
const [accentColor, setAccentColorState] = useState<string>(() => getAccentColor());
const effectiveMode: "light" | "dark" = themeMode === "light" ? "light" : themeMode === "dark" ? "dark" : systemPrefersDark ? "dark" : "light";
const theme = useMemo(() => getTheme(effectiveMode, accentColor), [effectiveMode, accentColor]);
const theme = useMemo(() => getTheme(effectiveMode), [effectiveMode]);
useEffect(() => {
const sync = () => { setThemeMode(getThemeModePref()); setAccentColorState(getAccentColor()); };
const sync = () => { setThemeMode(getThemeModePref()); };
window.addEventListener("auth-changed", sync);
return () => window.removeEventListener("auth-changed", sync);
}, []);
const onThemeModeChange = (v: ThemeModePref) => { setThemeModePref(v); setThemeMode(v); };
const onAccentColorChange = (v: string) => { setAccentColor(v); setAccentColorState(getAccentColor()); };
const onResetAccentColor = () => { clearAccentColor(); setAccentColorState(getAccentColor()); };
const [jobPageSize, setJobPageSize] = useState<15 | 20 | 25>(() => {
const raw = window.localStorage.getItem("jobPageSize");
@@ -349,14 +366,14 @@ export default function App() {
{ path: "/login", element: <LoginPage />, errorElement: <RouteErrorPage /> },
{ path: "/forgot-password", element: <ForgotPasswordPage />, errorElement: <RouteErrorPage /> },
{ path: "/reset-password", element: <ResetPasswordPage />, errorElement: <RouteErrorPage /> },
{ path: "/*", element: <Shell jobPageSize={jobPageSize} setJobPageSize={setJobPageSize} jobColumns={jobColumns} setJobColumns={setJobColumns} themeMode={themeMode} onThemeModeChange={onThemeModeChange} accentColor={accentColor} onAccentColorChange={onAccentColorChange} onResetAccentColor={onResetAccentColor} />, errorElement: <RouteErrorPage /> },
], { future: { v7_relativeSplatPath: true } }), [jobColumns, jobPageSize, themeMode, accentColor]);
{ path: "/*", element: <Shell jobPageSize={jobPageSize} setJobPageSize={setJobPageSize} jobColumns={jobColumns} setJobColumns={setJobColumns} themeMode={themeMode} onThemeModeChange={onThemeModeChange} />, errorElement: <RouteErrorPage /> },
], { future: { v7_relativeSplatPath: true } }), [jobColumns, jobPageSize, themeMode]);
return (
<ToastProvider>
<ConfirmProvider>
<PromptProvider>
<CssVarsProvider key={`${effectiveMode}:${accentColor}`} theme={theme as any} defaultMode={effectiveMode} disableTransitionOnChange>
<CssVarsProvider key={effectiveMode} theme={theme as any} defaultMode={effectiveMode} disableTransitionOnChange>
<CssBaseline enableColorScheme />
<I18nProvider>
<RouterProvider router={router} future={{ v7_startTransition: true }} />
+20
View File
@@ -0,0 +1,20 @@
"use client";
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
import { AdapterDateFns } from "@mui/x-date-pickers/AdapterDateFns";
import App from "./App";
import ErrorBoundary from "./components/ErrorBoundary";
import { I18nProvider } from "./i18n/I18nProvider";
export default function ClientApp() {
return (
<LocalizationProvider dateAdapter={AdapterDateFns}>
<I18nProvider>
<ErrorBoundary>
<App />
</ErrorBoundary>
</I18nProvider>
</LocalizationProvider>
);
}
@@ -4,7 +4,7 @@ import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
import { api } from "./api";
import LandingPage from "./pages/LandingPage";
import LandingPage from "./views/LandingPage";
jest.mock("./api", () => ({
api: {
@@ -1,7 +1,7 @@
import React from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import AdminSystemPage from './pages/AdminSystemPage';
import AdminSystemPage from './views/AdminSystemPage';
import { I18nProvider } from './i18n/I18nProvider';
import { api } from './api';
+1 -1
View File
@@ -35,7 +35,7 @@ export function getApiErrorMessage(error: any, fallback = "Request failed.") {
return fallback;
}
const envBaseUrl = process.env.REACT_APP_API_BASE_URL;
const envBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL;
const defaultBaseUrl =
window.location.hostname === "localhost"
? "http://localhost:5202/api"
@@ -0,0 +1,18 @@
import React, { useId } from "react";
export default function JobbjaktMark(props: React.SVGProps<SVGSVGElement>) {
const gradientId = useId();
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 34 34" role="img" aria-label="Jobbjakt" {...props}>
<defs>
<linearGradient id={gradientId} x1="0" x2="1" y1="0" y2="1">
<stop offset="0%" stopColor="#6366f1" />
<stop offset="100%" stopColor="#22d3ee" />
</linearGradient>
</defs>
<rect width="34" height="34" rx="9" fill={`url(#${gradientId})`} />
<path d="M9 17.5l5 5 11-12" fill="none" stroke="#ffffff" strokeWidth="3.4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
@@ -1,15 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Job tracker">
<defs>
<linearGradient id="briefcase-track" x1="0" x2="1" y1="0" y2="1">
<stop offset="0%" stop-color="#3b82f6"/>
<stop offset="100%" stop-color="#14b8a6"/>
</linearGradient>
</defs>
<rect x="8" y="12" width="48" height="40" rx="12" fill="#0f172a"/>
<path d="M22 20v-2c0-3.3 2.7-6 6-6h8c3.3 0 6 2.7 6 6v2" fill="none" stroke="url(#briefcase-track)" stroke-width="4" stroke-linecap="round"/>
<rect x="14" y="22" width="36" height="26" rx="8" fill="none" stroke="url(#briefcase-track)" stroke-width="4"/>
<path d="M14 31h14" stroke="url(#briefcase-track)" stroke-width="4" stroke-linecap="round"/>
<path d="M36 31h14" stroke="url(#briefcase-track)" stroke-width="4" stroke-linecap="round"/>
<circle cx="32" cy="31" r="4.5" fill="#e2e8f0"/>
<path d="M24 40l5 5 11-12" fill="none" stroke="#e2e8f0" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1002 B

@@ -23,6 +23,7 @@ import AutoGraphIcon from "@mui/icons-material/AutoGraph";
import { api } from "../api";
import ViewStateNotice from "./ViewStateNotice";
import OnboardingChecklist from "./OnboardingChecklist";
import { getUserKeyFromToken } from "../themePrefs";
import { useI18n } from "../i18n/I18nProvider";
import { statusLabel } from "../pipeline";
@@ -287,6 +288,7 @@ export default function DashboardView() {
return (
<Box>
<OnboardingChecklist hasJobs={(stats?.total ?? 0) > 0} />
<SectionCard
sx={{
backgroundColor: "background.paper",
@@ -51,9 +51,10 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void
const { t } = useI18n();
const [me, setMe] = useState<MeResponse | null>(null);
const [working, setWorking] = useState(false);
const [allowRegistration, setAllowRegistration] = useState(false);
const hostRef = useRef<HTMLDivElement | null>(null);
const clientId = (process.env.REACT_APP_GOOGLE_CLIENT_ID || "").trim();
const clientId = (process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || "").trim();
const signedIn = Boolean(me?.provider);
const actionLabel = !signedIn
? t("continueWithGoogle")
@@ -72,6 +73,9 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void
useEffect(() => {
void refreshMe();
api.get<{ allowRegistration: boolean }>("/auth/config").then((res) => {
setAllowRegistration(Boolean(res.data?.allowRegistration));
}).catch(() => setAllowRegistration(false));
}, []);
useEffect(() => {
@@ -156,7 +160,7 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void
{!signedIn ? (
<Typography sx={{ color: "text.secondary" }}>
{t("googleSignInHint")}
{allowRegistration ? t("googleSignInHintSelfServe") : t("googleSignInHint")}
</Typography>
) : me?.provider === "local" ? (
<Typography sx={{ color: "text.secondary" }}>
@@ -10,7 +10,6 @@ import {
DialogTitle,
FormControl,
InputLabel,
LinearProgress,
MenuItem,
Select,
Tab,
@@ -1133,6 +1132,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
<MatchScoreCard score={matchScore} loading={loadingMatchScore} />
{loadingCandidateFit ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : candidateFit ? (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2.5 }}>
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: -1 }}>{t("jobDetailsAiFitHint")}</Typography>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
<Box><Typography variant="overline">{t("jobDetailsHowYouMatch")}</Typography><Typography sx={{ whiteSpace: "pre-wrap" }}>{candidateFit.matchSummary}</Typography></Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
@@ -1229,27 +1229,36 @@ function MatchScoreCard({ score, loading }: { score: MatchScore | null; loading:
return (
<Box sx={{ p: 1.75, mb: 2, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap", mb: 1 }}>
<Box sx={{ display: "flex", alignItems: "baseline", gap: 1 }}>
<Typography variant="h4" sx={{ fontWeight: 800, fontVariantNumeric: "tabular-nums" }}>{score.hasEnoughSignal ? `${score.score}%` : "—"}</Typography>
<Typography variant="overline">{t("matchScoreTitle")}</Typography>
</Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
<Chip size="small" color={color === "inherit" ? "default" : color} label={bandLabel} />
<Chip size="small" variant="outlined" label={t("matchScoreKeywordsCovered", { matched: score.matchedCount, total: score.totalKeywords })} />
<Box sx={{ display: "flex", gap: 2.5, alignItems: "center", flexWrap: "wrap", mb: 1.5 }}>
{score.hasEnoughSignal ? (
<Box role="img" aria-label={`${t("matchScoreTitle")}: ${score.score}%`} sx={{ position: "relative", width: 92, height: 92, flexShrink: 0 }}>
<CircularProgress variant="determinate" value={100} size={92} thickness={4} aria-hidden="true" sx={{ color: "divider", position: "absolute" }} />
<CircularProgress
variant="determinate"
value={score.score}
size={92}
thickness={4}
aria-hidden="true"
color={color === "inherit" ? "primary" : color}
sx={{ position: "absolute", "& .MuiCircularProgress-circle": { strokeLinecap: "round" } }}
/>
<Box sx={{ position: "absolute", inset: 0, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center" }}>
<Typography variant="h6" sx={{ fontWeight: 800, fontVariantNumeric: "tabular-nums", lineHeight: 1 }}>{score.score}%</Typography>
<Typography variant="caption" sx={{ color: "text.secondary" }}>{t("matchScoreTitle")}</Typography>
</Box>
</Box>
) : (
<Typography variant="h4" sx={{ fontWeight: 800 }}></Typography>
)}
<Box sx={{ flex: 1, minWidth: 200 }}>
{!score.hasEnoughSignal ? <Typography variant="body2" sx={{ color: "text.secondary", mb: 1 }}>{t("matchScoreNoSignal")}</Typography> : null}
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
<Chip size="small" color={color === "inherit" ? "default" : color} label={bandLabel} />
<Chip size="small" variant="outlined" label={t("matchScoreKeywordsCovered", { matched: score.matchedCount, total: score.totalKeywords })} />
</Box>
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: 1 }}>{t("matchScoreDeterministicHint")}</Typography>
</Box>
</Box>
{score.hasEnoughSignal ? (
<LinearProgress
variant="determinate"
value={score.score}
color={color === "inherit" ? "primary" : color}
sx={{ height: 8, borderRadius: 4, mb: 1.5 }}
/>
) : (
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1 }}>{t("matchScoreNoSignal")}</Typography>
)}
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mb: 1 }}>{t("matchScoreDeterministicHint")}</Typography>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5 }}>
<Box>
<Typography variant="overline">{t("matchScoreMatched")}</Typography>
+25 -2
View File
@@ -110,6 +110,19 @@ function parseTags(raw?: string | null): string[] {
}
function EmptyJobsState({ firstTime, onOpenSettings, t }: { firstTime: boolean; onOpenSettings: () => void; t: (key: any) => string }) {
if (!firstTime) {
return <Typography sx={{ py: 2, textAlign: "center", color: "text.secondary" }}>{t("jobTableNoJobsFound")}</Typography>;
}
return (
<Box sx={{ py: 4, textAlign: "center" }}>
<Typography sx={{ fontWeight: 800, mb: 0.5 }}>{t("jobTableEmptyFirstTimeTitle")}</Typography>
<Typography sx={{ color: "text.secondary", mb: 1.5, maxWidth: 440, mx: "auto" }}>{t("jobTableEmptyFirstTimeBody")}</Typography>
<Button variant="text" onClick={onOpenSettings}>{t("jobTableEmptyFirstTimeBookmarklet")}</Button>
</Box>
);
}
function generateOverview(job: JobApplication): string {
if (job.fullSummary) return job.fullSummary;
if (job.shortSummary) return job.shortSummary;
@@ -220,6 +233,12 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
return jobs.filter((job) => needsWorkflowWork(job));
}, [jobs, readinessFilter]);
// Distinguishes "you have zero jobs, period" from "no results match your filters" so the
// empty state can actually help a first-time user instead of just saying "nothing here".
const noFiltersActive = !debouncedSearch.trim() && statusFilter === "All" && companyFilterId === "All"
&& !debouncedLocation.trim() && !needsFollowUpOnly && readinessFilter === "all";
const isFirstTimeEmpty = mode === "jobs" && total === 0 && noFiltersActive;
const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds]);
const selectedAllOnPage = filteredJobs.length > 0 && filteredJobs.every((job) => selectedIdSet.has(job.id));
@@ -629,7 +648,9 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
</Paper>
);
})}
{filteredJobs.length === 0 && !jobsResource.loading && !jobsResource.error ? <Typography sx={{ py: 2, textAlign: "center" }}>{t("jobTableNoJobsFound")}</Typography> : null}
{filteredJobs.length === 0 && !jobsResource.loading && !jobsResource.error ? (
<EmptyJobsState firstTime={isFirstTimeEmpty} onOpenSettings={() => navigate("/settings")} t={t} />
) : null}
</Stack>
) : (
<Box sx={{ overflowX: "auto" }}>
@@ -721,7 +742,9 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
</React.Fragment>
);
})}
{filteredJobs.length === 0 && !jobsResource.loading && !jobsResource.error ? <TableRow><TableCell colSpan={visibleDesktopColumns}><Typography sx={{ py: 2, textAlign: "center" }}>{t("jobTableNoJobsFound")}</Typography></TableCell></TableRow> : null}
{filteredJobs.length === 0 && !jobsResource.loading && !jobsResource.error ? (
<TableRow><TableCell colSpan={visibleDesktopColumns}><EmptyJobsState firstTime={isFirstTimeEmpty} onOpenSettings={() => navigate("/settings")} t={t} /></TableCell></TableRow>
) : null}
</TableBody>
</Table>
</Box>
+36 -31
View File
@@ -4,7 +4,6 @@ import {
Box,
Card,
CardContent,
Chip,
IconButton,
Menu,
MenuItem,
@@ -102,7 +101,18 @@ export default function KanbanBoard() {
/>
{!jobsResource.loading && !jobsResource.error ? (
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(3, 1fr)", xl: "repeat(6, 1fr)" }, gap: 2, alignItems: "start" }}>
<Box
sx={{
display: { xs: "flex", md: "grid" },
gridTemplateColumns: { md: "repeat(3, 1fr)", xl: "repeat(6, 1fr)" },
gap: 2,
alignItems: "start",
overflowX: { xs: "auto", md: "visible" },
scrollSnapType: { xs: "x mandatory", md: "none" },
pb: { xs: 1, md: 0 },
"-webkit-overflow-scrolling": "touch",
}}
>
{STATUSES.map((status) => {
const c = toneColor(theme, status);
const list = groups.get(status) ?? [];
@@ -115,24 +125,23 @@ export default function KanbanBoard() {
p: 1.5,
borderRadius: 3,
minHeight: 220,
border: `1px solid ${alpha(c, theme.palette.mode === "dark" ? 0.25 : 0.18)}`,
background: alpha(c, theme.palette.mode === "dark" ? 0.10 : 0.06),
flex: { xs: "0 0 85vw", md: "none" },
scrollSnapAlign: { xs: "start", md: "none" },
border: "1px solid",
borderColor: "divider",
background: theme.palette.mode === "dark" ? alpha(theme.palette.common.white, 0.02) : alpha(theme.palette.text.primary, 0.015),
}}
>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 800, color: theme.palette.mode === "dark" ? "#f8fafc" : "inherit" }}>
{statusLabel(t, status)}
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 1.25, px: 0.25 }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<Box sx={{ width: 9, height: 9, borderRadius: "50%", backgroundColor: c, flexShrink: 0 }} />
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>
{statusLabel(t, status)}
</Typography>
</Box>
<Typography variant="body2" sx={{ color: "text.secondary", fontWeight: 700 }}>
{list.length}
</Typography>
<Chip
size="small"
label={list.length}
sx={{
fontWeight: 800,
color: alpha(c, theme.palette.mode === "dark" ? 0.95 : 0.9),
backgroundColor: alpha(c, theme.palette.mode === "dark" ? 0.18 : 0.12),
border: `1px solid ${alpha(c, theme.palette.mode === "dark" ? 0.35 : 0.22)}`,
}}
/>
</Box>
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
@@ -144,21 +153,18 @@ export default function KanbanBoard() {
onDragEnd={() => setDragJobId(null)}
sx={{
cursor: "grab",
borderRadius: 3,
border: `1px solid ${alpha(c, theme.palette.mode === "dark" ? 0.22 : 0.14)}`,
background: theme.palette.mode === "dark" ? "rgba(15,23,42,0.82)" : "rgba(255,255,255,0.96)",
backdropFilter: "blur(8px)",
color: theme.palette.mode === "dark" ? "#e5eefc" : "#0f172a",
borderRadius: 2.5,
borderLeft: `4px solid ${c}`,
boxShadow: theme.palette.mode === "dark" ? "none" : "0 1px 3px rgba(15,23,42,0.06)",
}}
>
<CardContent sx={{ p: 1.25, "&:last-child": { pb: 1.25 } }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1 }}>
<Typography sx={{ fontWeight: 800, lineHeight: 1.25, color: theme.palette.mode === "dark" ? "#f8fafc" : "#0f172a" }}>
{j.company?.name ?? ""}
<Typography sx={{ fontWeight: 800, lineHeight: 1.25 }}>
{j.jobTitle}
</Typography>
<IconButton
size="small"
sx={{ color: theme.palette.mode === "dark" ? "#e2e8f0" : "#0f172a" }}
onClick={(e) => {
e.stopPropagation();
setMenuJobId(j.id);
@@ -168,13 +174,12 @@ export default function KanbanBoard() {
<MoreHorizIcon fontSize="small" />
</IconButton>
</Box>
<Typography variant="body2" sx={{ color: theme.palette.mode === "dark" ? "#cbd5e1" : "#475569" }}>
{j.jobTitle}
<Typography variant="body2" sx={{ color: "text.secondary" }}>
{[j.company?.name, j.location].filter(Boolean).join(" · ")}
</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.75 }}>
{j.daysSince}d
</Typography>
<Box sx={{ display: "flex", gap: 1, mt: 1, flexWrap: "wrap" }}>
<Chip size="small" label={`${j.daysSince}d`} sx={{ color: theme.palette.mode === "dark" ? "#e2e8f0" : "#0f172a", backgroundColor: theme.palette.mode === "dark" ? "rgba(148,163,184,0.18)" : "rgba(148,163,184,0.18)" }} />
{j.location ? <Chip size="small" label={j.location} sx={{ color: theme.palette.mode === "dark" ? "#e2e8f0" : "#0f172a", backgroundColor: theme.palette.mode === "dark" ? "rgba(148,163,184,0.18)" : "rgba(148,163,184,0.18)" }} /> : null}
</Box>
</CardContent>
</Card>
))}
@@ -0,0 +1,185 @@
import React, { useEffect, useState } from "react";
import { Box, Button, Chip, Paper, Typography } from "@mui/material";
import { PublicClientApplication } from "@azure/msal-browser";
import { api, getApiErrorMessage } from "../api";
import { clearAuthClientState, getAuthPersistencePreference } from "../auth";
import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
type MeResponse = {
provider?: "local" | "google" | "microsoft" | "external";
email?: string;
userName?: string;
displayName?: string;
firstName?: string;
lastName?: string;
microsoftLink?: {
linked: boolean;
email?: string | null;
linkedAt?: string | null;
} | null;
};
let msalInstance: PublicClientApplication | null = null;
function getMsalInstance(clientId: string): PublicClientApplication {
msalInstance ??= new PublicClientApplication({
auth: { clientId, authority: "https://login.microsoftonline.com/common", redirectUri: window.location.origin },
});
return msalInstance;
}
export default function MicrosoftAuthCard({ onSignedIn }: { onSignedIn?: () => void }) {
const { toast } = useToast();
const { t } = useI18n();
const [me, setMe] = useState<MeResponse | null>(null);
const [working, setWorking] = useState(false);
const clientId = (process.env.REACT_APP_MICROSOFT_CLIENT_ID || "").trim();
const signedIn = Boolean(me?.provider);
const actionLabel = !signedIn
? t("continueWithMicrosoft")
: me?.provider === "local" && !me?.microsoftLink?.linked
? t("linkWithMicrosoft")
: t("signInWithMicrosoft");
async function refreshMe() {
try {
const res = await api.get<MeResponse>("/auth/me");
setMe(res.data);
} catch {
setMe(null);
}
}
useEffect(() => {
void refreshMe();
}, []);
useEffect(() => {
const onAuthChanged = () => { void refreshMe(); };
window.addEventListener("auth-changed", onAuthChanged);
return () => window.removeEventListener("auth-changed", onAuthChanged);
}, []);
async function handleSignIn() {
if (!clientId) return;
setWorking(true);
try {
const msal = getMsalInstance(clientId);
await msal.initialize();
const result = await msal.loginPopup({ scopes: ["openid", "profile", "email"] });
const idToken = result.idToken;
if (!idToken) throw new Error(t("microsoftAuthFailed"));
if (me?.provider === "local") {
const res = await api.post<{ linked: boolean; email?: string | null }>("/auth/microsoft/link", { token: idToken, rememberMe: getAuthPersistencePreference() === "local" });
toast(res.data?.email ? t("microsoftLinkedSuccessWithEmail", { email: res.data.email }) : t("microsoftLinkedSuccess"), "success");
await refreshMe();
} else {
await api.post("/auth/microsoft/exchange", { token: idToken, rememberMe: getAuthPersistencePreference() === "local" });
window.dispatchEvent(new Event("auth-changed"));
toast(t("microsoftSignedIn"), "success");
onSignedIn?.();
}
} catch (e: any) {
toast(getApiErrorMessage(e, t("microsoftAuthFailed")), "error");
} finally {
setWorking(false);
}
}
const signedInName = me?.userName || me?.displayName || [me?.firstName, me?.lastName].filter(Boolean).join(" ") || me?.email || "";
return (
<Paper sx={{ mt: 2, p: 2 }}>
<Typography variant="h6" sx={{ mb: 1 }}>
{t("microsoftAccountTitle")}
</Typography>
{!clientId && (
<Typography sx={{ color: "text.secondary" }}>
{t("microsoftSetupHint")}
</Typography>
)}
{clientId && (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
<Chip size="small" label={me?.microsoftLink?.linked ? t("microsoftLinked") : t("microsoftAvailableToLink")} color={me?.microsoftLink?.linked ? "success" : "default"} variant={me?.microsoftLink?.linked ? "filled" : "outlined"} />
{me?.microsoftLink?.linkedAt ? <Chip size="small" variant="outlined" label={t("microsoftLinkedDate", { date: new Date(me.microsoftLink.linkedAt).toLocaleDateString() })} /> : null}
</Box>
{!signedIn ? (
<Typography sx={{ color: "text.secondary" }}>
{t("microsoftSignInHint")}
</Typography>
) : me?.provider === "local" ? (
<Typography sx={{ color: "text.secondary" }}>
{me.microsoftLink?.linked
? t("microsoftLinkedTo", { email: me.microsoftLink.email || t("microsoftLinkedToYourAccount") })
: t("microsoftBindHint")}
</Typography>
) : (
<Typography sx={{ color: "text.secondary" }}>
{t("microsoftExchangeHint")}
</Typography>
)}
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "flex-start", gap: 1 }}>
<Typography variant="caption" sx={{ color: "text.secondary", fontWeight: 700, letterSpacing: 0.4, textTransform: "uppercase" }}>
{actionLabel}
</Typography>
<Button variant="outlined" disabled={working} onClick={() => void handleSignIn()}>
{actionLabel}
</Button>
</Box>
<Box sx={{ display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap" }}>
{signedIn ? (
<Button
variant="outlined"
onClick={() => {
void api.post("/auth/logout").catch(() => undefined).finally(() => {
clearAuthClientState();
setMe(null);
toast(t("signedOut"), "info");
});
}}
>
{t("signOut")}
</Button>
) : null}
{me?.provider === "local" && me.microsoftLink?.linked ? (
<Button
variant="outlined"
color="warning"
disabled={working}
onClick={async () => {
try {
await api.delete("/auth/microsoft/link");
toast(t("microsoftUnlinked"), "info");
await refreshMe();
} catch (e: any) {
const msg = e?.response?.data || e?.message || t("microsoftUnlinkFailed");
toast(String(msg), "error");
}
}}
>
{t("unlinkMicrosoft")}
</Button>
) : null}
</Box>
{signedIn && me?.email ? (
<Typography variant="body2" sx={{ color: "text.secondary" }}>
{t("signedInAs", { name: signedInName })}
</Typography>
) : null}
</Box>
)}
</Paper>
);
}
@@ -0,0 +1,81 @@
import React, { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Box, Button, IconButton, Paper, Stack, Typography } from "@mui/material";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import RadioButtonUncheckedIcon from "@mui/icons-material/RadioButtonUnchecked";
import CloseIcon from "@mui/icons-material/Close";
import { alpha, useTheme } from "@mui/material/styles";
import { api } from "../api";
import { getUserKeyFromToken } from "../themePrefs";
import { useI18n } from "../i18n/I18nProvider";
function dismissKey() {
return `onboardingChecklistDismissed:${getUserKeyFromToken()}`;
}
type MeResponse = { profileCvText?: string | null };
export default function OnboardingChecklist({ hasJobs }: { hasJobs: boolean }) {
const theme = useTheme();
const navigate = useNavigate();
const { t } = useI18n();
const [hasCv, setHasCv] = useState<boolean | null>(null);
const [dismissed, setDismissed] = useState(() => window.localStorage.getItem(dismissKey()) === "1");
useEffect(() => {
let active = true;
api.get<MeResponse>("/auth/me")
.then((r) => { if (active) setHasCv(Boolean(r.data?.profileCvText?.trim())); })
.catch(() => { if (active) setHasCv(false); });
return () => { active = false; };
}, []);
const allDone = hasCv === true && hasJobs;
if (dismissed || allDone || hasCv === null) return null;
const dismiss = () => {
window.localStorage.setItem(dismissKey(), "1");
setDismissed(true);
};
const steps = [
{ done: hasCv, label: t("onboardingStepCv"), action: () => navigate("/profile"), actionLabel: t("onboardingStepCvAction") },
{ done: hasJobs, label: t("onboardingStepJob"), action: () => navigate("/jobs"), actionLabel: t("onboardingStepJobAction") },
{ done: hasCv === true && hasJobs, label: t("onboardingStepMatch"), action: () => navigate("/jobs"), actionLabel: t("onboardingStepMatchAction") },
];
return (
<Paper
sx={{
p: 2.25,
mb: 2,
borderRadius: 4,
border: "1px solid",
borderColor: alpha(theme.palette.primary.main, 0.25),
background: alpha(theme.palette.primary.main, 0.04),
position: "relative",
}}
>
<IconButton size="small" onClick={dismiss} aria-label={t("onboardingDismiss")} sx={{ position: "absolute", top: 8, right: 8 }}>
<CloseIcon fontSize="small" />
</IconButton>
<Typography sx={{ fontWeight: 900, mb: 0.25 }}>{t("onboardingTitle")}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>{t("onboardingBody")}</Typography>
<Stack spacing={1}>
{steps.map((step) => (
<Box key={step.label} sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 1.5, flexWrap: "wrap" }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
{step.done ? <CheckCircleIcon fontSize="small" color="success" /> : <RadioButtonUncheckedIcon fontSize="small" sx={{ color: "text.secondary" }} />}
<Typography variant="body2" sx={{ fontWeight: step.done ? 400 : 700, color: step.done ? "text.secondary" : "text.primary", textDecoration: step.done ? "line-through" : "none" }}>
{step.label}
</Typography>
</Box>
{!step.done ? <Button size="small" variant="text" onClick={step.action}>{step.actionLabel}</Button> : null}
</Box>
))}
</Stack>
</Paper>
);
}
+55 -168
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from "react";
import React, { useEffect, useState } from "react";
import {
Box,
@@ -9,11 +9,9 @@ import {
InputLabel,
MenuItem,
Paper,
Popover,
Select,
Tab,
Tabs,
TextField,
Typography,
} from "@mui/material";
@@ -21,12 +19,9 @@ import { useNavigate } from "react-router-dom";
import { JobTableColumns } from "./JobTable";
import ImportExportJobs from "./ImportExportJobs";
import GoogleAuthCard from "./GoogleAuthCard";
import EmailProviderConnections from "./EmailProviderConnections";
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";
@@ -37,17 +32,23 @@ interface Props {
onColumnsChange: (next: JobTableColumns) => void;
themeMode: ThemeModePref;
onThemeModeChange: (v: ThemeModePref) => void;
accentColor: string;
onAccentColorChange: (v: string) => void;
onResetAccentColor: () => void;
}
function TabPanel({ value, index, children }: { value: number; index: number; children: React.ReactNode }) {
if (value !== index) return null;
return <Box sx={{ mt: 2 }}>{children}</Box>;
return <Box sx={{ mt: 2.5 }}>{children}</Box>;
}
function SectionCard({ title, subtitle, children }: { title: string; subtitle?: string; children: React.ReactNode }) {
return (
<Paper sx={{ p: 2.5 }}>
<Typography variant="overline" sx={{ color: "text.secondary", fontWeight: 800 }}>{title}</Typography>
{subtitle ? <Typography variant="body2" sx={{ color: "text.secondary", mt: 0.25, mb: 1.5 }}>{subtitle}</Typography> : <Box sx={{ mb: 1.5 }} />}
{children}
</Paper>
);
}
const ACCENTS = ["#6366f1", "#22d3ee", "#2563eb", "#8b5cf6", "#15803d", "#16a34a", "#0f766e", "#f97316"];
const NOTIFICATION_PREFS_KEY = "settings.notificationPrefs";
type NotificationPrefs = {
@@ -88,43 +89,19 @@ export default function SettingsView({
onColumnsChange,
themeMode,
onThemeModeChange,
accentColor,
onAccentColorChange,
onResetAccentColor,
}: Props) {
const navigate = useNavigate();
const [tab, setTab] = useState(0);
const { language, setLanguage, t } = useI18n();
const [accentAnchor, setAccentAnchor] = useState<HTMLElement | null>(null);
const [accentDraft, setAccentDraft] = useState(accentColor);
const [notificationPrefs, setNotificationPrefs] = useState<NotificationPrefs>(() => loadNotificationPrefs());
const accentOk = useMemo(() => /^#[0-9a-fA-F]{6}$/.test(accentColor), [accentColor]);
const accentDraftOk = useMemo(() => /^#[0-9a-fA-F]{6}$/.test(accentDraft), [accentDraft]);
useEffect(() => {
setAccentDraft(accentOk ? accentColor : "#15803d");
}, [accentColor, accentOk]);
useEffect(() => {
window.localStorage.setItem(NOTIFICATION_PREFS_KEY, JSON.stringify(notificationPrefs));
}, [notificationPrefs]);
const applyAccent = () => {
if (!accentDraftOk) return;
onAccentColorChange(accentDraft);
setAccentAnchor(null);
};
const resetAccent = () => {
onResetAccentColor();
setAccentDraft("#15803d");
setAccentAnchor(null);
};
return (
<Paper sx={{ mt: 0, p: 2 }}>
<Typography variant="h5" sx={{ mb: 1, fontWeight: 900 }}>
<Paper sx={{ mt: 0, p: 2.5 }}>
<Typography variant="h5" sx={{ mb: 0.5, fontWeight: 900 }}>
{t("settingsTitle")}
</Typography>
<Typography sx={{ color: "text.secondary", mb: 2 }}>
@@ -135,130 +112,48 @@ export default function SettingsView({
<Tab label={t("settingsTabGeneral")} />
<Tab label={t("settingsTabFollowUps")} />
<Tab label={t("settingsTabNotifications")} />
<Tab label={t("settingsTabAccount")} />
<Tab label={t("settingsTabBackup")} />
</Tabs>
<TabPanel value={tab} index={0}>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
<Paper sx={{ p: 2 }}>
<Typography sx={{ fontWeight: 950, mb: 1 }}>{t("settingsAppearance")}</Typography>
<FormControl fullWidth sx={{ mb: 2 }}>
<InputLabel id="theme-mode-label">{t("settingsTheme")}</InputLabel>
<Select
labelId="theme-mode-label"
value={themeMode}
label={t("settingsTheme")}
onChange={(e) => onThemeModeChange(e.target.value as ThemeModePref)}
>
<MenuItem value="system">{t("settingsThemeSystem")}</MenuItem>
<MenuItem value="dark">{t("settingsThemeDark")}</MenuItem>
<MenuItem value="light">{t("settingsThemeLight")}</MenuItem>
</Select>
</FormControl>
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 2, flexWrap: "wrap" }}>
<Box>
<Typography variant="caption" sx={{ mb: 0.75, display: "block" }}>{t("settingsAccent")}</Typography>
<Button
variant="outlined"
onClick={(e) => setAccentAnchor(e.currentTarget)}
sx={{ gap: 1.25, justifyContent: "flex-start", minWidth: 180 }}
<Box sx={{ display: "grid", gap: 2.5 }}>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2.5 }}>
<SectionCard title={t("settingsAppearance")}>
<FormControl fullWidth>
<InputLabel id="theme-mode-label">{t("settingsTheme")}</InputLabel>
<Select
labelId="theme-mode-label"
value={themeMode}
label={t("settingsTheme")}
onChange={(e) => onThemeModeChange(e.target.value as ThemeModePref)}
>
<Box sx={{ width: 20, height: 20, borderRadius: 999, bgcolor: accentOk ? accentColor : "#15803d", border: "1px solid", borderColor: "divider" }} />
{accentOk ? accentColor.toUpperCase() : "#15803D"}
</Button>
</Box>
<Button variant="outlined" onClick={resetAccent}>
{t("settingsReset")}
</Button>
</Box>
<MenuItem value="system">{t("settingsThemeSystem")}</MenuItem>
<MenuItem value="dark">{t("settingsThemeDark")}</MenuItem>
<MenuItem value="light">{t("settingsThemeLight")}</MenuItem>
</Select>
</FormControl>
</SectionCard>
<Popover
open={Boolean(accentAnchor)}
anchorEl={accentAnchor}
onClose={() => setAccentAnchor(null)}
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
>
<Box sx={{ p: 2, width: 280, display: "grid", gap: 1.5 }}>
<Typography sx={{ fontWeight: 900 }}>{t("settingsAccent")}</Typography>
<input
aria-label={t("settingsAccent")}
type="color"
value={accentDraftOk ? accentDraft : "#15803d"}
onChange={(e) => setAccentDraft(e.target.value)}
style={{ width: "100%", height: 52, border: "none", background: "transparent", padding: 0, cursor: "pointer" }}
/>
<TextField
label={t("settingsAccent")}
value={accentDraft}
onChange={(e) => setAccentDraft(e.target.value)}
error={!accentDraftOk}
helperText={accentDraftOk ? t("settingsAccentHelp") : t("settingsAccentInvalid")}
fullWidth
/>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
{ACCENTS.map((c) => (
<button
key={c}
type="button"
onClick={() => setAccentDraft(c)}
title={c}
aria-label={`${t("settingsAccent")} ${c}`}
style={{
width: 28,
height: 28,
borderRadius: 999,
border: c.toLowerCase() === accentDraft.toLowerCase() ? "2px solid rgba(15,23,42,0.9)" : "1px solid rgba(148,163,184,0.35)",
background: c,
cursor: "pointer",
}}
/>
))}
</Box>
<Box sx={{ display: "flex", justifyContent: "flex-end", gap: 1 }}>
<Button variant="text" onClick={() => setAccentAnchor(null)}>{t("cancel")}</Button>
<Button variant="contained" onClick={applyAccent} disabled={!accentDraftOk}>{t("save")}</Button>
</Box>
</Box>
</Popover>
<SectionCard title={t("settingsLanguageTitle")} subtitle={t("settingsLanguageBody")}>
<FormControl fullWidth>
<InputLabel id="language-label">{t("settingsPreferredLanguage")}</InputLabel>
<Select
labelId="language-label"
value={language}
label={t("settingsPreferredLanguage")}
onChange={(e) => setLanguage(e.target.value as "en" | "no")}
>
<MenuItem value="en">{t("settingsEnglish")}</MenuItem>
<MenuItem value="no">{t("settingsNorwegian")}</MenuItem>
</Select>
</FormControl>
</SectionCard>
</Box>
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: 1 }}>
{t("settingsSavedPerUser")}
</Typography>
</Paper>
<Paper sx={{ p: 2 }}>
<Typography sx={{ fontWeight: 950, mb: 1 }}>{t("settingsLanguageTitle")}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mb: 2 }}>
{t("settingsLanguageBody")}
</Typography>
<FormControl fullWidth sx={{ mb: 2 }}>
<InputLabel id="language-label">{t("settingsPreferredLanguage")}</InputLabel>
<Select
labelId="language-label"
value={language}
label={t("settingsPreferredLanguage")}
onChange={(e) => setLanguage(e.target.value as "en" | "no")}
>
<MenuItem value="en">{t("settingsEnglish")}</MenuItem>
<MenuItem value="no">{t("settingsNorwegian")}</MenuItem>
</Select>
</FormControl>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
{t("settingsMorePagesSoon")}
</Typography>
</Paper>
<Paper sx={{ p: 2, gridColumn: { xs: "1 / -1", md: "1 / -1" } }}>
<Typography sx={{ fontWeight: 950, mb: 1 }}>{t("settingsJobs")}</Typography>
<Box sx={{ display: "flex", gap: 3, flexWrap: "wrap" }}>
<SectionCard title={t("settingsJobs")}>
<Box sx={{ display: "flex", gap: 4, flexWrap: "wrap" }}>
<Box sx={{ minWidth: 240 }}>
<Typography variant="h6" sx={{ mb: 1 }}>
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 700 }}>
{t("settingsPagination")}
</Typography>
<FormControl fullWidth>
@@ -277,7 +172,7 @@ export default function SettingsView({
</Box>
<Box sx={{ minWidth: 240 }}>
<Typography variant="h6" sx={{ mb: 1 }}>
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 700 }}>
{t("settingsColumns")}
</Typography>
{(
@@ -297,8 +192,10 @@ export default function SettingsView({
</Box>
</Box>
<ImportExportJobs />
</Paper>
<Box sx={{ mt: 2 }}>
<ImportExportJobs />
</Box>
</SectionCard>
<QuickCaptureCard />
</Box>
@@ -309,9 +206,7 @@ export default function SettingsView({
</TabPanel>
<TabPanel value={tab} index={2}>
<Paper sx={{ p: 2 }}>
<Typography sx={{ fontWeight: 950, mb: 0.5 }}>{t("settingsNotificationsTitle")}</Typography>
<Typography sx={{ color: "text.secondary", mb: 2 }}>{t("settingsNotificationsBody")}</Typography>
<SectionCard title={t("settingsNotificationsTitle")} subtitle={t("settingsNotificationsBody")}>
<Box sx={{ display: "grid", gap: 1 }}>
<FormControlLabel
control={<Checkbox checked={notificationPrefs.emailFollowUpReminders} onChange={(e) => setNotificationPrefs((prev) => ({ ...prev, emailFollowUpReminders: e.target.checked }))} />}
@@ -333,18 +228,10 @@ export default function SettingsView({
<Button variant="outlined" onClick={() => navigate("/reminders")}>{t("settingsOpenReminderInbox")}</Button>
<Button variant="text" onClick={() => navigate("/admin/system")}>{t("settingsCheckSystemStatus")}</Button>
</Box>
</Paper>
</SectionCard>
</TabPanel>
<TabPanel value={tab} index={3}>
<AuthStatusCard />
<GoogleAuthCard />
<Box sx={{ mt: 2 }}>
<EmailProviderConnections />
</Box>
</TabPanel>
<TabPanel value={tab} index={4}>
<BackupCard />
</TabPanel>
</Paper>
@@ -0,0 +1,32 @@
import React from "react";
import { Box } from "@mui/material";
import { diffWords } from "diff";
// AI-mutation trust primitive (career-workspace-implementation-roadmap.md Phase F5): every AI
// rewrite should show what it actually changed before the user accepts it, instead of silently
// overwriting. Word-level diff keeps small edits readable; whole-paragraph rewrites still show
// as one big change, which is itself useful signal ("this replaced almost everything").
export default function TextDiff({ before, after }: { before: string; after: string }) {
const parts = React.useMemo(() => diffWords(before ?? "", after ?? ""), [before, after]);
return (
<Box sx={{ whiteSpace: "pre-wrap", fontSize: "0.875rem", lineHeight: 1.6 }}>
{parts.map((part, index) => (
<Box
key={index}
component="span"
sx={{
backgroundColor: part.added ? "success.main" : part.removed ? "error.main" : "transparent",
color: part.added || part.removed ? "common.white" : "text.primary",
opacity: part.added || part.removed ? 0.85 : 1,
textDecoration: part.removed ? "line-through" : "none",
borderRadius: part.added || part.removed ? 0.5 : 0,
px: part.added || part.removed ? 0.25 : 0,
}}
>
{part.value}
</Box>
))}
</Box>
);
}
@@ -4,7 +4,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { ToastProvider } from './toast';
import { I18nProvider } from './i18n/I18nProvider';
import CorrespondenceInboxPage from './pages/CorrespondenceInboxPage';
import CorrespondenceInboxPage from './views/CorrespondenceInboxPage';
import { api } from './api';
jest.mock('./api', () => ({
@@ -4,7 +4,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { ToastProvider } from './toast';
import { I18nProvider } from './i18n/I18nProvider';
import GmailReviewPage from './pages/GmailReviewPage';
import GmailReviewPage from './views/GmailReviewPage';
import { api } from './api';
jest.mock('./api', () => ({
+86 -18
View File
@@ -18,6 +18,12 @@ export const translations = {
home: "Home",
analytics: "Analytics",
overview: "Overview",
dashboardPageSubtitle: "Your search at a glance — response rate, funnel, and what needs attention.",
jobsPageSubtitle: "Filter, search, and manage every application in one table.",
kanbanPageSubtitle: "Drag a card between stages to update its status.",
remindersPageSubtitle: "Everything due for follow-up, soonest first.",
correspondencePageSubtitle: "Every message linked to a job, in one inbox.",
gmailReviewPageSubtitle: "Review Gmail threads before linking them to a job.",
account: "Account",
profile: "Profile",
admin: "Admin",
@@ -128,22 +134,17 @@ export const translations = {
settingsTabGeneral: "General",
settingsTabFollowUps: "Follow-ups",
settingsTabNotifications: "Notifications",
settingsTabAccount: "Account",
settingsTabBackup: "Backup",
settingsAppearance: "Appearance",
settingsTheme: "Theme",
settingsThemeSystem: "System",
settingsThemeDark: "Dark",
settingsThemeLight: "Light",
settingsAccent: "Accent",
settingsReset: "Reset",
settingsSavedPerUser: "Saved per user on this browser.",
settingsLanguageTitle: "Language and localization",
settingsLanguageBody: "Set your preferred app language. This is also the language used when deciding whether imported job descriptions should show translated text.",
settingsPreferredLanguage: "Preferred language",
settingsEnglish: "English",
settingsNorwegian: "Norwegian Bokmål",
settingsMorePagesSoon: "More pages will be moved onto this translation system as the UI cleanup continues.",
settingsJobs: "Jobs",
settingsPagination: "Pagination",
settingsRowsPerPage: "Rows per page",
@@ -167,8 +168,6 @@ export const translations = {
settingsNotificationsFollowUpReminders: "Email reminders for follow-ups",
settingsNotificationsGhostedJobs: "Email alerts for ghosted jobs",
settingsNotificationsInAppReminders: "Highlight reminders in the app",
settingsAccentHelp: "Drag in the color picker, then save when it looks right.",
settingsAccentInvalid: "Use a full hex color like #15803D.",
settingsCheckSystemStatus: "Check system status",
profileTitle: "Profile",
profileHeadlinePlaceholder: "Add a short headline to personalize your account view.",
@@ -313,6 +312,15 @@ export const translations = {
cropDialogSave: "Save image",
dashboardOverviewTitle: "Dashboard overview",
dashboardHeroLabel: "Job search overview",
onboardingTitle: "Get set up",
onboardingBody: "A few steps to get the most out of Jobbjakt.",
onboardingDismiss: "Dismiss",
onboardingStepCv: "Add your CV",
onboardingStepCvAction: "Add CV",
onboardingStepJob: "Import your first job",
onboardingStepJobAction: "Add job",
onboardingStepMatch: "Check your CV match score on a job",
onboardingStepMatchAction: "Open jobs",
dashboardResponseRate: "{rate}% response rate",
dashboardMonthsShort: "{count} mo",
dashboardAppliedCount: "{count} applied",
@@ -608,11 +616,12 @@ export const translations = {
adminSystemCpuMode: "CPU mode",
adminSystemNoSmtpHost: "No SMTP host configured",
googleAccountTitle: "Google account",
googleSetupHint: "Set `REACT_APP_GOOGLE_CLIENT_ID` in your UI environment to enable Google sign-in and account linking.",
googleSetupHint: "Set `NEXT_PUBLIC_GOOGLE_CLIENT_ID` in your UI environment to enable Google sign-in and account linking.",
googleLinked: "Linked",
googleAvailableToLink: "Available to link",
googleLinkedDate: "Linked {date}",
googleSignInHint: "Sign in with a Google account that has already been linked to your Jobbjakt user.",
googleSignInHintSelfServe: "Continue with Google. New here? We'll create your account automatically.",
continueWithGoogle: "Continue with Google",
signInWithGoogle: "Sign in with Google",
linkWithGoogle: "Link with Google",
@@ -628,6 +637,26 @@ export const translations = {
googleScriptLoadFailed: "Google auth script failed to load.",
googleUnlinked: "Google account unlinked.",
googleUnlinkFailed: "Failed to unlink Google account.",
microsoftAccountTitle: "Microsoft account",
microsoftSetupHint: "Set `NEXT_PUBLIC_MICROSOFT_CLIENT_ID` in your UI environment to enable Microsoft sign-in and account linking.",
microsoftLinked: "Linked",
microsoftAvailableToLink: "Available to link",
microsoftLinkedDate: "Linked {date}",
microsoftSignInHint: "Sign in with a Microsoft account that has already been linked to your Jobbjakt user.",
continueWithMicrosoft: "Continue with Microsoft",
signInWithMicrosoft: "Sign in with Microsoft",
linkWithMicrosoft: "Link with Microsoft",
microsoftLinkedTo: "Linked to {email}.",
microsoftLinkedToYourAccount: "Linked to your Microsoft account.",
microsoftBindHint: "Bind a Microsoft account to this user so you can sign in with Microsoft and still keep your normal app roles and data.",
microsoftExchangeHint: "Exchange your Microsoft sign-in for a normal Jobbjakt session.",
microsoftSignedIn: "Signed in with Microsoft.",
microsoftLinkedSuccess: "Microsoft account linked.",
microsoftLinkedSuccessWithEmail: "Linked Microsoft account {email}.",
microsoftAuthFailed: "Microsoft authentication failed.",
microsoftUnlinked: "Microsoft account unlinked.",
microsoftUnlinkFailed: "Failed to unlink Microsoft account.",
unlinkMicrosoft: "Unlink Microsoft",
signedOut: "Signed out.",
signedInAs: "Signed in as {name}.",
unlinkGoogle: "Unlink Google",
@@ -663,6 +692,7 @@ export const translations = {
authOptional: "Authentication is optional in this environment.",
emailAndPassword: "Email & password",
google: "Google",
microsoft: "Microsoft",
createAccount: "Create account",
signedIn: "Signed in.",
rememberMe: "Remember me",
@@ -736,6 +766,9 @@ export const translations = {
jobTableOverview: "Overview",
jobTableNoSummaryYet: "No summary yet.",
jobTableNoJobsFound: "No jobs found.",
jobTableEmptyFirstTimeTitle: "No jobs yet — let's fix that.",
jobTableEmptyFirstTimeBody: "Click \"Add job\" above to add one manually, or paste a job posting URL. There's also a one-click bookmarklet that captures a posting straight from the page you're viewing.",
jobTableEmptyFirstTimeBookmarklet: "Set up the bookmarklet",
jobTableSetStatus: "Set {status}",
editJobTitle: "Edit job",
editJobIntro: "Update job details, timeline status, documents, and notes from one editing workspace.",
@@ -880,6 +913,7 @@ export const translations = {
jobDetailsFollowUpSent: "Follow-up sent and logged.",
jobDetailsFollowUpSendFailed: "Failed to send follow-up.",
jobDetailsHowYouMatch: "How you match",
jobDetailsAiFitHint: "AI opinion — strengths, gaps, and a tailored pitch based on your CV and this posting.",
matchScoreTitle: "Match score",
matchScoreLoading: "Scoring your CV against this role…",
matchScoreBand_Strong: "Strong match",
@@ -888,7 +922,7 @@ export const translations = {
matchScoreBand_Unknown: "Not enough signal",
matchScoreKeywordsCovered: "{matched}/{total} keywords",
matchScoreNoSignal: "Add more CV detail or a fuller job description to get a reliable score.",
matchScoreDeterministicHint: "Deterministic keyword coverage — no AI, so the score is stable and repeatable.",
matchScoreDeterministicHint: "Deterministic keyword coverage — no AI, so the score is stable and repeatable. For a written opinion on strengths and gaps, see the AI section below.",
matchScoreMatched: "Matched keywords",
matchScoreMissing: "Missing keywords",
matchScoreNoneYet: "No matches found yet.",
@@ -962,6 +996,12 @@ export const translations = {
home: "Hjem",
analytics: "Analyse",
overview: "Oversikt",
dashboardPageSubtitle: "Søket ditt i korte trekk — svarrate, trakt og hva som trenger oppmerksomhet.",
jobsPageSubtitle: "Filtrer, søk og administrer alle søknader i én tabell.",
kanbanPageSubtitle: "Dra et kort mellom stadier for å oppdatere status.",
remindersPageSubtitle: "Alt som trenger oppfølging, snarest først.",
correspondencePageSubtitle: "Alle meldinger koblet til en jobb, i én innboks.",
gmailReviewPageSubtitle: "Se gjennom Gmail-tråder før du kobler dem til en jobb.",
account: "Konto",
profile: "Profil",
admin: "Admin",
@@ -1072,22 +1112,17 @@ export const translations = {
settingsTabGeneral: "Generelt",
settingsTabFollowUps: "Oppfølging",
settingsTabNotifications: "Varsler",
settingsTabAccount: "Konto",
settingsTabBackup: "Sikkerhetskopi",
settingsAppearance: "Utseende",
settingsTheme: "Tema",
settingsThemeSystem: "System",
settingsThemeDark: "Mørkt",
settingsThemeLight: "Lyst",
settingsAccent: "Aksent",
settingsReset: "Tilbakestill",
settingsSavedPerUser: "Lagres per bruker i denne nettleseren.",
settingsLanguageTitle: "Språk og lokalisering",
settingsLanguageBody: "Velg foretrukket språk i appen. Dette brukes også når appen avgjør om importerte stillingsbeskrivelser skal vise oversatt tekst.",
settingsPreferredLanguage: "Foretrukket språk",
settingsEnglish: "Engelsk",
settingsNorwegian: "Norsk Bokmål",
settingsMorePagesSoon: "Flere sider flyttes til dette oversettelsessystemet etter hvert som UI-oppryddingen fortsetter.",
settingsJobs: "Jobber",
settingsPagination: "Paginering",
settingsRowsPerPage: "Rader per side",
@@ -1111,8 +1146,6 @@ export const translations = {
settingsNotificationsFollowUpReminders: "E-postpåminnelser for oppfølginger",
settingsNotificationsGhostedJobs: "E-postvarsler for ghostede jobber",
settingsNotificationsInAppReminders: "Fremhev påminnelser i appen",
settingsAccentHelp: "Dra i fargevelgeren, og lagre når den ser riktig ut.",
settingsAccentInvalid: "Bruk en full hex-farge som #15803D.",
settingsCheckSystemStatus: "Sjekk systemstatus",
profileTitle: "Profil",
profileHeadlinePlaceholder: "Legg til en kort overskrift for å gjøre kontovisningen mer personlig.",
@@ -1257,6 +1290,15 @@ export const translations = {
cropDialogSave: "Lagre bilde",
dashboardOverviewTitle: "Dashboard-oversikt",
dashboardHeroLabel: "Oversikt over jobbsøket",
onboardingTitle: "Kom i gang",
onboardingBody: "Noen få steg for å få mest mulig ut av Jobbjakt.",
onboardingDismiss: "Lukk",
onboardingStepCv: "Legg til CV-en din",
onboardingStepCvAction: "Legg til CV",
onboardingStepJob: "Importer din første jobb",
onboardingStepJobAction: "Legg til jobb",
onboardingStepMatch: "Sjekk CV-matchscoren på en jobb",
onboardingStepMatchAction: "Åpne jobber",
dashboardResponseRate: "{rate}% svarrate",
dashboardMonthsShort: "{count} md",
dashboardAppliedCount: "{count} søkt",
@@ -1552,11 +1594,12 @@ export const translations = {
adminSystemCpuMode: "CPU-modus",
adminSystemNoSmtpHost: "Ingen SMTP-vert konfigurert",
googleAccountTitle: "Google-konto",
googleSetupHint: "Sett `REACT_APP_GOOGLE_CLIENT_ID` i UI-miljøet ditt for å aktivere Google-innlogging og kontokobling.",
googleSetupHint: "Sett `NEXT_PUBLIC_GOOGLE_CLIENT_ID` i UI-miljøet ditt for å aktivere Google-innlogging og kontokobling.",
googleLinked: "Koblet",
googleAvailableToLink: "Tilgjengelig for kobling",
googleLinkedDate: "Koblet {date}",
googleSignInHint: "Logg inn med en Google-konto som allerede er koblet til Jobbjakt-brukeren din.",
googleSignInHintSelfServe: "Fortsett med Google. Ny her? Vi oppretter kontoen din automatisk.",
continueWithGoogle: "Fortsett med Google",
signInWithGoogle: "Logg inn med Google",
linkWithGoogle: "Koble til med Google",
@@ -1572,6 +1615,26 @@ export const translations = {
googleScriptLoadFailed: "Kunne ikke laste Google-autentiseringsskriptet.",
googleUnlinked: "Google-konto koblet fra.",
googleUnlinkFailed: "Kunne ikke koble fra Google-kontoen.",
microsoftAccountTitle: "Microsoft-konto",
microsoftSetupHint: "Sett `NEXT_PUBLIC_MICROSOFT_CLIENT_ID` i UI-miljøet ditt for å aktivere Microsoft-innlogging og kontokobling.",
microsoftLinked: "Koblet",
microsoftAvailableToLink: "Tilgjengelig for kobling",
microsoftLinkedDate: "Koblet {date}",
microsoftSignInHint: "Logg inn med en Microsoft-konto som allerede er koblet til Jobbjakt-brukeren din.",
continueWithMicrosoft: "Fortsett med Microsoft",
signInWithMicrosoft: "Logg inn med Microsoft",
linkWithMicrosoft: "Koble til med Microsoft",
microsoftLinkedTo: "Koblet til {email}.",
microsoftLinkedToYourAccount: "Koblet til Microsoft-kontoen din.",
microsoftBindHint: "Koble en Microsoft-konto til denne brukeren slik at du kan logge inn med Microsoft og fortsatt beholde vanlige approller og data.",
microsoftExchangeHint: "Bytt Microsoft-innloggingen din mot en vanlig Jobbjakt-økt.",
microsoftSignedIn: "Logget inn med Microsoft.",
microsoftLinkedSuccess: "Microsoft-konto koblet.",
microsoftLinkedSuccessWithEmail: "Koblet Microsoft-konto {email}.",
microsoftAuthFailed: "Microsoft-autentisering mislyktes.",
microsoftUnlinked: "Microsoft-konto koblet fra.",
microsoftUnlinkFailed: "Kunne ikke koble fra Microsoft-kontoen.",
unlinkMicrosoft: "Koble fra Microsoft",
signedOut: "Logget ut.",
signedInAs: "Logget inn som {name}.",
unlinkGoogle: "Koble fra Google",
@@ -1607,6 +1670,7 @@ export const translations = {
authOptional: "Autentisering er valgfri i dette miljøet.",
emailAndPassword: "E-post og passord",
google: "Google",
microsoft: "Microsoft",
createAccount: "Opprett konto",
signedIn: "Logget inn.",
rememberMe: "Husk meg",
@@ -1680,6 +1744,9 @@ export const translations = {
jobTableOverview: "Oversikt",
jobTableNoSummaryYet: "Ingen oppsummering ennå.",
jobTableNoJobsFound: "Ingen jobber funnet.",
jobTableEmptyFirstTimeTitle: "Ingen jobber ennå — la oss fikse det.",
jobTableEmptyFirstTimeBody: "Klikk \"Legg til jobb\" over for å legge til en manuelt, eller lim inn en lenke til en stillingsannonse. Det finnes også et bokmerke som fanger en annonse rett fra siden du ser på.",
jobTableEmptyFirstTimeBookmarklet: "Sett opp bokmerket",
jobTableSetStatus: "Sett {status}",
editJobTitle: "Rediger jobb",
editJobIntro: "Oppdater jobbdetaljer, status i tidslinjen, dokumenter og notater fra ett redigeringsområde.",
@@ -1824,6 +1891,7 @@ export const translations = {
jobDetailsFollowUpSent: "Oppfølging sendt og loggført.",
jobDetailsFollowUpSendFailed: "Kunne ikke sende oppfølging.",
jobDetailsHowYouMatch: "Slik matcher du",
jobDetailsAiFitHint: "AI-vurdering — styrker, svakheter og et skreddersydd pitch basert på CV-en din og denne annonsen.",
matchScoreTitle: "Match-score",
matchScoreLoading: "Vurderer CV-en mot denne stillingen…",
matchScoreBand_Strong: "Sterk match",
@@ -1832,7 +1900,7 @@ export const translations = {
matchScoreBand_Unknown: "For lite grunnlag",
matchScoreKeywordsCovered: "{matched}/{total} nøkkelord",
matchScoreNoSignal: "Legg til mer CV-innhold eller en fyldigere stillingstekst for en pålitelig score.",
matchScoreDeterministicHint: "Deterministisk nøkkelorddekning — ingen AI, så scoren er stabil og repeterbar.",
matchScoreDeterministicHint: "Deterministisk nøkkelorddekning — ingen AI, så scoren er stabil og repeterbar. For en skriftlig vurdering av styrker og svakheter, se AI-seksjonen under.",
matchScoreMatched: "Treff på nøkkelord",
matchScoreMissing: "Manglende nøkkelord",
matchScoreNoneYet: "Ingen treff ennå.",
-29
View File
@@ -1,29 +0,0 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import ErrorBoundary from "./components/ErrorBoundary";
import { I18nProvider } from './i18n/I18nProvider';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<React.StrictMode>
<LocalizationProvider dateAdapter={AdapterDateFns}>
<I18nProvider>
<ErrorBoundary>
<App />
</ErrorBoundary>
</I18nProvider>
</LocalizationProvider>
</React.StrictMode>
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
+43 -21
View File
@@ -25,7 +25,7 @@ import MenuOpenIcon from "@mui/icons-material/MenuOpen";
import NotificationsNoneIcon from "@mui/icons-material/NotificationsNone";
import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined";
import { ReactComponent as JobbjaktMark } from "../assets/jobbbjakt-mark.svg";
import JobbjaktMark from "../assets/JobbjaktMark";
import { useI18n } from "../i18n/I18nProvider";
export type NavItem = {
@@ -47,8 +47,19 @@ function initialsFrom(s?: string) {
const DESKTOP_SIDEBAR_KEY = "appShellDesktopSidebarCollapsed";
// The nav rail stays a fixed dark navy regardless of the app's light/dark theme toggle --
// a deliberate signature element, not derived from theme tokens.
const SIDEBAR_BG = "#0f172a";
const SIDEBAR_BORDER = "rgba(255,255,255,0.08)";
const SIDEBAR_TEXT_MUTED = "#94a3b8";
const SIDEBAR_TEXT = "#e2e8f0";
const SIDEBAR_SELECTED_BG = "rgba(99,102,241,0.18)";
const SIDEBAR_SELECTED_TEXT = "#ffffff";
const SIDEBAR_SELECTED_ICON = "#a5b4fc";
export default function AppShell({
pageTitle,
pageSubtitle,
breadcrumbs,
pathname,
nav,
@@ -66,6 +77,7 @@ export default function AppShell({
children,
}: {
pageTitle: string;
pageSubtitle?: string;
breadcrumbs: string[];
pathname: string;
nav: NavItem[];
@@ -122,7 +134,7 @@ export default function AppShell({
{groups.map(([section, rows]) => (
<Box key={section || "_"} sx={{ mb: desktopNavCollapsed ? 1 : 1.25 }}>
{section && !desktopNavCollapsed ? (
<Typography variant="caption" sx={{ px: 1.25, color: "text.secondary", fontWeight: 600, textTransform: "uppercase" }}>
<Typography variant="caption" sx={{ px: 1.25, color: SIDEBAR_TEXT_MUTED, fontWeight: 600, textTransform: "uppercase" }}>
{section}
</Typography>
) : null}
@@ -135,20 +147,25 @@ export default function AppShell({
selected={selected}
onClick={() => onNavigate(item.to)}
title={desktopNavCollapsed ? item.label : undefined}
sx={(muiTheme: any) => ({
sx={{
borderRadius: 2,
mb: 0.5,
minHeight: 44,
px: desktopNavCollapsed ? 1 : 1.5,
justifyContent: desktopNavCollapsed ? "center" : "flex-start",
border: "1px solid transparent",
color: SIDEBAR_TEXT_MUTED,
"&:hover": { backgroundColor: "rgba(255,255,255,0.06)", color: SIDEBAR_TEXT },
"&.Mui-selected": {
backgroundColor: muiTheme.vars.palette.action.hover,
borderColor: muiTheme.vars.palette.divider,
backgroundColor: SIDEBAR_SELECTED_BG,
color: SIDEBAR_SELECTED_TEXT,
},
})}
"&.Mui-selected:hover": {
backgroundColor: SIDEBAR_SELECTED_BG,
},
}}
>
<ListItemIcon sx={{ minWidth: desktopNavCollapsed ? 0 : 36, justifyContent: "center" }}>
<ListItemIcon sx={{ minWidth: desktopNavCollapsed ? 0 : 36, justifyContent: "center", color: selected ? SIDEBAR_SELECTED_ICON : SIDEBAR_TEXT_MUTED }}>
{item.badgeCount && item.badgeCount > 0 ? (
<Badge color="error" badgeContent={item.badgeCount > 99 ? "99+" : item.badgeCount}>
{item.icon}
@@ -166,16 +183,16 @@ export default function AppShell({
);
const drawerContent = (
<Box sx={{ height: "100%", display: "flex", flexDirection: "column" }}>
<Box sx={{ height: "100%", display: "flex", flexDirection: "column", backgroundColor: SIDEBAR_BG }}>
<Box sx={{ px: desktopNavCollapsed ? 1.5 : 2.25, py: desktopNavCollapsed ? 2 : 2.5, display: "flex", justifyContent: desktopNavCollapsed ? "center" : "flex-start" }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 1, justifyContent: desktopNavCollapsed ? "center" : "flex-start" }}>
<JobbjaktMark style={{ width: 22, height: 22 }} />
<JobbjaktMark style={{ width: 30, height: 30, flexShrink: 0 }} />
{!desktopNavCollapsed ? (
<Box>
<Typography variant="h6" sx={{ fontWeight: 600 }}>
<Typography variant="h6" sx={{ fontWeight: 700, color: SIDEBAR_SELECTED_TEXT }}>
Jobbjakt
</Typography>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
<Typography variant="caption" sx={{ color: SIDEBAR_TEXT_MUTED }}>
{t("appTagline")}
</Typography>
</Box>
@@ -183,13 +200,13 @@ export default function AppShell({
</Box>
</Box>
<Divider />
<Divider sx={{ borderColor: SIDEBAR_BORDER }} />
{renderNavList(grouped.top)}
<Box sx={{ flex: 1 }} />
<Divider />
<Divider sx={{ borderColor: SIDEBAR_BORDER }} />
{renderNavList(grouped.bottom)}
</Box>
@@ -406,19 +423,19 @@ export default function AppShell({
<Drawer
variant="permanent"
sx={(muiTheme: any) => ({
sx={{
display: { xs: "none", md: "block" },
width: drawerWidth,
flexShrink: 0,
[`& .MuiDrawer-paper`]: {
width: drawerWidth,
boxSizing: "border-box",
borderRight: `1px solid ${muiTheme.vars.palette.grey[300]}`,
backgroundColor: muiTheme.vars.palette.background.default,
borderRight: `1px solid ${SIDEBAR_BORDER}`,
backgroundColor: SIDEBAR_BG,
backgroundImage: "none",
boxShadow: "none",
},
})}
}}
open
>
<Toolbar sx={{ minHeight: { xs: 68, md: 76 } }} />
@@ -430,15 +447,15 @@ export default function AppShell({
open={drawerOpen}
onClose={() => onToggleDrawer(false)}
ModalProps={{ keepMounted: true }}
sx={(muiTheme: any) => ({
sx={{
display: { xs: "block", md: "none" },
[`& .MuiDrawer-paper`]: {
width: drawerWidth,
borderRight: `1px solid ${muiTheme.vars.palette.grey[300]}`,
backgroundColor: muiTheme.vars.palette.background.default,
borderRight: `1px solid ${SIDEBAR_BORDER}`,
backgroundColor: SIDEBAR_BG,
backgroundImage: "none",
},
})}
}}
>
{drawerContent}
</Drawer>
@@ -466,6 +483,11 @@ export default function AppShell({
<Typography variant="h5" sx={{ fontWeight: 600, overflowWrap: "anywhere" }}>
{pageTitle}
</Typography>
{pageSubtitle ? (
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.25, overflowWrap: "anywhere" }}>
{pageSubtitle}
</Typography>
) : null}
</Box>
</Box>
+1 -1
View File
@@ -3,7 +3,7 @@ import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import LoginPage from './pages/LoginPage';
import LoginPage from './views/LoginPage';
import { ToastProvider } from './toast';
import { I18nProvider } from './i18n/I18nProvider';
import { api } from './api';
+9 -1
View File
@@ -3,7 +3,7 @@ import '@testing-library/jest-dom';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { ToastProvider } from './toast';
import { I18nProvider } from './i18n/I18nProvider';
import ProfilePage from './pages/ProfilePage';
import ProfilePage from './views/ProfilePage';
import { api } from './api';
const createObjectURLMock = jest.fn(() => 'blob:mock-pdf');
@@ -270,6 +270,14 @@ test('profile page rewrite tools use selected template and saved job context', a
expect(screen.getByText(/clearer, sharper positioning for backend platform roles/i)).toBeInTheDocument();
expect(screen.getByRole('heading', { name: /pdf carousel/i })).toBeInTheDocument();
const showChangesToggle = screen.getByText(/show changes/i);
fireEvent.click(showChangesToggle);
expect(screen.queryByText(/clearer, sharper positioning for backend platform roles/i)).not.toBeInTheDocument();
expect(screen.getByText(/Clearer/i)).toBeInTheDocument();
fireEvent.click(showChangesToggle);
expect(screen.getByText(/clearer, sharper positioning for backend platform roles/i)).toBeInTheDocument();
const buildCarouselButton = screen.getByRole('button', { name: /build pdf carousel/i });
fireEvent.click(buildCarouselButton);
-1
View File
@@ -1 +0,0 @@
/// <reference types="react-scripts" />
-15
View File
@@ -1,15 +0,0 @@
import { ReportHandler } from 'web-vitals';
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;
+21 -34
View File
@@ -1,6 +1,6 @@
import React from 'react';
import '@testing-library/jest-dom';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { fireEvent, render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import SettingsView from './components/SettingsView';
@@ -21,35 +21,27 @@ jest.mock('./api', () => ({
}));
jest.mock('./components/ImportExportJobs', () => () => <div>Import Export Stub</div>);
jest.mock('./components/GoogleAuthCard', () => () => <div>Google Auth Stub</div>);
jest.mock('./components/BackupCard', () => () => <div>Backup Stub</div>);
jest.mock('./components/AuthStatusCard', () => () => <div>Auth Status Stub</div>);
const mockedApi = api as jest.Mocked<typeof api>;
function renderView(onAccentColorChange = jest.fn()) {
return {
onAccentColorChange,
...render(
<MemoryRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<ToastProvider>
<I18nProvider>
<SettingsView
pageSize={20}
onPageSizeChange={jest.fn()}
columns={{ status: true, dateApplied: true, daysSince: true, jobUrl: false }}
onColumnsChange={jest.fn()}
themeMode="dark"
onThemeModeChange={jest.fn()}
accentColor="#15803d"
onAccentColorChange={onAccentColorChange}
onResetAccentColor={jest.fn()}
/>
</I18nProvider>
</ToastProvider>
</MemoryRouter>,
),
};
function renderView() {
return render(
<MemoryRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<ToastProvider>
<I18nProvider>
<SettingsView
pageSize={20}
onPageSizeChange={jest.fn()}
columns={{ status: true, dateApplied: true, daysSince: true, jobUrl: false }}
onColumnsChange={jest.fn()}
themeMode="dark"
onThemeModeChange={jest.fn()}
/>
</I18nProvider>
</ToastProvider>
</MemoryRouter>,
);
}
beforeEach(() => {
@@ -76,15 +68,10 @@ afterEach(() => {
jest.clearAllMocks();
});
test('settings view uses one follow-up section, one notification section, and staged accent apply', async () => {
const { onAccentColorChange } = renderView();
test('settings view has no accent picker and uses one follow-up section, one notification section', async () => {
renderView();
fireEvent.click(screen.getByRole('button', { name: /#15803D/i }));
const accentInput = (await screen.findAllByLabelText('Accent'))[1] as HTMLInputElement;
fireEvent.change(accentInput, { target: { value: '#2563eb' } });
expect(onAccentColorChange).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: /^save$/i }));
expect(onAccentColorChange).toHaveBeenCalledWith('#2563eb');
expect(screen.queryByText(/accent/i)).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('tab', { name: /follow-ups/i }));
expect(await screen.findByText(/follow-up rules by scenario/i)).toBeInTheDocument();
+2 -1
View File
@@ -29,12 +29,13 @@ jest.mock('./api', () => ({
}));
jest.mock('./components/GoogleAuthCard', () => () => null);
jest.mock('./components/MicrosoftAuthCard', () => () => null);
beforeEach(() => {
const { api } = require('./api');
api.get.mockImplementation((url: string) => {
if (url === '/auth/config') {
return Promise.resolve({ data: { requireAuth: false, googleEnabled: false, localEnabled: true, allowRegistration: false } });
return Promise.resolve({ data: { requireAuth: false, googleEnabled: false, microsoftEnabled: false, localEnabled: true, allowRegistration: false } });
}
if (url === '/auth/me') {
return Promise.resolve({ data: { roles: [], email: 'demo@example.com', userName: 'demo' } });
+26
View File
@@ -0,0 +1,26 @@
import React from "react";
import { render, screen } from "@testing-library/react";
import "@testing-library/jest-dom";
import TextDiff from "./components/TextDiff";
describe("TextDiff", () => {
it("renders unchanged text without strike-through or highlight styling", () => {
render(<TextDiff before="Backend engineer." after="Backend engineer." />);
expect(screen.getByText("Backend engineer.")).toBeInTheDocument();
});
it("marks removed words with a distinct style from unchanged text", () => {
render(<TextDiff before="Assisted with the migration." after="Led the migration." />);
const removed = screen.getByText("Assisted with", { selector: "span" });
const unchanged = screen.getByText(/the migration\./, { selector: "span" });
// jsdom doesn't resolve emotion's generated CSS cascade for getComputedStyle, so assert the
// component branched into a different (MUI-generated) class for removed vs. unchanged text
// rather than the literal computed decoration value.
expect(removed.className).not.toBe(unchanged.className);
});
it("treats an empty before as an entirely new addition", () => {
render(<TextDiff before="" after="Brand new summary." />);
expect(screen.getByText(/Brand new summary\./)).toBeInTheDocument();
});
});
+13 -9
View File
@@ -2,6 +2,10 @@ import { alpha, createTheme, darken, lighten } from "@mui/material/styles";
type PaletteLike = Record<string, any>;
// Single global brand accent -- matches the dark sidebar/landing page indigo used throughout
// the app. Not user-configurable; see jobbjakt-nextjs-migration memory / UI rework notes.
const ACCENT = "#6366F1";
function buildPrimary(main: string) {
return {
lighter: lighten(main, 0.82),
@@ -12,7 +16,7 @@ function buildPrimary(main: string) {
};
}
function buildLightPalette(accentColor: string): PaletteLike {
function buildLightPalette(): PaletteLike {
const textPrimary = "#1B1B1F";
const textSecondary = "#46464F";
@@ -24,7 +28,7 @@ function buildLightPalette(accentColor: string): PaletteLike {
const disabledBackground = "#E4E1E6";
return {
primary: buildPrimary(accentColor || "#6366F1"),
primary: buildPrimary(ACCENT),
secondary: {
lighter: "#E0E0FF",
light: "#C3C4E4",
@@ -82,14 +86,14 @@ function buildLightPalette(accentColor: string): PaletteLike {
// from the product mockups; cards/inputs (paper) sit above it.
background: { default: "#F4F6FB", paper: background },
action: {
hover: alpha(accentColor || "#6366F1", 0.05),
hover: alpha(ACCENT, 0.05),
disabled: alpha(disabled, 0.6),
disabledBackground: alpha(disabledBackground, 0.9),
},
};
}
function buildDarkPalette(accentColor: string): PaletteLike {
function buildDarkPalette(): PaletteLike {
const bg = "#0B0B0E";
const paper = "#111116";
const divider = alpha("#FFFFFF", 0.10);
@@ -101,7 +105,7 @@ function buildDarkPalette(accentColor: string): PaletteLike {
const disabledBackground = alpha("#FFFFFF", 0.08);
return {
primary: buildPrimary(accentColor || "#6366F1"),
primary: buildPrimary(ACCENT),
secondary: {
lighter: alpha(secondaryMain, 0.22),
light: alpha(secondaryMain, 0.14),
@@ -157,7 +161,7 @@ function buildDarkPalette(accentColor: string): PaletteLike {
divider,
background: { default: bg, paper },
action: {
hover: alpha(accentColor || "#6366F1", 0.16),
hover: alpha(ACCENT, 0.16),
disabled: alpha("#FFFFFF", 0.5),
disabledBackground,
},
@@ -196,9 +200,9 @@ function buildTypography() {
};
}
export const getTheme = (_mode: "light" | "dark", accentColor: string) => {
const lightPalette = buildLightPalette(accentColor);
const darkPalette = buildDarkPalette(accentColor);
export const getTheme = (_mode: "light" | "dark") => {
const lightPalette = buildLightPalette();
const darkPalette = buildDarkPalette();
const theme = createTheme({
breakpoints: {
-14
View File
@@ -19,17 +19,3 @@ export function getThemeModePref(): ThemeModePref {
export function setThemeModePref(v: ThemeModePref) {
window.localStorage.setItem(k("themeMode"), v);
}
export function getAccentColor(): string {
const raw = window.localStorage.getItem(k("accentColor"));
if (raw && /^#[0-9a-fA-F]{6}$/.test(raw)) return raw;
return "#6366f1";
}
export function setAccentColor(v: string) {
if (v && /^#[0-9a-fA-F]{6}$/.test(v)) window.localStorage.setItem(k("accentColor"), v);
}
export function clearAccentColor() {
window.localStorage.removeItem(k("accentColor"));
}
@@ -82,10 +82,11 @@ export default function CorrespondenceInboxPage() {
Cross-job view of imported correspondence and Gmail-linked history.
</Typography>
</Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
<Chip icon={<MailOutlineIcon />} label={`${items.length} items`} variant="outlined" />
<Chip label={`${filteredSummary.linked} linked`} variant="outlined" color={filteredSummary.linked > 0 ? "success" : "default"} />
<Chip label={`${filteredSummary.inbound} inbound`} variant="outlined" />
<Button variant="outlined" size="small" onClick={() => navigate("/correspondence/review")}>Review Gmail queue</Button>
</Box>
</Box>
@@ -138,6 +138,7 @@ export default function GmailReviewPage() {
<Button variant="outlined" onClick={() => void load()} disabled={loading || syncing}>
{loading ? "Loading..." : "Refresh"}
</Button>
<Button variant="text" onClick={() => navigate("/correspondence")}>Back to inbox</Button>
</Box>
</Box>
@@ -50,7 +50,7 @@ export default function LandingPage() {
let active = true;
api
.get("/auth/me")
.then(() => { if (active) navigate("/jobs", { replace: true }); })
.then(() => { if (active) navigate("/dashboard", { replace: true }); })
.catch(() => { if (active) setChecking(false); });
return () => { active = false; };
}, [navigate]);
@@ -7,12 +7,14 @@ import { useLocation, useNavigate } from "react-router-dom";
import { api, getApiErrorMessage } from "../api";
import { getRememberMePref, setAuthPersistencePreference } from "../auth";
import GoogleAuthCard from "../components/GoogleAuthCard";
import MicrosoftAuthCard from "../components/MicrosoftAuthCard";
import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
type AuthConfig = {
requireAuth: boolean;
googleEnabled: boolean;
microsoftEnabled: boolean;
localEnabled: boolean;
allowRegistration: boolean;
};
@@ -31,7 +33,7 @@ export default function LoginPage() {
const [rememberMe, setRememberMe] = useState(() => getRememberMePref());
const [loading, setLoading] = useState(false);
const nextPath = (location?.state?.from as string | undefined) ?? "/jobs";
const nextPath = (location?.state?.from as string | undefined) ?? "/dashboard";
useEffect(() => {
api
@@ -81,6 +83,7 @@ export default function LoginPage() {
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 2 }}>
<Tab label={t("emailAndPassword")} />
<Tab label={t("google")} />
<Tab label={t("microsoft")} />
</Tabs>
{tab === 0 && (
@@ -123,6 +126,7 @@ export default function LoginPage() {
)}
{tab === 1 && <GoogleAuthCard onSignedIn={() => { navigate(nextPath, { replace: true }); }} />}
{tab === 2 && <MicrosoftAuthCard onSignedIn={() => { navigate(nextPath, { replace: true }); }} />}
</Paper>
</Box>
);
@@ -9,7 +9,11 @@ import ZoomInOutlinedIcon from "@mui/icons-material/ZoomInOutlined";
import { api, getApiErrorMessage } from "../api";
import GoogleAuthCard from "../components/GoogleAuthCard";
import MicrosoftAuthCard from "../components/MicrosoftAuthCard";
import AuthStatusCard from "../components/AuthStatusCard";
import EmailProviderConnections from "../components/EmailProviderConnections";
import CropImageDialog from "../components/CropImageDialog";
import TextDiff from "../components/TextDiff";
import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
import {
@@ -253,6 +257,7 @@ export default function ProfilePage() {
const [cvLanguage, setCvLanguage] = useState<CvBuilderLanguage>("English");
const [selectedRewriteJobId, setSelectedRewriteJobId] = useState<string>("");
const [rewritePreview, setRewritePreview] = useState<CvBuilderPreview | null>(null);
const [showRewriteDiff, setShowRewriteDiff] = useState(false);
const [rewritePreviewTemplate, setRewritePreviewTemplate] = useState<RewriteTemplateOption | null>(null);
const [pdfCarousel, setPdfCarousel] = useState<PdfCarouselItem[]>([]);
const [activePdfIndex, setActivePdfIndex] = useState(0);
@@ -351,6 +356,10 @@ export default function ProfilePage() {
const initials = useMemo(() => initialsFrom([me?.displayName, me?.firstName, me?.lastName, me?.userName, me?.email]), [me]);
const isLocal = me?.provider === "local";
// Career/CV features belong to every authenticated user regardless of auth provider.
// Only identity fields (password, email, provider-managed names) stay local-only. Gating CV
// controls on isLocal locked Google/Microsoft users out of their own CV builder.
const canEditCv = Boolean(me);
const fullName = [me?.firstName, me?.lastName].filter(Boolean).join(" ");
const cvWordCount = profileCvText.trim() ? profileCvText.trim().split(/\s+/).length : 0;
@@ -365,6 +374,11 @@ export default function ProfilePage() {
const selectedRewriteTemplate = REWRITE_TEMPLATES.find((option) => option.id === cvSectionStyle) ?? REWRITE_TEMPLATES[0];
const selectedRewriteJob = savedJobs.find((job) => String(job.id) === selectedRewriteJobId) ?? null;
const rewriteReady = Boolean(rewritePreview?.html && rewritePreview.fullText.trim());
// What the rewrite is replacing, so the preview can show a diff instead of silently swapping
// text out from under the user (career-workspace-implementation-roadmap.md Phase F5).
const rewriteBeforeText = rewritePreview?.sectionName
? structuredCv.sections.find((section) => section.name === rewritePreview.sectionName)?.content ?? ""
: profileCvText;
const activePdfItem = pdfCarousel[activePdfIndex] ?? null;
const releasePdfCarousel = useCallback((items: PdfCarouselItem[]) => {
@@ -561,7 +575,12 @@ export default function ProfilePage() {
</Box>
</Box>
<AuthStatusCard />
<GoogleAuthCard />
<MicrosoftAuthCard />
<Box sx={{ mt: 2 }}>
<EmailProviderConnections />
</Box>
<Box sx={{ mt: 3, display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
<Box sx={{ gridColumn: "1 / -1" }}>
@@ -618,12 +637,12 @@ export default function ProfilePage() {
}
}}
/>
<Button variant="outlined" disabled={!isLocal || uploadingCv || improvingCv || rebuildingCv} onClick={() => cvInputRef.current?.click()}>
<Button variant="outlined" disabled={!canEditCv || uploadingCv || improvingCv || rebuildingCv} onClick={() => cvInputRef.current?.click()}>
{uploadingCv ? t("profileUploading") : t("profileUploadCv")}
</Button>
<Button
variant="outlined"
disabled={!isLocal || !profileCvText.trim() || uploadingCv || improvingCv || rebuildingCv}
disabled={!canEditCv || !profileCvText.trim() || uploadingCv || improvingCv || rebuildingCv}
onClick={async () => {
setRebuildingCv(true);
try {
@@ -641,7 +660,7 @@ export default function ProfilePage() {
</Button>
<Button
variant="outlined"
disabled={!isLocal || !profileCvText.trim() || uploadingCv || improvingCv || rebuildingCv}
disabled={!canEditCv || !profileCvText.trim() || uploadingCv || improvingCv || rebuildingCv}
onClick={async () => {
setImprovingCv(true);
try {
@@ -659,7 +678,7 @@ export default function ProfilePage() {
</Button>
<Button
variant="outlined"
disabled={!isLocal || uploadingCv || improvingCv || rebuildingCv || reprocessingCv || !latestRun}
disabled={!canEditCv || uploadingCv || improvingCv || rebuildingCv || reprocessingCv || !latestRun}
onClick={async () => {
setReprocessingCv(true);
try {
@@ -699,7 +718,7 @@ export default function ProfilePage() {
helperText={t("profileCvTextHelp")}
multiline
minRows={12}
disabled={!isLocal}
disabled={!canEditCv}
fullWidth
/>
<Box sx={{ mt: 1.5, display: "flex", justifyContent: "flex-end" }}>
@@ -752,7 +771,7 @@ export default function ProfilePage() {
</Box>
<Button
variant="outlined"
disabled={!isLocal || !profileCvText.trim() || parsingCvSections}
disabled={!canEditCv || !profileCvText.trim() || parsingCvSections}
onClick={async () => {
setParsingCvSections(true);
try {
@@ -1122,7 +1141,7 @@ export default function ProfilePage() {
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
<Button
variant="contained"
disabled={!isLocal || (!profileCvText.trim() && structuredCv.sections.length === 0) || rewritingSection || uploadingCv || improvingCv || rebuildingCv}
disabled={!canEditCv || (!profileCvText.trim() && structuredCv.sections.length === 0) || rewritingSection || uploadingCv || improvingCv || rebuildingCv}
onClick={async () => {
setRewritingSection(true);
resetPdfCarousel();
@@ -1168,13 +1187,28 @@ export default function ProfilePage() {
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", xl: "0.9fr 1.1fr" }, gap: 1.5 }}>
<Paper sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "center", mb: 1 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "center", mb: 1, flexWrap: "wrap" }}>
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{rewritePreview?.sectionName || "Full rewritten CV text"}</Typography>
{rewriteReady ? <Chip size="small" color="success" label={`${(rewritePreview?.fullText || "").trim().split(/\s+/).filter(Boolean).length} words`} /> : null}
<Box sx={{ display: "flex", gap: 0.75, alignItems: "center" }}>
{rewriteReady ? (
<Chip
size="small"
variant={showRewriteDiff ? "filled" : "outlined"}
color={showRewriteDiff ? "primary" : "default"}
label="Show changes"
onClick={() => setShowRewriteDiff((current) => !current)}
/>
) : null}
{rewriteReady ? <Chip size="small" color="success" label={`${(rewritePreview?.fullText || "").trim().split(/\s+/).filter(Boolean).length} words`} /> : null}
</Box>
</Box>
<Box sx={{ minHeight: 220, maxHeight: 520, overflow: "auto", borderRadius: 2.5, backgroundColor: "background.default", border: "1px dashed", borderColor: "divider", p: 1.5 }}>
{rewriteReady ? (
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>{rewritePreview?.sectionName ? rewritePreview?.rewrittenText : rewritePreview?.fullText}</Typography>
showRewriteDiff ? (
<TextDiff before={rewriteBeforeText} after={rewritePreview?.sectionName ? rewritePreview?.rewrittenText ?? "" : rewritePreview?.fullText ?? ""} />
) : (
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>{rewritePreview?.sectionName ? rewritePreview?.rewrittenText : rewritePreview?.fullText}</Typography>
)
) : (
<Typography variant="body2" sx={{ color: "text.secondary" }}>Choose a template and generate a live preview. The builder will show rewritten content here and render the PDF layout beside it.</Typography>
)}
+17 -4
View File
@@ -1,6 +1,6 @@
{
"compilerOptions": {
"target": "es5",
"target": "es2017",
"lib": [
"dom",
"dom.iterable",
@@ -14,13 +14,26 @@
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"incremental": true,
"noEmit": true,
"jsx": "react-jsx"
"jsx": "react-jsx",
"plugins": [
{
"name": "next"
}
]
},
"include": [
"src"
"src",
"app",
"next-env.d.ts",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}