diff --git a/docs/work-programmes/master-progress.md b/docs/work-programmes/master-progress.md
index 048582c..4359c24 100644
--- a/docs/work-programmes/master-progress.md
+++ b/docs/work-programmes/master-progress.md
@@ -112,6 +112,7 @@ Updated: 2026-08-31
- Saved job-search implementation: tenant-owned NAV search definitions now retain per-vacancy first-seen, last-seen and dismissal state; repeat runs identify only genuinely new results, while one-off discovery remains unchanged. Account export/deletion and provider-safe schema ownership include the new records. Focused backend tests passed 3/3, focused frontend tests passed 6/6, schema/migration gates passed 20/20, ESLint passed with zero warnings, and the optimized Next build/TypeScript passed.
- Interview debrief loop: interview-stage workspaces now create an explicit, durable debrief in the existing user-owned preparation model, prompting for questions, strengths, improvements, feedback and next steps without generating answers. It remains editable/deletable and is localized in English and Bokmål. Focused backend tests passed 11/11, focused frontend tests passed 13/13, ESLint and the optimized Next build/TypeScript passed.
- Recruiter relationship view: each company now exposes a tenant-scoped view over its existing recruiter details, active applications, status, message totals and recent correspondence, with direct application navigation and no duplicate contact store. Focused backend authorization/data tests passed 2/2, the frontend interaction test passed 1/1, ESLint and the optimized Next build/TypeScript passed.
+- Offer comparison workspace: active applications in the existing Offer status now appear in a responsive side-by-side comparison of recorded compensation, location, decision deadline, next contact, next action and user notes. Missing facts remain explicitly unknown; no parallel offer record was introduced. Focused frontend tests passed 2/2, ESLint and the optimized Next build/TypeScript passed.
- Focused frontend: 2 suites, 6 tests passed.
- Full frontend: 64 suites, 272 tests passed.
- Next production build and TypeScript: passed.
diff --git a/job-tracker-ui/src/App.tsx b/job-tracker-ui/src/App.tsx
index b797fb9..55ada62 100644
--- a/job-tracker-ui/src/App.tsx
+++ b/job-tracker-ui/src/App.tsx
@@ -16,6 +16,7 @@ import SearchIcon from "@mui/icons-material/Search";
import MemoryIcon from "@mui/icons-material/Memory";
import DescriptionOutlinedIcon from "@mui/icons-material/DescriptionOutlined";
import MailOutlineIcon from "@mui/icons-material/MailOutline";
+import CompareArrowsIcon from "@mui/icons-material/CompareArrows";
import { Navigate, Route, Routes, useLocation, useNavigate, useParams, createBrowserRouter, RouterProvider } from "react-router-dom";
@@ -62,6 +63,7 @@ const AdminUsersPage = lazy(() => import("./views/AdminUsersPage"));
const AdminSystemPage = lazy(() => import("./views/AdminSystemPage"));
const CorrespondenceInboxPage = lazy(() => import("./views/CorrespondenceInboxPage"));
const OperationsPage = lazy(() => import("./views/OperationsPage"));
+const OfferComparisonPage = lazy(() => import("./views/OfferComparisonPage"));
const NotFoundPage = lazy(() => import("./views/NotFoundPage"));
type AuthConfig = { requireAuth: boolean };
@@ -93,6 +95,7 @@ function breadcrumbsFor(path: string, t: (k: any) => string): string[] {
if (path.startsWith("/operations")) return [t("home"), t("operations")];
if (path.startsWith("/kanban")) return [t("home"), t("kanbanBoard")];
if (path.startsWith("/companies")) return [t("home"), t("companies")];
+ if (path.startsWith("/offers")) return [t("home"), t("offerComparisonTitle")];
if (path.startsWith("/correspondence/review")) return [t("home"), t("gmailReviewQueue")];
if (path.startsWith("/correspondence")) return [t("home"), t("correspondenceInboxTitle")];
if (path.startsWith("/trash")) return [t("home"), t("trash")];
@@ -116,6 +119,7 @@ function titleFor(path: string, t: (k: any) => string): string {
if (path.startsWith("/jobs")) return t("jobApplications");
if (path.startsWith("/kanban")) return t("kanbanBoard");
if (path.startsWith("/companies")) return t("companies");
+ if (path.startsWith("/offers")) return t("offerComparisonTitle");
if (path.startsWith("/correspondence/review")) return t("gmailReviewQueue");
if (path.startsWith("/correspondence")) return t("correspondenceInboxTitle");
if (path.startsWith("/trash")) return t("trash");
@@ -137,6 +141,7 @@ function subtitleFor(path: string, t: (k: any) => string): string | undefined {
if (path.startsWith("/jobs")) return t("jobsPageSubtitle");
if (path.startsWith("/kanban")) return t("kanbanPageSubtitle");
if (path.startsWith("/reminders")) return t("remindersPageSubtitle");
+ if (path.startsWith("/offers")) return t("offerComparisonPageSubtitle");
if (path.startsWith("/correspondence/review")) return t("gmailReviewPageSubtitle");
if (path.startsWith("/correspondence")) return t("correspondencePageSubtitle");
return undefined;
@@ -287,6 +292,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
{ to: "/reminders", label: t("reminders"), icon: , badgeCount: reminderCount, section: t("manage") },
{ to: "/kanban", label: t("kanbanBoard"), icon: , section: t("manage") },
{ to: "/companies", label: t("companies"), icon: , section: t("manage") },
+ { to: "/offers", label: t("offerComparisonNav"), icon: , section: t("manage") },
{ to: "/correspondence", label: t("correspondenceInbox"), icon: , section: t("manage") },
{ to: "/career", label: t("careerWorkspace"), icon: , section: t("manage") },
{ to: "/career/builder", label: t("cvBuilder"), icon: , section: t("manage") },
@@ -376,6 +382,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
} />
} />
} />
+ } />
} />
} />
} />
diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts
index c1feaa5..b789106 100644
--- a/job-tracker-ui/src/i18n/translations.ts
+++ b/job-tracker-ui/src/i18n/translations.ts
@@ -1439,6 +1439,17 @@ export const translations = {
companiesRecentContact: "Recent contact",
companiesNoContact: "No correspondence has been recorded for this company.",
companiesContact: "Contact",
+ offerComparisonNav: "Compare offers",
+ offerComparisonTitle: "Offer comparison",
+ offerComparisonPageSubtitle: "Compare active offers using the facts already recorded on each application.",
+ offerComparisonHelp: "Review compensation, location, decision dates, next actions and your own notes side by side. Missing details stay visibly unknown.",
+ offerComparisonLoadFailed: "Could not load active offers.",
+ offerComparisonEmpty: "No active offers to compare",
+ offerComparisonEmptyHelp: "When an application reaches Offer, it will appear here automatically.",
+ offerComparisonCompensation: "Compensation",
+ offerComparisonDecisionDeadline: "Decision deadline",
+ offerComparisonNotes: "Notes and considerations",
+ offerComparisonNotRecorded: "Not recorded",
adminUsersTitle: "Users",
adminUsersSubtitle: "Admin-only user management.",
adminUsersCreateUser: "Create user",
@@ -3828,6 +3839,17 @@ export const translations = {
companiesRecentContact: "Nylig kontakt",
companiesNoContact: "Ingen korrespondanse er registrert for dette selskapet.",
companiesContact: "Kontakt",
+ offerComparisonNav: "Sammenlign tilbud",
+ offerComparisonTitle: "Sammenligning av tilbud",
+ offerComparisonPageSubtitle: "Sammenlign aktive tilbud med opplysningene som allerede er registrert i hver søknad.",
+ offerComparisonHelp: "Se lønn, sted, svarfrister, neste handling og egne notater side om side. Manglende opplysninger vises tydelig som ukjente.",
+ offerComparisonLoadFailed: "Kunne ikke laste aktive tilbud.",
+ offerComparisonEmpty: "Ingen aktive tilbud å sammenligne",
+ offerComparisonEmptyHelp: "Når en søknad får status Tilbud, vises den automatisk her.",
+ offerComparisonCompensation: "Kompensasjon",
+ offerComparisonDecisionDeadline: "Svarfrist",
+ offerComparisonNotes: "Notater og vurderinger",
+ offerComparisonNotRecorded: "Ikke registrert",
adminUsersTitle: "Brukere",
adminUsersSubtitle: "Brukeradministrasjon kun for administratorer.",
adminUsersCreateUser: "Opprett bruker",
diff --git a/job-tracker-ui/src/offer-comparison.test.tsx b/job-tracker-ui/src/offer-comparison.test.tsx
new file mode 100644
index 0000000..2dab04a
--- /dev/null
+++ b/job-tracker-ui/src/offer-comparison.test.tsx
@@ -0,0 +1,34 @@
+import React from "react";
+import "@testing-library/jest-dom";
+import { render, screen } from "@testing-library/react";
+import { MemoryRouter } from "react-router-dom";
+import { api } from "./api";
+import { I18nProvider } from "./i18n/I18nProvider";
+import OfferComparisonPage from "./views/OfferComparisonPage";
+
+jest.mock("./api", () => ({
+ api: { get: jest.fn() },
+ getApiErrorMessage: (_error: unknown, fallback: string) => fallback,
+}));
+const mockedApi = api as jest.Mocked;
+
+test("compares recorded offer facts without filling missing values", async () => {
+ mockedApi.get.mockResolvedValue({ data: { items: [
+ { id: 1, jobTitle: "Backend Engineer", company: { id: 1, name: "Acme" }, status: "Offer", salaryMin: 800000, salaryMax: 900000, salaryCurrency: "NOK", salaryPeriod: "year", location: "Oslo", deadline: "2026-09-10", nextAction: "Review contract", notes: "Hybrid three days", dateApplied: "2026-08-01", savedAt: "2026-07-20", responseReceived: true, daysSince: 10 },
+ { id: 2, jobTitle: "Platform Engineer", company: { id: 2, name: "Beta" }, status: "Offer", dateApplied: "2026-08-02", savedAt: "2026-07-21", responseReceived: true, daysSince: 9 },
+ ], total: 2, page: 1, pageSize: 25 } } as any);
+
+ render();
+
+ expect(await screen.findByText("Backend Engineer")).toBeInTheDocument();
+ expect(screen.getByText("800,000 – 900,000 NOK / year")).toBeInTheDocument();
+ expect(screen.getByText("Hybrid three days")).toBeInTheDocument();
+ expect(screen.getAllByText("Not recorded").length).toBeGreaterThan(0);
+ expect(mockedApi.get).toHaveBeenCalledWith("/jobapplications", { params: { status: "Offer", page: 1, pageSize: 25, sortBy: "deadline", sortDir: "asc" } });
+});
+
+test("shows an honest empty state when there are no offers", async () => {
+ mockedApi.get.mockResolvedValue({ data: { items: [], total: 0, page: 1, pageSize: 25 } } as any);
+ render();
+ expect(await screen.findByText("No active offers to compare")).toBeInTheDocument();
+});
diff --git a/job-tracker-ui/src/views/OfferComparisonPage.tsx b/job-tracker-ui/src/views/OfferComparisonPage.tsx
new file mode 100644
index 0000000..d5f2d15
--- /dev/null
+++ b/job-tracker-ui/src/views/OfferComparisonPage.tsx
@@ -0,0 +1,66 @@
+import { useCallback } from "react";
+import { Alert, Box, Button, Chip, Paper, Skeleton, Stack, Typography } from "@mui/material";
+import { useNavigate } from "react-router-dom";
+import { api } from "../api";
+import { useViewResource } from "../hooks/useViewResource";
+import { useI18n } from "../i18n/I18nProvider";
+import type { JobApplication } from "../types";
+
+type PagedJobs = { items: JobApplication[]; total: number; page: number; pageSize: number };
+
+export default function OfferComparisonPage() {
+ const navigate = useNavigate();
+ const { language, t } = useI18n();
+ const load = useCallback(() => api.get("/jobapplications", { params: { status: "Offer", page: 1, pageSize: 25, sortBy: "deadline", sortDir: "asc" } }).then((r) => r.data), []);
+ const resource = useViewResource(load, { initialData: { items: [], total: 0, page: 1, pageSize: 25 }, errorMessage: t("offerComparisonLoadFailed"), deps: [t] });
+ const date = (value?: string | null) => value ? new Date(value).toLocaleDateString(language === "nb" ? "nb-NO" : "en") : "—";
+
+ return (
+
+
+ {t("offerComparisonTitle")}
+ {t("offerComparisonHelp")}
+
+ {resource.loading ? {[0, 1].map((n) => )} : null}
+ {resource.error ? void resource.reload()}>{t("retry")}}>{resource.error.message} : null}
+ {!resource.loading && !resource.error && resource.data.items.length === 0 ? (
+
+ {t("offerComparisonEmpty")}
+ {t("offerComparisonEmptyHelp")}
+
+
+ ) : null}
+
+ {resource.data.items.map((offer) => (
+
+
+ {offer.jobTitle}{offer.company?.name}
+
+
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+ );
+}
+
+function Fact({ label, value, multiline = false }: { label: string; value: string; multiline?: boolean }) {
+ return {label}{value};
+}
+
+function formatSalary(job: JobApplication, fallback: string) {
+ if (job.salaryMin != null || job.salaryMax != null) {
+ const range = [job.salaryMin, job.salaryMax].filter((value) => value != null).map((value) => Number(value).toLocaleString()).join(" – ");
+ return `${range} ${job.salaryCurrency || ""}${job.salaryPeriod ? ` / ${job.salaryPeriod}` : ""}`.trim();
+ }
+ return job.salary || fallback;
+}