diff --git a/job-tracker-ui/src/components/InterviewPrep.tsx b/job-tracker-ui/src/components/InterviewPrep.tsx index 5db5a61..95f41b3 100644 --- a/job-tracker-ui/src/components/InterviewPrep.tsx +++ b/job-tracker-ui/src/components/InterviewPrep.tsx @@ -5,16 +5,22 @@ import { Skeleton, Stack, TextField, Tooltip, Typography, } from "@mui/material"; import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline"; +import AutoFixHighIcon from "@mui/icons-material/AutoFixHigh"; import { getApiErrorMessage } from "../api"; import { FollowUp, INTERVIEW_PREP_CATEGORIES, InterviewPrepBoard, InterviewPrepItem, interviewPrepApi, + ApplicationCv, applicationAssetsApi, } from "../applicationWorkspace"; +import { aiWorkspaceApi } from "../aiWorkspace"; +import { useAccountPlan } from "../accountPlan"; +import { useI18n } from "../i18n/I18nProvider"; +import { useConfirm } from "../confirm"; // Phase 5.5 — Interview preparation and follow-up. // -// The prep content is the user's: this component never generates anything. AI suggestions live in the -// AI panel below and only become prep items when the user adds them. +// The prep content is the user's. AI suggestions are generated in this dedicated workspace and only +// become prep items after an explicit accept action. // docs/architecture/application-workspace.md. function Shell({ title, subtitle, loading, error, children }: { @@ -41,6 +47,7 @@ function Shell({ title, subtitle, loading, error, children }: { } export function ApplicationInterviewPrep({ jobId }: { jobId: number }) { + const { t } = useI18n(); const [board, setBoard] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); @@ -87,15 +94,15 @@ export function ApplicationInterviewPrep({ jobId }: { jobId: number }) { return ( {board && !board.isInterviewStage && ( - This application has not reached an interview stage yet. Preparing early is fine. + {t("interviewPrepEarly")} )} @@ -103,16 +110,16 @@ export function ApplicationInterviewPrep({ jobId }: { jobId: number }) { - Preparation progress + {t("interviewPrepProgress")} - {board.prepared} of {board.total} ready + {t("interviewPrepReadyCount", { prepared: board.prepared, total: board.total })} @@ -120,8 +127,7 @@ export function ApplicationInterviewPrep({ jobId }: { jobId: number }) { {board && board.total === 0 ? ( - Nothing prepared yet. Add a question you expect, a company fact worth knowing, or a STAR - example you want ready. + {t("interviewPrepEmpty")} ) : ( (board?.groups ?? []).map((group) => ( @@ -150,7 +156,7 @@ export function ApplicationInterviewPrep({ jobId }: { jobId: number }) { setCategory(e.target.value)} @@ -163,22 +169,132 @@ export function ApplicationInterviewPrep({ jobId }: { jobId: number }) { setTitle(e.target.value)} /> - + + + ); } +const INTERVIEW_AI_FOCUS = [ + { key: "full", labelKey: "interviewAiFull", category: "note" }, + { key: "technical", labelKey: "interviewAiTechnical", category: "technical" }, + { key: "behavioural", labelKey: "interviewAiBehavioural", category: "behavioural" }, + { key: "star", labelKey: "interviewAiStar", category: "star" }, + { key: "gaps", labelKey: "interviewAiGaps", category: "note" }, +] as const; + +function InterviewAiAssistant({ jobId, onAccepted }: { jobId: number; onAccepted: () => void }) { + const { canUseAi } = useAccountPlan(); + const { language: uiLanguage, t } = useI18n(); + const [cv, setCv] = useState(null); + const [focus, setFocus] = useState<(typeof INTERVIEW_AI_FOCUS)[number]["key"]>("full"); + const [language, setLanguage] = useState<"en" | "nb-NO">(() => uiLanguage === "nb" ? "nb-NO" : "en"); + const [instructions, setInstructions] = useState(""); + const [suggestion, setSuggestion] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + let active = true; + applicationAssetsApi.cv(jobId) + .then((result) => { if (active) setCv(result); }) + .catch(() => { if (active) setCv(null); }); + return () => { active = false; }; + }, [jobId]); + + const selected = INTERVIEW_AI_FOCUS.find((item) => item.key === focus) ?? INTERVIEW_AI_FOCUS[0]; + const generate = async () => { + setBusy(true); + setError(null); + try { + const focusLabel = t(selected.labelKey); + const extraContext = [ + `Focus this preparation on: ${focusLabel}.`, + instructions.trim(), + ].filter(Boolean).join("\n"); + const result = await aiWorkspaceApi.generate(jobId, { + module: "interview", + targetLanguage: language, + extraContext, + }); + setSuggestion(result.result.text?.trim() ?? ""); + } catch (err) { + setError(getApiErrorMessage(err, t("interviewAiFailed"))); + } finally { + setBusy(false); + } + }; + + const accept = async () => { + if (!suggestion) return; + setBusy(true); + setError(null); + try { + await interviewPrepApi.add(jobId, { + category: selected.category, + title: t(selected.labelKey), + content: suggestion, + source: "ai", + }); + setSuggestion(""); + onAccepted(); + } catch (err) { + setError(getApiErrorMessage(err, t("interviewAiAcceptFailed"))); + } finally { + setBusy(false); + } + }; + + const hasCv = !!cv?.attachedVariantId; + return ( + + + {hasCv ? ( + {t("interviewAiUsing")} {cv?.attachedVariantName} + ) : ( + {t("interviewAiSelectCv")} + )} + + setFocus(event.target.value as typeof focus)} sx={{ minWidth: 220 }}> + {INTERVIEW_AI_FOCUS.map((item) => {t(item.labelKey)})} + + setLanguage(event.target.value as "en" | "nb-NO")} sx={{ minWidth: 190 }}> + English + Norsk bokmål + + + setInstructions(event.target.value)} /> + {error && {error}} + + {suggestion && ( + + {t("coverAiSuggestion")} + {suggestion} + + + + + + )} + + + ); +} + // One prep entry. The answer is a local draft until saved, so a background reload never eats typing. function PrepRow({ jobId, item, busy, onChanged, onError }: { jobId: number; @@ -187,6 +303,8 @@ function PrepRow({ jobId, item, busy, onChanged, onError }: { onChanged: () => void; onError: (message: string) => void; }) { + const { t } = useI18n(); + const { confirm } = useConfirm(); const [draft, setDraft] = useState(null); const [saving, setSaving] = useState(false); const content = draft ?? item.content ?? ""; @@ -212,7 +330,7 @@ function PrepRow({ jobId, item, busy, onChanged, onError }: { size="small" checked={item.isPrepared} disabled={busy || saving} - inputProps={{ "aria-label": `Ready: ${item.title}` }} + inputProps={{ "aria-label": t("interviewPrepReadyLabel", { title: item.title }) }} onChange={() => run(() => interviewPrepApi.update(jobId, item.id, { isPrepared: !item.isPrepared }))} sx={{ mt: -0.5 }} /> @@ -220,7 +338,7 @@ function PrepRow({ jobId, item, busy, onChanged, onError }: { {item.title} {item.source === "ai" && ( - + )} setDraft(e.target.value)} @@ -242,19 +360,27 @@ function PrepRow({ jobId, item, busy, onChanged, onError }: { disabled={saving} onClick={() => run(() => interviewPrepApi.update(jobId, item.id, { content }))} > - Save answer + {t("interviewPrepSaveAnswer")} - + )} - + run(() => interviewPrepApi.remove(jobId, item.id))} + aria-label={t("interviewPrepDeleteLabel", { title: item.title })} + onClick={async () => { + const accepted = await confirm({ + title: t("interviewPrepDeleteTitle"), + message: t("interviewPrepDeleteMessage", { title: item.title }), + confirmLabel: t("interviewPrepDelete"), + destructive: true, + }); + if (accepted) await run(() => interviewPrepApi.remove(jobId, item.id)); + }} > @@ -266,6 +392,7 @@ function PrepRow({ jobId, item, busy, onChanged, onError }: { } export function ApplicationFollowUp({ jobId }: { jobId: number }) { + const { t } = useI18n(); const [data, setData] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); @@ -307,16 +434,15 @@ export function ApplicationFollowUp({ jobId }: { jobId: number }) { return ( {data && data.openFollowUpTasks > 0 && ( - {data.openFollowUpTasks} open follow-up {data.openFollowUpTasks === 1 ? "task" : "tasks"} on - the checklist. + {t(data.openFollowUpTasks === 1 ? "interviewFollowUpOpenTask" : "interviewFollowUpOpenTasks", { count: data.openFollowUpTasks })} )} @@ -324,7 +450,7 @@ export function ApplicationFollowUp({ jobId }: { jobId: number }) { setDate(e.target.value)} @@ -333,17 +459,17 @@ export function ApplicationFollowUp({ jobId }: { jobId: number }) { setAction(e.target.value)} /> - + {data && !data.followUpAt && ( - No follow-up scheduled. Applications without one go quiet. + {t("interviewFollowUpEmpty")} )} diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index 1c61f11..c4159eb 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -91,6 +91,46 @@ export const translations = { confirmDestructiveHint: "This action may be hard to undo.", confirmStandardHint: "Please confirm this action.", enterValue: "Enter value", + interviewPrepTitle: "Interview preparation", + interviewPrepSubtitle: "Your research, answers and questions. AI suggestions are only saved when you accept them.", + interviewPrepEarly: "This application has not reached an interview stage yet. Preparing early is fine.", + interviewPrepProgress: "Interview preparation progress", + interviewPrepReadyCount: "{prepared} of {total} ready", + interviewPrepEmpty: "Nothing prepared yet. Add an expected question, a company fact worth knowing, or a STAR example you want ready.", + interviewPrepCategory: "Category", + interviewPrepAddLabel: "Add a question, topic or note", + interviewPrepAdd: "Add", + interviewAiTitle: "AI interview coach", + interviewAiSubtitle: "Uses the linked CV, job advert and application analysis. Suggestions never overwrite your notes.", + interviewAiUsing: "Using", + interviewAiSelectCv: "Select a CV on the CV tab before generating tailored interview preparation.", + interviewAiFocus: "Preparation focus", + interviewAiFull: "Complete interview brief", + interviewAiTechnical: "Technical questions", + interviewAiBehavioural: "Behavioural questions", + interviewAiStar: "STAR preparation", + interviewAiGaps: "Weak areas and likely concerns", + interviewAiGenerate: "Generate preparation", + interviewAiGenerating: "Generating…", + interviewAiProRequired: "Pro required", + interviewAiAddToPrep: "Add to preparation", + interviewAiFailed: "Could not generate interview preparation.", + interviewAiAcceptFailed: "Could not add the suggestion to your preparation.", + interviewPrepReadyLabel: "Ready: {title}", + interviewPrepFromAi: "From AI", + interviewPrepAnswerPlaceholder: "Your answer, in your own words.", + interviewPrepSaveAnswer: "Save answer", + interviewPrepDiscard: "Discard", + interviewPrepDelete: "Delete", + interviewPrepDeleteLabel: "Delete: {title}", + interviewPrepDeleteTitle: "Delete preparation item?", + interviewPrepDeleteMessage: "Delete “{title}”? This cannot be undone.", + interviewFollowUpTitle: "Follow-up", + interviewFollowUpSubtitle: "Uses the same date as reminders. Follow-up tasks remain in the application checklist.", + interviewFollowUpOpenTask: "{count} open follow-up task on the checklist.", + interviewFollowUpOpenTasks: "{count} open follow-up tasks on the checklist.", + interviewFollowUpDate: "Follow up on", + interviewFollowUpEmpty: "No follow-up scheduled. Applications without one can go quiet.", notFoundTitle: "Page not found", notFoundBody: "The page you were looking for does not exist or may have moved.", appErrorTitle: "Something went wrong", @@ -1318,6 +1358,46 @@ export const translations = { confirmDestructiveHint: "Denne handlingen kan være vanskelig å angre.", confirmStandardHint: "Bekreft denne handlingen.", enterValue: "Skriv inn verdi", + interviewPrepTitle: "Intervjuforberedelse", + interviewPrepSubtitle: "Din research, dine svar og spørsmål. AI-forslag lagres bare når du godtar dem.", + interviewPrepEarly: "Søknaden har ikke nådd intervjustadiet ennå. Det er helt greit å forberede seg tidlig.", + interviewPrepProgress: "Fremdrift for intervjuforberedelse", + interviewPrepReadyCount: "{prepared} av {total} klare", + interviewPrepEmpty: "Ingenting er forberedt ennå. Legg til et forventet spørsmål, et relevant selskapsfakta eller et STAR-eksempel.", + interviewPrepCategory: "Kategori", + interviewPrepAddLabel: "Legg til spørsmål, tema eller notat", + interviewPrepAdd: "Legg til", + interviewAiTitle: "AI-intervjutrening", + interviewAiSubtitle: "Bruker tilknyttet CV, stillingsannonse og søknadsanalyse. Forslag overskriver aldri notatene dine.", + interviewAiUsing: "Bruker", + interviewAiSelectCv: "Velg en CV i CV-fanen før du genererer tilpasset intervjuforberedelse.", + interviewAiFocus: "Fokus for forberedelsen", + interviewAiFull: "Komplett intervjuforberedelse", + interviewAiTechnical: "Tekniske spørsmål", + interviewAiBehavioural: "Atferdsbaserte spørsmål", + interviewAiStar: "STAR-forberedelse", + interviewAiGaps: "Svake områder og sannsynlige bekymringer", + interviewAiGenerate: "Generer forberedelse", + interviewAiGenerating: "Genererer…", + interviewAiProRequired: "Krever Pro", + interviewAiAddToPrep: "Legg til i forberedelsen", + interviewAiFailed: "Kunne ikke generere intervjuforberedelse.", + interviewAiAcceptFailed: "Kunne ikke legge forslaget til i forberedelsen.", + interviewPrepReadyLabel: "Klar: {title}", + interviewPrepFromAi: "Fra AI", + interviewPrepAnswerPlaceholder: "Svaret ditt, med egne ord.", + interviewPrepSaveAnswer: "Lagre svar", + interviewPrepDiscard: "Forkast", + interviewPrepDelete: "Slett", + interviewPrepDeleteLabel: "Slett: {title}", + interviewPrepDeleteTitle: "Slette forberedelseselement?", + interviewPrepDeleteMessage: "Slette «{title}»? Dette kan ikke angres.", + interviewFollowUpTitle: "Oppfølging", + interviewFollowUpSubtitle: "Bruker samme dato som påminnelser. Oppfølgingsoppgaver forblir i søknadssjekklisten.", + interviewFollowUpOpenTask: "{count} åpen oppfølgingsoppgave i sjekklisten.", + interviewFollowUpOpenTasks: "{count} åpne oppfølgingsoppgaver i sjekklisten.", + interviewFollowUpDate: "Følg opp den", + interviewFollowUpEmpty: "Ingen oppfølging er planlagt. Søknader uten oppfølging kan bli stille.", notFoundTitle: "Siden ble ikke funnet", notFoundBody: "Siden du lette etter finnes ikke eller kan ha blitt flyttet.", appErrorTitle: "Noe gikk galt", diff --git a/job-tracker-ui/src/interview-prep.test.tsx b/job-tracker-ui/src/interview-prep.test.tsx index fa9a0f3..275b15b 100644 --- a/job-tracker-ui/src/interview-prep.test.tsx +++ b/job-tracker-ui/src/interview-prep.test.tsx @@ -1,9 +1,13 @@ import React from "react"; import "@testing-library/jest-dom"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, render as rtlRender, screen, waitFor } from "@testing-library/react"; import { ApplicationFollowUp, ApplicationInterviewPrep } from "./components/InterviewPrep"; import { api } from "./api"; +import { I18nProvider } from "./i18n/I18nProvider"; +import { ConfirmProvider } from "./confirm"; + +const render = (ui: React.ReactElement) => rtlRender({ui}); jest.mock("./api", () => ({ api: { @@ -48,6 +52,7 @@ const followUp = { followUpAt: "2026-07-26T00:00:00", nextAction: "Chase recruit function routeGet(overrides: Record = {}) { mockedApi.get.mockImplementation((url: string) => { if (url.endsWith("/follow-up")) return Promise.resolve({ data: overrides.followUp ?? followUp } as any); + if (url.endsWith("/cv")) return Promise.resolve({ data: overrides.cv ?? { attachedVariantId: 3, attachedVariantName: "Backend CV" } } as any); return Promise.resolve({ data: overrides.board ?? board } as any); }); } @@ -116,6 +121,7 @@ test("deleting a prep item calls delete", async () => { render(); fireEvent.click(await screen.findByRole("button", { name: "Delete: Funding history" })); + fireEvent.click(await screen.findByRole("button", { name: "Delete" })); await waitFor(() => expect(mockedApi.delete).toHaveBeenCalledWith("/jobapplications/7/interview-prep/1")); }); @@ -129,6 +135,36 @@ test("empty prep shows a useful empty state", async () => { expect(screen.getByText(/has not reached an interview stage/i)).toBeInTheDocument(); }); +test("AI preparation uses the linked CV and only saves after explicit acceptance", async () => { + routeGet(); + mockedApi.post.mockImplementation((url: string) => { + if (url.endsWith("/ai/generate")) { + return Promise.resolve({ data: { result: { text: "## Technical\nExplain dependency injection." } } } as any); + } + return Promise.resolve({ data: board.groups[0].items[0] } as any); + }); + + render(); + expect(await screen.findByText("Backend CV")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Generate preparation" })); + + expect(await screen.findByText(/Explain dependency injection/i)).toBeInTheDocument(); + expect(mockedApi.post).toHaveBeenCalledWith( + "/jobapplications/7/ai/generate", + expect.objectContaining({ module: "interview", targetLanguage: "en" }), + ); + expect(mockedApi.post).not.toHaveBeenCalledWith( + "/jobapplications/7/interview-prep", + expect.anything(), + ); + + fireEvent.click(screen.getByRole("button", { name: "Add to preparation" })); + await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith( + "/jobapplications/7/interview-prep", + expect.objectContaining({ source: "ai", content: expect.stringContaining("dependency injection") }), + )); +}); + test("prep surfaces a load error", async () => { mockedApi.get.mockRejectedValue(new Error("boom"));